Skip to content

feat: expose per-tenant enrichment status and backlog metric (ENG-1670) - #111

Merged
xernobyl merged 13 commits into
mainfrom
feat/ENG-1670_enrichment-status
Jul 28, 2026
Merged

feat: expose per-tenant enrichment status and backlog metric (ENG-1670)#111
xernobyl merged 13 commits into
mainfrom
feat/ENG-1670_enrichment-status

Conversation

@xernobyl

@xernobyl xernobyl commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Gives the product a way to surface enrichment progress ("in progress" / "done") for a feedback directory, the way we already do for embeddings.

New endpoint: GET /v1/enrichment-status?tenant_id=<id> returns, per record-level enrichment (translation, sentiment, emotions):

{
  "tenant_id": "",
  "translation": { "enabled": true, "eligible": 500, "done": 480 },
  "sentiment":   { "enabled": true, "eligible": 500, "done": 500 },
  "emotions":    { "enabled": false, "eligible": 0, "done": 0 }
}
  • eligible = feedback records that qualify for the enrichment (open-text with content, and the enrichment is on for the tenant — a resolvable target language for translation, the per-directory switch for sentiment/emotions).
  • done = how many have been enriched. The UI derives "in progress" as eligible - done (same as the embeddings banner).
  • enabled folds in the deployment-level provider/model gate; when it's false the counts are zero.

Design note — this is data-derived, not queue-derived. The counts come from feedback_records (mirroring how /v1/taxonomy/fields reports record_count/embedding_count), not from the River queue. That choice is deliberate: it gives the progress-bar denominator the queue can't, and — importantly — it needs no changes to the ingest/enqueue path, so a burst of a few thousand incoming records is unaffected (no new index on the insert path, no per-job tagging). The per-tenant query is served by the existing idx_feedback_records_tenant_field_type.

Also included

A schema change — emotions_classified_at (migration 020). Review surfaced that emotions could never reach "done": a successful classification that detects no emotion is stored as NULL (the 015 CHECK rejects the empty array), which is the same value that means "never classified". Those records would have counted as pending forever and the backlog gauge could never drain. The new column records completion independently of the labels found:

  • SetEmotions stamps it (an empty result is still a completed classification); a new ClearEmotions clears both, for the content-is-gone path.
  • The eager-clear on a value_text edit drops the marker with the labels, so an edited record correctly returns to pending.
  • done is (emotions_classified_at IS NOT NULL OR emotions IS NOT NULL) — the second arm covers rows enriched before the column existed, which is why the migration needs no bulk UPDATE of the primary write table.
  • Bonus: the classify backfill now skips already-classified rows instead of re-sending every classified-empty record to the provider on every run.

The marker is processing state and is deliberately kept out of feedbackRecordColumns / the API model, so there is no response-shape, OpenAPI or SDK change. Sentiment and translation are unaffected — their successful results are single-valued and always non-NULL (verified); emotions is the only enrichment whose success can legitimately be "nothing".

Backlog metric. hub_enrichment_pending_records{enrichment} — an OpenTelemetry gauge of the aggregate (cross-tenant) eligible-but-unenriched backlog, a durable completeness signal complementing the transient River queue-depth gauge. It only runs when metrics are enabled, is bounded by a 30s statement timeout, and polls every 5 minutes (a full-table aggregate over a high-write table, so we keep it infrequent). Labeled by enrichment type only — never tenant_id — to keep cardinality bounded.

Production runs several API replicas per region, so the poller is leader-elected: a session-scoped advisory lock held on a dedicated connection for the process lifetime means exactly one replica scans and exports the (global by definition) series. Leadership is deliberately sticky rather than per-scan — replicas tick on independent schedules, so a lock held only for the duration of a scan would almost never collide, reducing neither the DB load nor the duplicate series, while occasionally blanking a replica's series.

Failed refreshes increment hub_enrichment_backlog_poll_errors_total and escalate warn → error after three consecutive misses, because a failing poll otherwise leaves the gauge frozen at its last value — indistinguishable from a healthy steady backlog on a dashboard. A scan cancelled by shutdown is not counted (it would fire that alert on every rolling deploy).

