Environment Variables
Required
Section titled “Required”| Variable | Used by | Default | Description |
|---|---|---|---|
API_KEY |
API | - | Bearer token required for authenticated /v1/* API requests. |
DATABASE_URL |
API/worker | Local test database URL | PostgreSQL connection string. Set explicitly outside local development. |
hub-worker requires an explicit DATABASE_URL; it does not use the local test
default.
Server
Section titled “Server”| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP port used inside the API process/container. |
PUBLIC_BASE_URL |
- | Public API root advertised in runtime OpenAPI specs when Hub is behind ingress, TLS, or a path prefix. |
LOG_LEVEL |
info |
Log level, such as debug, info, warn, or error. |
SHUTDOWN_TIMEOUT_SECONDS |
30 |
Graceful shutdown timeout in seconds. |
LOG_LEVEL also applies to the job-queue lines hub-worker logs for its
background jobs. A failed job attempt is logged at info, with the job id,
kind, and error, so warn or error hides failures that a job does not report
itself. At info the job queue also logs a status line per busy queue at most
every 5 seconds, and a summary when its maintenance work (scheduling retries,
cleaning up finished jobs) did something. debug adds the job queue’s internal
tracing (fetches, completions, notifications), whose volume grows with job
throughput.
So that an outage cannot flood the logs, hub-worker samples repeated job-queue
lines. Once one job kind logs the same line more than 10 times in a second, it
keeps 1 in 100 for the rest of that second. Job-queue lines that carry no job
kind are sampled the same way, per message. The dropped lines are counted: the
next time that line is logged, or when hub-worker shuts down, a
similar log lines suppressed by sampling line reports the count and the window
(since, until) it covers.
hub-worker’s other lines are never sampled.
The Quick Start Docker Compose file also supports HUB_PORT, POSTGRES_PORT,
and HUB_IMAGE_TAG as Compose conveniences. They are compose variables, not Hub
runtime settings.
PUBLIC_BASE_URL must be an absolute http or https URL. It may include a
path prefix, such as https://example.com/hub, but must not include query
strings, fragments, or credentials. Hub serves runtime OpenAPI specs at
/openapi.yaml and /openapi.json.
Database Pool
Section titled “Database Pool”| Variable | Default | Description |
|---|---|---|
DATABASE_MAX_CONNS |
25 |
Maximum PostgreSQL pool connections. |
DATABASE_MIN_CONNS |
0 |
Minimum PostgreSQL pool connections. |
DATABASE_MAX_CONN_LIFETIME_SECONDS |
3600 |
Maximum connection lifetime. |
DATABASE_MAX_CONN_IDLE_TIME_SECONDS |
1800 |
Maximum idle time before a connection is closed. |
DATABASE_HEALTH_CHECK_PERIOD_SECONDS |
60 |
Pool health-check interval. |
DATABASE_CONNECT_TIMEOUT_SECONDS |
10 |
Database connection timeout. |
River Worker
Section titled “River Worker”| Variable | Default | Description |
|---|---|---|
RIVER_JOB_TIMEOUT_SECONDS |
0 |
Max job runtime before cancellation; 0 uses the River default. |
RIVER_RESCUE_STUCK_JOBS_AFTER_SECONDS |
0 |
Stuck-job rescue interval; 0 uses the River default. |
RIVER_COMPLETED_JOB_RETENTION_SECONDS |
86400 |
Completed job retention period; -1 disables deletion. |
RIVER_CLIENT_ID |
- | Optional client identifier for logs and River coordination. |
Webhooks
Section titled “Webhooks”| Variable | Default | Description |
|---|---|---|
WEBHOOK_DELIVERY_MAX_CONCURRENT |
100 |
Max concurrent webhook delivery workers. |
WEBHOOK_DELIVERY_MAX_ATTEMPTS |
3 |
Max delivery attempts per webhook job. |
WEBHOOK_MAX_FAN_OUT_PER_EVENT |
500 |
Max webhook jobs inserted for a single event batch. |
WEBHOOK_MAX_COUNT |
500 |
Max total webhook endpoints allowed. |
WEBHOOK_HTTP_TIMEOUT_SECONDS |
15 |
Outbound webhook request timeout. |
WEBHOOK_ENQUEUE_MAX_RETRIES |
3 |
Retries when enqueueing webhook jobs fails. |
WEBHOOK_ENQUEUE_INITIAL_BACKOFF_MS |
100 |
Initial enqueue retry backoff in milliseconds. |
WEBHOOK_ENQUEUE_MAX_BACKOFF_MS |
2000 |
Maximum enqueue retry backoff in milliseconds. |
WEBHOOK_BLACKLIST |
localhost,127.0.0.1,::1,169.254.169.254 |
Comma-separated hostnames blocked for webhook URLs. |
Event Publisher
Section titled “Event Publisher”| Variable | Default | Description |
|---|---|---|
MESSAGE_PUBLISHER_QUEUE_MAX_SIZE |
16384 |
In-memory event channel buffer size. |
MESSAGE_PUBLISHER_PER_EVENT_TIMEOUT_SECONDS |
10 |
Per-event publish timeout in seconds. |
Enrichment
Section titled “Enrichment”Hub runs four independent text enrichments — embeddings, translation, sentiment,
and emotions — processed asynchronously by hub-worker. Each is enabled per type
by setting its *_PROVIDER and *_MODEL; if either is unset, that enrichment is
disabled and no jobs are enqueued.
Each enrichment also has a *_MAX_INPUT_BYTES cap on the text it sends to its provider, in
UTF-8 bytes (0 disables it). No accepted record can exceed the 30,000-rune ingest ceiling —
roughly 120,000 bytes, and about 90,000 for CJK — so a value at or above that has no effect
whatever; the defaults are deliberately far below it, because the caps exist to bound cost and,
for embeddings, to stay inside the model’s own limit.
All four accept the same providers: openai, google (Gemini Developer API /
Google AI Studio), and google-gemini (Gemini Enterprise Agent Platform API).
openai and google authenticate with the enrichment’s *_PROVIDER_API_KEY;
google-gemini uses Google Cloud Application Default Credentials and reads
*_GOOGLE_CLOUD_PROJECT / *_GOOGLE_CLOUD_LOCATION, falling back to the global
GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION (set GOOGLE_APPLICATION_CREDENTIALS
to a service-account key path when running outside Google Cloud). A custom
*_BASE_URL is honored only with openai.
Embeddings
Section titled “Embeddings”| Variable | Default | Description |
|---|---|---|
EMBEDDING_PROVIDER |
- | Embedding provider (openai, google, or google-gemini; legacy google-vertex maps to google-gemini). Set with EMBEDDING_MODEL to enable. |
EMBEDDING_PROVIDER_API_KEY |
- | API key for openai / google. |
EMBEDDING_MODEL |
- | Embedding model name. |
EMBEDDING_BASE_URL |
- | OpenAI-compatible embeddings root URL. Use with EMBEDDING_PROVIDER=openai to target a self-hosted endpoint. See Self-Hosted Embeddings. |
EMBEDDING_MAX_CONCURRENT |
5 |
Max concurrent embedding jobs. |
EMBEDDING_MAX_ATTEMPTS |
5 |
Max embedding attempts. |
EMBEDDING_JOB_TIMEOUT_SECONDS |
60 |
Deadline for one embedding job. |
EMBEDDING_MAX_INPUT_BYTES |
12000 |
Max bytes of a record sent to the embedding provider; over it the input is trimmed to its first N bytes, so an over-long record still gets a vector. 0 disables the cap. |
EMBEDDING_NORMALIZE |
false |
Whether to normalize embeddings before storage. |
EMBEDDING_GOOGLE_CLOUD_PROJECT |
- | Google Cloud project (for google-gemini). |
EMBEDDING_GOOGLE_CLOUD_LOCATION |
- | Google Cloud location (for google-gemini). |
EMBEDDING_RECONCILE_ENABLED |
false |
Periodically repair embeddings on hub-worker (see Embedding repair). Opt in per deployment. |
EMBEDDING_RECONCILE_INTERVAL_SECONDS |
300 |
How often the repair sweep runs. |
EMBEDDING_RECONCILE_RETRY_AFTER_SECONDS |
900 |
Cooldown before a record whose attempts ran out on a transient error is retried. |
EMBEDDING_RECONCILE_TARGET_DEPTH |
100 |
Each sweep tops the embeddings_reconcile queue up to this many runnable jobs, shared fairly between the search and taxonomy embeddings. See How long recovery takes. |
EMBEDDING_RECONCILE_MAX_CONCURRENT |
1 |
Worker concurrency reserved for repaired records, separate from live embedding work. |
Embedding repair
Section titled “Embedding repair”hub-api queues a record’s embedding jobs in the same transaction that writes it, so a crash
cannot lose them; if the jobs cannot be queued, the write fails and the client can retry. What can
still leave a record without a current vector is a job that runs out of attempts. With
EMBEDDING_RECONCILE_ENABLED=true and an embedding provider configured, a sweep on hub-worker
(on River’s elected leader) re-queues every record whose vector is:
- missing: the record has text but no vector for the current model;
- stale: the text the vector was computed from has changed since (for the taxonomy embedding, this includes the translation);
- orphaned: the record no longer has any text. The repair deletes the vector without calling the provider.
This covers the search embedding always, and the taxonomy embedding when TAXONOMY_SERVICE_URL
is set. A repair job that finds the vector already current (because a live job got there first)
skips the provider. Records whose attempts ran out on a transient error are retried after
EMBEDDING_RECONCILE_RETRY_AFTER_SECONDS. Records the provider rejected outright are not retried
until the record is written again (the rejection is tied to the record’s revision, so an edit, or
an enrichment such as translation or sentiment landing on it, makes it eligible once more).
Vectors written before Hub recorded what they were computed from are trusted as current. Changing
the model’s document prefix or EMBEDDING_MAX_INPUT_BYTES does not mark existing vectors stale
either: those are deployment settings, not record content.
How long recovery takes. This applies only with EMBEDDING_RECONCILE_ENABLED=true; without
it, nothing repairs a record automatically. A record whose attempts ran out is re-queued at the
first sweep after its cooldown. Each sweep tops the repair lane up to
EMBEDDING_RECONCILE_TARGET_DEPTH jobs, shared between the search and taxonomy embeddings, so
with both backlogged each model drains about TARGET_DEPTH ÷ 2 records per sweep (with the
defaults, 100 every 300 seconds: roughly 600 an hour per model); with only one model backlogged it
gets the whole depth. The lane also runs only EMBEDDING_RECONCILE_MAX_CONCURRENT jobs at a time,
so when a provider call takes longer than about interval ÷ (TARGET_DEPTH ÷ MAX_CONCURRENT) (three
seconds with the defaults), worker throughput sets the pace instead. A job left running by a
worker that died is not retried until River’s stuck-job rescue (RIVER_RESCUE_STUCK_JOBS_AFTER_SECONDS,
one hour when unset), and the sweep leaves that record alone until then; set it a little above
EMBEDDING_JOB_TIMEOUT_SECONDS to shorten that. A provider answering 429 snoozes a job without
using an attempt for up to an hour from when the job was created, then lets it fail normally. For a
faster drain, use the backfill below.
For incident recovery, backfill-embeddings queues the same work on demand, on the live
embeddings queue so it drains at full worker concurrency. It selects what the sweep would, except
that it does not wait out the transient-failure cooldown: it is meant to run once the provider has
recovered. Records with a job already in flight (including the sweep’s) and records the provider
rejected outright are skipped, so running it twice queues nothing the second time.
| Flag | Description |
|---|---|
-tenant-id <id> |
Restrict to one tenant (feedback directory). Without it the backfill runs across the deployment. |
-taxonomy |
Backfill the taxonomy embedding instead of the search embedding. |
-max-records <n> |
Stop after n records, to run in bounded batches (for example 100, then 500 at a time). |
-count-only |
Print how many records are owed work without queueing anything. |
-stale |
Also re-embed vectors written before Hub recorded what they were computed from. Needs -tenant-id. |
-retry-terminal |
Also retry records the provider rejected outright, once the cause is fixed (say, a lower input cap). Needs -tenant-id. |
-cooldown <d> |
Skip records whose attempts ran out on a transient error less than d ago (for example 24h), to keep a record that keeps failing out of repeated batch runs. Default 0: retry them at once. |
Each run logs one line when it starts and one when it finishes, with its scope and counts, as the record of what was done.
Upgrading to the release that adds repair of stale vectors. Vectors from before it carry no
record of what they were computed from. While old and new hub-worker pods overlap during the
rollout, an old pod can rewrite a vector without updating that record, so a record edited and then
edited back inside the rollout window can keep the vector of its middle text. To rule this out,
stop the old workers before the new ones start (or let the rollout finish first and then clear the
record for vectors written during it: UPDATE embeddings SET source_hash = NULL WHERE updated_at BETWEEN <rollout start> AND <rollout end>, followed by backfill-embeddings -tenant-id <id> -stale
for the affected tenants). Search-embedding failures are recorded for the repair sweep but do not
appear in hub_enrichment_failed_records, which counts taxonomy embedding failures only.
Translation
Section titled “Translation”| Variable | Default | Description |
|---|---|---|
TRANSLATION_PROVIDER |
- | Translation provider (openai, google, or google-gemini; legacy google-vertex maps to google-gemini). Set with TRANSLATION_MODEL to enable. |
TRANSLATION_PROVIDER_API_KEY |
- | API key for openai / google. |
TRANSLATION_MODEL |
- | Translation model name. |
TRANSLATION_BASE_URL |
- | OpenAI-compatible base URL; honored only with openai. |
TRANSLATION_DEFAULT_LANGUAGE |
- | Fallback target language (BCP-47, e.g. en-US) for tenants with no target_language. Empty makes translation per-tenant opt-in. |
TRANSLATION_MAX_CONCURRENT |
5 |
Max concurrent translation jobs. |
TRANSLATION_RECONCILE_MAX_CONCURRENT |
2 |
Max concurrent translation jobs on the reconcile lane (see Reconciliation). |
TRANSLATION_MAX_ATTEMPTS |
3 |
Max translation attempts. |
TRANSLATION_MAX_INPUT_BYTES |
24000 |
Max bytes of a record sent to the translation provider. Over it the record is skipped — not trimmed, since a partial translation stored as complete is wrong — and marked as a terminal failure, without calling the provider. An edit that brings the text back under the cap re-runs it; raising this value does not clear markers already written (a tenant clears them with the retry endpoint). 0 disables the cap. |
TRANSLATION_GOOGLE_CLOUD_PROJECT |
- | Google Cloud project (for google-gemini). |
TRANSLATION_GOOGLE_CLOUD_LOCATION |
- | Google Cloud location (for google-gemini). |
Each record is translated into its tenant’s target_language (set via the tenant
settings API); TRANSLATION_DEFAULT_LANGUAGE supplies the target for tenants that
have not set one.
Sentiment
Section titled “Sentiment”| Variable | Default | Description |
|---|---|---|
SENTIMENT_PROVIDER |
- | Sentiment provider (openai, google, or google-gemini). Set with SENTIMENT_MODEL to enable. |
SENTIMENT_PROVIDER_API_KEY |
- | API key for openai / google. |
SENTIMENT_MODEL |
- | Sentiment model name. |
SENTIMENT_BASE_URL |
- | OpenAI-compatible base URL; honored only with openai. |
SENTIMENT_MAX_CONCURRENT |
5 |
Max concurrent sentiment jobs. |
SENTIMENT_RECONCILE_MAX_CONCURRENT |
2 |
Max concurrent sentiment jobs on the reconcile lane (see Reconciliation). |
SENTIMENT_MAX_ATTEMPTS |
3 |
Max sentiment attempts. |
SENTIMENT_MAX_INPUT_BYTES |
24000 |
Max bytes of a record sent to the sentiment provider; over it the input is trimmed to its first N bytes. 0 disables the cap. |
SENTIMENT_GOOGLE_CLOUD_PROJECT |
- | Google Cloud project (for google-gemini). |
SENTIMENT_GOOGLE_CLOUD_LOCATION |
- | Google Cloud location (for google-gemini). |
Emotions
Section titled “Emotions”| Variable | Default | Description |
|---|---|---|
EMOTIONS_PROVIDER |
- | Emotion provider (openai, google, or google-gemini). Set with EMOTIONS_MODEL to enable. |
EMOTIONS_PROVIDER_API_KEY |
- | API key for openai / google. |
EMOTIONS_MODEL |
- | Emotion model name. |
EMOTIONS_BASE_URL |
- | OpenAI-compatible base URL; honored only with openai. |
EMOTIONS_MAX_CONCURRENT |
5 |
Max concurrent emotion jobs. |
EMOTIONS_RECONCILE_MAX_CONCURRENT |
2 |
Max concurrent emotion jobs on the reconcile lane (see Reconciliation). |
EMOTIONS_MAX_ATTEMPTS |
3 |
Max emotion attempts. |
EMOTIONS_MAX_INPUT_BYTES |
24000 |
Max bytes of a record sent to the emotion provider; over it the input is trimmed to its first N bytes. 0 disables the cap. |
EMOTIONS_GOOGLE_CLOUD_PROJECT |
- | Google Cloud project (for google-gemini). |
EMOTIONS_GOOGLE_CLOUD_LOCATION |
- | Google Cloud location (for google-gemini). |
Sentiment and emotions additionally honor per-directory switches (sentiment_enabled,
emotions_enabled) set through the tenant settings API;
both default to on when the enrichment is configured.
Reconciliation
Section titled “Reconciliation”Enrichment is enqueued by events as records arrive, which is fast but not complete: a record created while a provider was unconfigured has no job at all, a job can be lost to a crash, and a transient outage can exhaust a record’s retries. The reconciler closes that gap. It periodically finds every record that is eligible for an enrichment, has not received one, and has not failed terminally, and enqueues the difference. Events remain the fast path; this is what makes coverage eventually complete.
It runs on hub-worker, on River’s elected leader, so a deployment with several workers sweeps
once between them rather than once each. Disabling it does not affect the event path — it only
stops coverage from healing itself.
| Variable | Default | Description |
|---|---|---|
ENRICHMENT_RECONCILE_ENABLED |
true |
Master switch for the periodic sweep. Set it identically on hub-api and hub-worker — see below. |
ENRICHMENT_RECONCILE_INTERVAL_SECONDS |
300 |
How often the sweep runs. |
ENRICHMENT_RECONCILE_TARGET_DEPTH |
1000 |
Per-queue top-up target: each tick adds only enough to bring a reconcile queue to this depth. Clamped to 100000. |
Reconciled work runs on its own *_reconcile queue per enrichment, with its own worker budget
(*_RECONCILE_MAX_CONCURRENT above), so draining a large backlog cannot crowd out a record
submitted a moment ago. Topping up to a depth rather than enqueueing at a rate makes the sweep
self-regulating — a tick can only add what the workers have already drained — so the job table
stays bounded whether the backlog is a thousand records or millions.
ENRICHMENT_RECONCILE_ENABLED must carry the same value on both hub-api and hub-worker.
The sweep itself runs on the worker, but the API reads the flag too, to decide whether clearing
terminal failures could ever lead to anything. Disable it on the worker alone — the natural thing
to do for a worker-only feature, since that is where the Helm chart’s worker.extraEnv applies —
and the API still accepts a retry: it deletes the markers, spends the tenant’s cooldown, drops
those records out of the status endpoint’s failed counts, and no sweep ever picks them up. Set it
on the API alone and every retry is refused with a 409 while the worker sweeps normally.
Records that failed terminally are excluded. A terminal failure means retrying cannot change the
outcome, because the outcome is a property of the record’s own text: the provider rejected it on
structural grounds — a content-policy block, a refusal, input past the model’s limit — or the record
is over the enrichment’s own configured cap, which Hub refuses before calling anything
(TRANSLATION_MAX_INPUT_BYTES; the other three trim instead). A sweep that re-enqueued them would
never terminate, and for the provider-rejected case every attempt would spend a call that cannot
succeed. To give those records another chance after the underlying reason has changed (the text was
edited, a provider relaxed a policy, or you raised the cap — raising it does not clear markers
already written), clear their markers with POST /v1/tenants/{tenant_id}/enrichments/retry. That
endpoint is rate-limited by a per-tenant, per-enrichment cooldown, because clearing the markers is
what makes the next sweep spend money on records already known to fail.
Taxonomy
Section titled “Taxonomy”Taxonomy clusters embedded feedback into a tree of topics via a standalone service. It requires embeddings (see Embeddings) to be configured.
| Variable | Default | Description |
|---|---|---|
TAXONOMY_SERVICE_URL |
- | Absolute http(s) URL Hub calls to run clustering. Unset disables taxonomy. |
TAXONOMY_SERVICE_TOKEN |
- | Bearer token Hub sends to the taxonomy service. |
HUB_INTERNAL_API_TOKEN |
- | Token the taxonomy service presents to call Hub’s internal callback endpoints. Required to enable the internal routes. |
TAXONOMY_MIN_EMBEDDED_RECORDS |
20 |
Minimum embedded records a scope needs before a run may start. |
Tenant Settings Cache
Section titled “Tenant Settings Cache”The translation, sentiment, and emotion enqueue providers each resolve a per-tenant setting
(target_language, sentiment_enabled, emotions_enabled) on every feedback event. They share a
small in-process LRU cache over tenant settings so that resolution does not hit the database on each
event; a settings write evicts the tenant’s entry so a change is visible immediately.
| Variable | Default | Description |
|---|---|---|
TENANT_SETTINGS_CACHE_SIZE |
2048 |
Maximum number of tenants cached (LRU). Set to 0 to disable the cache. |
TENANT_SETTINGS_CACHE_TTL_SECONDS |
60 |
How long a cached settings entry stays fresh, in seconds. |
Observability
Section titled “Observability”| Variable | Default | Description |
|---|---|---|
OTEL_METRICS_EXPORTER |
- | OpenTelemetry metrics exporter. |
OTEL_TRACES_EXPORTER |
- | OpenTelemetry traces exporter. |
With OTEL_METRICS_EXPORTER set, Hub emits the metrics listed in the Metrics reference.