fix: stop the Hub API from requiring enrichment credentials, and honor LOG_LEVEL in hub-worker (ENG-1916) - #114
Conversation
The Hub's six River job kinds and their queues were spelled out in three hand-maintained places: the worker registration gates in internal/workers/wiring.go, the inline registration in cmd/api/app.go, and riverDepthQueues for the queue-depth gauge. Adding a kind meant touching all three, and drift was only observable at runtime. Add service.JobKindSpecs as the declaration, plus JobQueueNames for callers that want queues rather than kinds, and derive riverDepthQueues from it. wiring.go keeps naming its own queues, because it pairs each with a per-enrichment MaxWorkers that this list has no notion of; a later commit asserts the two agree rather than collapsing them, since deriving would mean inventing a kind-to-enrichment grouping just to satisfy the abstraction. No behaviour change: the derived queue set is identical to the list it replaces.
The API built translation, sentiment, and emotions LLM clients at startup and passed each to exactly one consumer: river.AddWorker. It never worked those jobs — its River client is only ever Stop()ed, never Start()ed; hub-worker runs the workers. So an optional, worker-only feature could take down the whole HTTP API: point GOOGLE_APPLICATION_CREDENTIALS at a file the container cannot read and the API exits 1 with "sentiment config: ... failed to find default credentials", nothing listens on :8080, and every Hub call fails with a bare "fetch failed". In a cluster, enabling google-gemini without the matching IAM binding does the same to all feedback ingestion, not just enrichment. The registration existed to satisfy River's insert-time unknown-kind check, but that check only runs when Workers is non-nil, and queue declaration is never required to insert. So the requirement was self-imposed: build the client insert-only and it disappears, along with the API's need for credentials it cannot use. Fail-fast is deliberately kept where it belongs — hub-worker still exits on a missing credential, because silently disabling a configured provider would turn a visible boot failure into an unnoticed data-quality outage. This only narrows the blast radius to the process that actually needs the credential. Embeddings are unaffected: the API keeps its embedding client, which search uses synchronously to embed the incoming query.
It existed for one caller: the API, which passed 1 because it registered workers it never ran and wanted no real concurrency reserved for them. Now that the API's River client is insert-only and registers nothing, hub-worker is the only caller and it always passed 0, so every queue takes its configured concurrency and the override is unreachable. Removing it means the tests exercise the same path production does, rather than a placeholder mode nothing uses.
Making the API's River client insert-only gave up River's insert-time unknown-kind check: with Workers nil, inserting a kind nothing registers is accepted, so a kind added to the insert side without a matching worker would strand jobs with no error anywhere. internal/workers had no test for its wiring at all, so add one and put that invariant in it, checked against service.JobKindSpecs — that pairing is what lets wiring.go keep declaring its own queues without the two drifting. River exposes no way to enumerate a bundle's registered kinds (workersMap is unexported with no accessor), so registration is observed with AddWorkerSafely, which errors only on a duplicate kind. Both guards were confirmed to fail when the invariant breaks: dropping the sentiment registration fails the parity test on its missing queue, and sourcing a queue's MaxWorkers from the wrong enrichment fails the concurrency test. Also pins the disabled-enrichment shape — a nil client leaves out both worker and queue, rather than registering a worker that would fail every job.
WalkthroughThe API now creates an insert-only River client and performs embedding search synchronously without registering embedding workers. Shared job specifications derive queue names, while worker wiring uses configured concurrency and optional-client registration. API and worker startup logging now uses centralized observability helpers with case-insensitive log-level parsing and info fallback. Tests cover River client validation, embedding handler setup, worker registration and concurrency, and log-level parsing. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
hub-worker never configured slog, so it ran on Go's default handler at info
regardless of LOG_LEVEL, while hub-api honored it. That matters for ENG-1916
specifically: when a missing enrichment credential takes down the worker, the
worker's logs are the only place the actionable error appears, and its debug
lines were unreachable.
Move the level parsing and handler setup into internal/observability, which
already owns the logging handler, and call it from both entrypoints. Living
there also means the logic is covered by make test-unit, which runs ./cmd/api
and ./internal/... but not ./cmd/worker.
Note for operators: this changes hub-worker's log stream and format, not just
its level. It previously used Go's default handler, which writes to stderr as
2026/07/28 10:37:10 INFO Worker running client_id=...
and now matches hub-api, writing to stdout as
time=2026-07-28T10:37:10.962Z level=INFO msg="Worker running" client_id=...
Container log collection is unaffected, but anything parsing hub-worker output
or separating stderr from stdout needs updating. hub-api is unchanged,
including configuring logging before reporting a config-load failure so a
broken config still produces output. Unrecognized levels keep falling back to
info.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/service/job_kinds.go`:
- Around line 41-56: Add a focused unit test for JobQueueNames that exercises
duplicate-queue handling by using or arranging job-kind specs with at least two
entries sharing the same queue, then assert the returned queue names contain
that queue only once. Keep the test centered on the seen-map deduplication path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c66e63a4-a113-4f27-9afe-e5bbe9be1bc2
📒 Files selected for processing (10)
cmd/api/app.gocmd/api/app_test.gocmd/api/main.gocmd/worker/app.gocmd/worker/main.gointernal/observability/logging.gointernal/observability/logging_test.gointernal/service/job_kinds.gointernal/workers/wiring.gointernal/workers/wiring_test.go
CodeRabbit noted the dedup in JobQueueNames was unreachable in tests, and it was right: every declared kind owns its own queue, so the duplicate branch never ran. Split it into distinctQueues(specs) so a test can pass specs that share a queue, and pin the kind-to-queue pairing while there, since the API's queue-depth gauge and the worker-registration parity test both read it.
|
Fixed in 699ac69 — you were right, the dedup branch was unreachable because every declared kind owns its own queue. Rather than test it indirectly, I split it into |
The variable appeared nowhere in .env.example or docs/, despite being required by the google-gemini (Vertex) enrichment providers and forwarded to both Hub containers — so the only way to discover it was to read the compose file or hit the crash it causes. Document it, note that compose now mounts the host file into the containers so the path here does not need to exist inside them, and steer local dev toward the `google` provider, which authenticates with an API key and needs no credentials file at all. Deliberately additive: the existing note about a missing credential crash-looping both containers stays as-is, because it is still true of the Hub release this repo pins. It becomes wrong only once the pin moves to a release containing formbricks/hub#114, so the correction belongs to that version bump.
The note claimed a provider without credentials crash-loops *both* Hub containers. That is true of the currently pinned 0.8.1 and stops being true at 0.8.3, which carries formbricks/hub#114: the API only enqueues there, never builds the provider clients, and stays up serving requests. Verified against the tag -- cmd/api/app.go at 0.8.3 has no "sentiment config" failure path, while cmd/worker still does. Worded by version rather than by what is pinned, so it reads correctly either side of the #8758 bump and does not go stale again when the pin moves. hub-worker's fail-fast stays documented as deliberate: silently disabling a configured enrichment would turn a visible boot failure into an unnoticed data-quality outage. Originally written for #8758, which is the bump that invalidates the note, but that branch is locked in the merge queue -- and this PR already edits the same file, so the correction lands here instead.
The variable appeared nowhere in .env.example or docs/, despite being required by the google-gemini (Vertex) enrichment providers and forwarded to both Hub containers — so the only way to discover it was to read the compose file or hit the crash it causes. Document it, note that compose now mounts the host file into the containers so the path here does not need to exist inside them, and steer local dev toward the `google` provider, which authenticates with an API key and needs no credentials file at all. Deliberately additive: the existing note about a missing credential crash-looping both containers stays as-is, because it is still true of the Hub release this repo pins. It becomes wrong only once the pin moves to a release containing formbricks/hub#114, so the correction belongs to that version bump.
The note claimed a provider without credentials crash-loops *both* Hub containers. That is true of the currently pinned 0.8.1 and stops being true at 0.8.3, which carries formbricks/hub#114: the API only enqueues there, never builds the provider clients, and stays up serving requests. Verified against the tag -- cmd/api/app.go at 0.8.3 has no "sentiment config" failure path, while cmd/worker still does. Worded by version rather than by what is pinned, so it reads correctly either side of the #8758 bump and does not go stale again when the pin moves. hub-worker's fail-fast stays documented as deliberate: silently disabling a configured enrichment would turn a visible boot failure into an unnoticed data-quality outage. Originally written for #8758, which is the bump that invalidates the note, but that branch is locked in the merge queue -- and this PR already edits the same file, so the correction lands here instead.
What does this PR do?
Local Hub crashes on boot when the sentiment/emotion providers are configured but the Google credentials file isn't reachable inside the container — the API exits 1 with
sentiment config: ... failed to find default credentials, nothing listens on:8080, and every web-side Hub call fails with a genericfetch failed.Linear: https://linear.app/formbricks/issue/ENG-1916/dev-hub-crashes-on-boot-when-gemini-enrichment-creds-arent-mounted
The root cause turned out to be a bit more interesting than a dev-env problem. The API was building sentiment, emotions, and translation LLM clients at startup and passing each to exactly one consumer:
river.AddWorker. But the API's River client is neverStart()ed —hub-workerruns the workers — so the API cannot work those jobs even in principle. It was constructing credentialed Vertex clients it can never call, and any one of them failing took the whole HTTP API down with it.The registration existed to satisfy River's insert-time unknown-kind check, but that check only runs when
Workersis non-nil (client.go:2228-2239), and declaring a queue is never required in order to insert onto it (client.go:1693-1695). So the requirement was self-imposed: building the client insert-only (river.Config{}, bothWorkersandQueuesomitted — River rejects setting one without the other) makes it disappear, and with it the API's need for credentials it can't use.This is not the graceful degradation the ticket proposed, on purpose.
hub-workerstill fails fast when a credential is missing, because silently disabling a provider somebody explicitly configured turns a visible boot failure into an unnoticed data-quality outage — you'd deploy green and quietly enrich nothing for weeks. Config errors should fail loudly; what was wrong here was the blast radius, not the fail-fast. This narrows it to the process that actually needs the credential.It's worth flagging that this is production-relevant and not only a local-dev annoyance: enable
google-geminiin Helm values without the matching IAM binding and today the entire API CrashLoopBackOffs, taking down all feedback ingestion rather than just enrichment.Embeddings are deliberately untouched — the API keeps its embedding client, because search uses it synchronously to embed the incoming query. So
EMBEDDING_PROVIDER=google-geminidoes still require credentials in the API, and that's correct.Trade-off
Going insert-only gives up River's insert-time
UnknownJobKindError, so a kind added to the insert side without a matching worker would strand jobs silently.internal/workershad no test for its wiring at all, so this adds one and puts that invariant in it, checked against a newservice.JobKindSpecs. River exposes no way to enumerate a bundle's registered kinds (workersMapis unexported with no accessor), so registration is observed viaAddWorkerSafely, which errors only on a duplicate kind. Net effect: the guard moves from boot time (where it needed credentials) to test time (where it doesn't).JobKindSpecsalso collapsesriverDepthQueues, which was a third hand-maintained copy of the queue set.wiring.gostill names its own queues, since it pairs each with a per-enrichmentMaxWorkersthat the list has no notion of — the two are kept in step by assertion rather than derivation, which is what the parity test is for.Dropping
placeholderMaxWorkersis fallout: it existed only for the API (which passed1for workers it never ran), so with the API registering nothing,hub-workeris the only caller and it always passed0.One thing for reviewers to weigh
A deployment can now have a healthy API happily enqueueing jobs while
hub-workercrash-loops — enrichment stops while ingestion continues. That's the intended narrowing, but it does move detection off "the API is down" and onto the queue signals. Both are preserved: the River queue-depth gauge (now derived fromJobKindSpecs, so a new kind can't be added without its queue appearing on the gauge) and the enrichment-backlog gauge. Happy to add an explicit alert suggestion if that feels thin.Not closed by this PR
ENG-1916 also asks for the dev
docker-compose.dev.ymlcredential mount and an.env.exampleguardrail. Those live in the formbricks monorepo and come in a follow-up — and that follow-up is the part that actually gets Gemini enrichment running locally, which is what the ticket was filed for. This PR stops the API dying; it doesn't make enrichment work.Also here:
hub-workernow honorsLOG_LEVELThis came up while debugging the above.
hub-workernever configuredslog, so it ignoredLOG_LEVELentirely and ran on Go's default handler, whilehub-apihonored it. That matters more after this change, because once the API stops dying the worker's logs are the only place the actionable credential error appears — and its debug lines were unreachable.The level parsing and handler setup move into
internal/observability, which already owns the logging handler, and both entrypoints call it. Living there also means the logic is covered bymake test-unit, which runs./cmd/apiand./internal/...but not./cmd/worker.Heads-up for operators — the worker's output changes, not just its level. It previously wrote to stderr as:
and now matches
hub-api, writing to stdout as:Container log collection is unaffected (both streams are captured), but anything parsing
hub-workeroutput or relying on stderr/stdout separation needs updating. Worth a line in the release notes.hub-apiis unchanged, including that it configures logging before reporting a config-load failure so a broken config still produces output — the worker now does the same. Unrecognized levels still fall back to info rather than silencing logs. One pre-existing oddity preserved rather than fixed: only the exact stringwarnmatches, soLOG_LEVEL=warningfalls back to info. Pinned in a test; happy to make it acceptwarningif that reads as a trap.How should this be tested?
Unit, lint and integration:
Note on
make testslocally: it sources.env, and if yourDATABASE_URLpoints at a Postgres that isn't running you'll get a wall of connection failures unrelated to this change. Against a livetest_dbit's green:End-to-end, reproducing the ticket. Build this branch, point
HUB_IMAGE_TAGat it, and set the enrichment providers with a credentials path that does not exist inside the container:Expected, and what I saw:
ghcr.io/formbricks/hub:0.8.1ERROR Failed to create application→ exit 1INFO Starting server port=8080GET /health200POST /v1/feedback-records201, record persistedhub-workerThen confirm inserts still land correctly with no
Workers/Queuesdeclared — this is the load-bearing bit, so worth checking rather than trusting:All six kinds should appear on their own queues, matching
service.JobKindSpecsexactly:(
webhook_dispatchneeds a webhook registered first;tenant_translation_backfillis triggered by aPATCH /v1/tenants/{tenant_id}/settingswritingtarget_language.)Both new guards were checked by breaking them deliberately, so they aren't just passing decoration: dropping the sentiment registration from
wiring.gofails the parity test on its missing queue (queue "sentiments" for kind "feedback_sentiment" missing from queue config), and sourcing a queue'sMaxWorkersfrom the wrong enrichment fails the concurrency test (MaxWorkers = 5, want 4).For the
LOG_LEVELpart, run the worker briefly withLOG_LEVEL=erroragainst any validDATABASE_URL/API_KEY:ghcr.io/formbricks/hub:0.8.1still printsINFOlines in the default-handler format (2026/07/28 ... INFO Worker running ...), i.e. the level is ignored. This branch prints nothing atINFO, and withLOG_LEVELunset printstime=... level=INFO msg="Worker running" ..., matchinghub-api.ParseLogLevelalso has a table test covering casing and the unknown-value fallback.Beyond that I exercised the wider surface to check nothing regressed: full CRUD on feedback records (create/get/list/count/patch/delete, plus 401 unauthenticated and 404 after delete), tenant settings get/patch, semantic search returning 503 with embeddings disabled and reaching the provider when enabled, the worker draining a real
webhook_dispatchjob (attempted and retried on a non-2xx), and SIGTERM giving a clean exit 0 withServer stopped/Worker stoppedon both binaries.Enrichment itself was verified end to end against a stubbed OpenAI-compatible endpoint (no credentials, no external egress):
feedback_sentimentandfeedback_emotionsboth reachedcompletedon the first attempt and the values persisted on the record. Disabling sentiment for the tenant then produced an emotions job but no sentiment job, so the per-tenant enrichment gate still applies.Checklist
Required
make buildmake tests(integration tests intests/) — green against a livetest_db, see the note abovemake fmtandmake lint; no new warningsgit pull origin mainmigrations/— n/a, no schema changeAppreciated
docs/if changes were necessary — n/a here; the env-var guardrail lands in the formbricks follow-upmake tests-coveragefor meaningful logic changes — 74.3% total, comfortably over the 15% gate