Dependency bump. golang.org/x/text 0.37.0 → 0.39.0 for GO-2026-5970, which was failing the govulncheck gate on unrelated pre-existing call paths.

Out of scope (follow-ups): the Formbricks UI that consumes this, and a per-tenant failed count (permanent failures are already observable globally via hub_<type>_outcomes_total{status="failed_final"}).

Linear: https://linear.app/formbricks/issue/ENG-1670

How should this be tested?

Automated

  • make tests — integration tests in tests/enrichment_status_test.go cover the SQL against Postgres: eligibility (text + content only, whitespace-only and non-text excluded), translation done vs. stale (translation_lang_key = effective target), the sentiment and emotions per-tenant switches, the default-language fallback, strict per-tenant isolation, and the aggregate delta.
  • Emotion completion specifically: a classified-empty record counts as done (not pending forever), a row enriched before the marker existed still counts as done, an unprocessed row stays pending, and editing the text returns a record to pending.
  • Leader election: one replica wins and returns the true aggregate, a second is denied (a normal skip, not an error) and publishes nothing, leadership is sticky across polls, and Close hands over promptly.
  • go test ./internal/... — service gating/zeroing, handler 400/503/200, the gauge and the poll-error counter, and that empty content takes the clear path rather than recording a classification.
  • make migrate-validate for migration 020.

Manual (live progression)

Bring up hub-api + hub-worker against a DB with translation + sentiment configured, set a target language for a tenant, create a few open-text records, then poll the endpoint. Config used: DATABASE_URL, API_KEY, TRANSLATION_PROVIDER/MODEL/_API_KEY, SENTIMENT_PROVIDER/MODEL/_API_KEY, TRANSLATION_DEFAULT_LANGUAGE.

curl -H "Authorization: Bearer $API_KEY" "$HUB/v1/enrichment-status?tenant_id=demo"

Created 3 text records, then polled:

// T0 — immediately after creating
{"tenant_id":"demo-eng1670","translation":{"enabled":true,"eligible":3,"done":1},"sentiment":{"enabled":true,"eligible":3,"done":0},"emotions":{"enabled":false,"eligible":0,"done":0}}

// T1 — ~12s later, once the workers drained the queue
{"tenant_id":"demo-eng1670","translation":{"enabled":true,"eligible":3,"done":3},"sentiment":{"enabled":true,"eligible":3,"done":3},"emotions":{"enabled":false,"eligible":0,"done":0}}

done climbs from the partial state to eligible as enrichment completes — that's the "in progress → done" signal the UI needs. (emotions is enabled:false here only because that provider wasn't configured in the test env.) Also verified: 401 without the key, 400 when tenant_id is missing, and isolation (an unknown tenant returns zero counts).

Checklist

Required

  • Filled out the "How to test" section in this PR
  • Read Repository Guidelines
  • Self-reviewed my own code
  • Commented on my code in hard-to-understand bits
  • Ran make build
  • Ran make tests (integration tests in tests/)
  • Ran make fmt and make lint; no new warnings
  • Removed debug prints / temporary logging
  • Merged the latest changes from main onto my branch with git pull origin main
  • If database schema changed: added migration in migrations/020_add_emotions_classified_at.sql with goose annotations and ran make migrate-validate. Nullable ADD COLUMN with no default, so it is metadata-only (instant, no rewrite, no long lock) and re-runnable; no data backfill by design (see the note above)

Appreciated

  • If API changed: added or updated OpenAPI spec and ran contract tests (make lint-openapi)
  • If API behavior changed: added request/response examples to this PR
  • Updated docs in docs/ — the Hub API reference regenerates from openapi.yaml via Stainless; the hand-authored hub-api-docs pages (metrics reference + core-concepts) are a separate follow-up
  • Ran make tests-coverage for meaningful logic changes

xernobyl added 5 commits July 24, 2026 16:22
Expose per-tenant, data-derived enrichment progress so the UI can surface an
"in progress" indicator, mirroring the embeddings UX (eligible vs done counts,
not queue depth).

GET /v1/enrichment-status?tenant_id= returns {enabled, eligible, done} per
enrichment (translation, sentiment, emotions) counted over feedback_records:
- eligibility is text-with-content (field_type='text', btrim charset matches the
  workers' TrimSpace), gated by the per-tenant switch (sentiment/emotions) or a
  resolvable target language (translation);
- "done" for translation means the stored lang key equals the effective target
  (stale translations are not done);
- the deployment-level provider/model gate is overlaid in the service.

tenant_id is required and validated; the response is counts-only (no record
ids/content); the query is parameterized and strictly tenant-scoped.
Emit hub_enrichment_pending_records{enrichment}, an async gauge of
eligible-but-unenriched feedback records per enrichment type, summed across all
tenants. Unlike the transient River queue-depth gauge this is a data-derived
completeness signal that persists across queue drains.

A background poller in the API process refreshes it on a conservative interval
with a per-iteration query timeout (the aggregate is a full-table scan), gated on
metrics being enabled and reporting only deployment-configured enrichments. The
per-tenant enable gates still apply in SQL, so a disabled tenant never inflates
the backlog. The gauge is labeled by enrichment type only — never tenant_id — to
keep cardinality bounded; metrics are OTLP-push, so it never reaches the public API.
Register the endpoint (tag "Enrichment Status", operationId get-enrichment-status
→ enrichmentStatus.retrieve) plus the EnrichmentStatusOutputBody /
EnrichmentTypeStatus schemas, so Stainless regenerates the SDK and API reference.
Validated with spectral (make lint-openapi).
- DRY the count SQL: the per-tenant and aggregate queries now share a single
  enrichmentCountSelect / enrichmentCountFrom so the six FILTER predicates can't
  drift; the effective-target placeholder is unified to $1 in both.
- Document that the per-tenant scan is served by idx_feedback_records_tenant_field_type
  (tenant_id, field_type) — the existing composite index already covers it, so no new
  index is needed.
- Name the metric-label values (observability.EnrichmentType{Translation,Sentiment,
  Emotions}) instead of string literals in the poller.
…-1670)

The aggregate backlog gauge is a slow-moving trend signal, so a full-table scan
every 60s over the high-write feedback_records table is more often than needed.
Widening to 5m cuts the shared-DB load 5x and keeps the MVCC snapshot the scan
holds short-lived relative to VACUUM. The poller still only runs when metrics are
enabled, and each scan is bounded by a 30s timeout.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a tenant-scoped enrichment status API covering translation, sentiment, and emotions. Introduces repository queries for eligible and completed enrichment counts, service logic for deployment and tenant settings, and OpenAPI schemas. Adds aggregate pending-enrichment gauges with a periodic application poller. Updates application and test-server wiring, adds handler, service, observability, and Postgres integration tests, and bumps two Go module dependencies.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, conventional, and accurately summarizes the main change: exposing enrichment status plus backlog metrics.
Description check ✅ Passed The description covers purpose, API behavior, testing steps, checklist items, and includes examples and migration details, so it is mostly complete.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the hub SDKs with the following commit message.

feat: expose per-tenant enrichment status and backlog metric (ENG-1670)
hub-openapi studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅

hub-typescript studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅build ✅lint ✅test ✅


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-07-28 09:09:49 UTC

govulncheck flags GO-2026-5970 (infinite loop on invalid input in
golang.org/x/text, fixed in v0.39.0), failing the Code Quality gate. The
vulnerable paths are pre-existing (webhook sender, pgx pool, embedding input) and
unrelated to this PR, but the scan runs module-wide. Bump to v0.39.0; govulncheck
is clean afterwards. go mod tidy also pulled x/sync v0.20.0 -> v0.21.0.
@xernobyl

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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 `@cmd/api/app.go`:
- Line 720: Normalize the fallback language passed in the poller’s configuration
by trimming a.DefaultLanguage before assigning it to defaultLang in the
NewEnrichmentStatusService setup. Keep the normalized value consistent with the
service’s existing DefaultLang handling so SQL and API translation-status
calculations use the same target.

In `@internal/repository/enrichment_status_repository.go`:
- Around line 71-86: Update both countEnrichmentStatusSQL and
countEnrichmentBacklogAggregateSQL to add fr.field_type = 'text' to their outer
WHERE clauses, preserving the tenant_id filter for the tenant-scoped query and
adding the predicate to the aggregate query so both scans visit only text
records.

In `@openapi.yaml`:
- Around line 1385-1434: Update the responses for the get-enrichment-status
operation to explicitly declare a 503 Service Unavailable response for the
unconfigured enrichment status service condition. Match the existing 503
response structure and ErrorModel reference used by analogous feature-gated
endpoints, while preserving the current 200, 400, 401, and default responses.
🪄 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: ad5d8f92-e24d-4cb3-adf3-c0d7f2534e38

📥 Commits

Reviewing files that changed from the base of the PR and between 01e48b8 and 92151a6.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (15)
  • cmd/api/app.go
  • cmd/api/app_test.go
  • go.mod
  • internal/api/handlers/enrichment_status_handler.go
  • internal/api/handlers/enrichment_status_handler_test.go
  • internal/models/enrichment_status.go
  • internal/observability/aggregate.go
  • internal/observability/enrichment_backlog.go
  • internal/observability/enrichment_backlog_test.go
  • internal/observability/names.go
  • internal/repository/enrichment_status_repository.go
  • internal/service/enrichment_status_service.go
  • internal/service/enrichment_status_service_test.go
  • openapi.yaml
  • tests/enrichment_status_test.go

Comment thread cmd/api/app.go Outdated
Comment thread internal/repository/enrichment_status_repository.go Outdated
Comment thread openapi.yaml
xernobyl added 2 commits July 24, 2026 18:08
- repository: hoist `field_type = 'text'` into the outer WHERE of both count
  queries so the (tenant_id, field_type) index narrows the scan to text rows
  instead of scanning all rows and discarding non-text inside the FILTERs.
  Redundant with the per-column predicate, so no count changes (EXPLAIN confirms
  index access; integration tests still green).
- api: trim the poller's fallback language for symmetry with
  NewEnrichmentStatusService so the endpoint and the backlog gauge resolve the
  same target (config already canonicalizes it; defensive).
- openapi: document the 503 the handler returns when the service is unwired,
  matching the other feature-gated endpoints.
…670)

Follow-ups from a final re-review:
- Broaden the eligibility trim to the full ASCII whitespace set (add VT/FF ->
  E' \t\n\v\f\r') and correct the comment, which previously overclaimed an exact
  match with the workers' Go strings.TrimSpace gate. TrimSpace also strips exotic
  Unicode whitespace (NBSP, U+3000, ...), so a value composed entirely of those
  remains a rare eligible-but-never-done edge -- now documented and accepted,
  matching the existing backfill queries rather than pretending otherwise.
- Add a DB-level test that the emotions per-tenant switch gates eligibility
  (previously only the sentiment switch was covered against Postgres).
Comment thread internal/repository/enrichment_status_repository.go
Comment thread cmd/api/app.go Outdated
Comment thread internal/repository/enrichment_status_repository.go Outdated
Comment thread cmd/api/app.go
xernobyl added 5 commits July 27, 2026 08:40
…st plan note (ENG-1670)

Review follow-ups:
- Multi-replica: production runs several API replicas per region and each was
  repeating the same cross-tenant scan every tick and publishing the same global
  gauge (multiplied DB load, duplicate series a sum would over-count). The poll
  now runs under a non-blocking transaction-scoped advisory lock, so exactly one
  replica scans per tick; losing the race is a normal skip, not an error. Reuses
  the pg_try_advisory_xact_lock idiom already used elsewhere in the package.
- Alerting: a failing poll left the gauge frozen at its last value, which reads as
  a healthy steady backlog on a dashboard. Failures now increment
  hub_enrichment_backlog_poll_errors_total and escalate warn -> error after three
  consecutive misses.
- Correct the aggregate query's plan comment: it canNOT use the
  (tenant_id, field_type) index (tenant_id is the leading column and the aggregate
  has no tenant predicate), so it seq-scans by design. Documented why that is the
  right trade-off rather than adding a partial index that would tax the ingest path.

Adds DB-backed coverage for the leader election and the poll-error counter.
…p (ENG-1670)

Emotions could never reach "done". A successful classification that detects no
emotion is stored as NULL (the 015 CHECK rejects the empty array), which is the
same value that means "never classified", so those records counted as pending
forever and the backlog gauge could not drain. Adds emotions_classified_at
(migration 020) to record completion independently of the labels found:
- SetEmotions stamps it (an empty result is still a completed classification);
  the new ClearEmotions clears both, for the content-is-gone path.
- The eager-clear on a value_text edit drops the marker with the labels.
- "done" is (emotions_classified_at IS NOT NULL OR emotions IS NOT NULL); the
  second arm covers rows enriched before the column existed, so the migration
  needs no bulk UPDATE of the primary write table.
- The classify backfill now skips already-classified rows instead of re-sending
  every classified-empty record to the provider on each run.
The marker is processing state and stays out of the API surface, so no response
shape, OpenAPI or SDK change. Sentiment and translation are unaffected: their
successful results are single-valued and always non-NULL (verified).

Also replaces the previous backlog-poller advisory lock, which did not do what it
claimed. It was transaction-scoped, so it was held only for the ~seconds a scan
takes, while replicas tick on independent unsynchronized schedules -- they almost
never collided, so neither the duplicated DB load nor the duplicate series was
actually prevented, and a collision on a replica's first poll left it exporting no
series at all until the next tick. Leadership is now a sticky session-scoped lock
held on a dedicated connection for the process lifetime: exactly one replica scans
and exports, the series stays put instead of flapping, followers never pin a
connection, and Close hands over promptly on shutdown. Also stops counting a
shutdown-cancelled scan as a poll error, which would otherwise fire the staleness
alert on every rolling deploy.
…bound zombie leader (ENG-1670)

Three defects found re-reviewing the leader election and the emotions marker:

- A demoted leader kept exporting forever. The async gauge re-observes its stored
  values on every collection and nothing ever cleared them, so after a handover
  the old leader's frozen reading coexisted with the new leader's live one --
  reintroducing exactly the double-count the election was meant to remove, and
  making the stale copy look like a permanently stuck backlog. The poller now
  withdraws its series whenever it is not the publishing leader (lost election or
  failed scan). Exporting nothing is honest; a frozen value is not.
- Migration 020 had broken the 016 emotions-backfill index. That index is
  predicated on `emotions IS NULL` so rows leave it once enriched, keeping it
  near-empty; classified-empty rows now keep emotions NULL forever, so they would
  have been retained permanently -- an index growing without bound and a drained
  backfill forced to scan the whole retained set to find nothing. 020 now rebuilds
  it with the marker in the predicate, which also removes a post-filter (verified
  with EXPLAIN) and round-trips cleanly up/down.
- A leader lost to node failure or a network partition held the session lock until
  TCP keepalives reaped the backend (hours by default), with no replica able to
  take over and no error to alert on. The leader session now sets a 30min
  idle_session_timeout -- far above the poll interval, so it can only fire on a
  session that has genuinely stopped polling.

Also stamp the marker from the DB clock (CASE WHEN ... THEN NOW()) rather than the
pod clock, matching updated_at beside it; extend the eager-clear drift guard to the
new column; and release leadership via defer in the leader test so an early failure
reports instead of wedging the suite on a checked-out connection.
…-1670)

The SDK preview build flagged Model/Recommended for this schema: it is referenced
three times (translation, sentiment, emotions), so without a model declaration the
generated SDKs emit three structurally identical inline types instead of one shared
one. Mirrors the existing taxonomy.run / taxonomy.node declarations.
… (ENG-1670)

The camelCase model path failed Stainless generation (Name/NotSnakeCase, then a
fatal). Model paths are snake_case; the existing taxonomy.run / taxonomy.node
declarations are single lowercase words, so the convention was not visible there.
@BhagyaAmarasinghe
BhagyaAmarasinghe self-requested a review July 27, 2026 17:49
@xernobyl
xernobyl enabled auto-merge July 27, 2026 17:50
@xernobyl
xernobyl added this pull request to the merge queue Jul 28, 2026
Merged via the queue into main with commit e5a2f16 Jul 28, 2026
12 checks passed
@xernobyl
xernobyl deleted the feat/ENG-1670_enrichment-status branch July 28, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants