feat(core): capture the final recorded error for failed rETL records - #7295
Conversation
Read from the first event, honored only on authenticated rETL traffic (non-empty source job run id), carried into job params, and deleted from every event's context.sources before the payload is rebuilt so the destination never sees it.
…meters EventParams -> Metadata -> ParametersT, all omitempty so non-rETL and non-opted-in jobs marshal unchanged.
Additive error_response column on rsources_failed_keys_v2_records, envelope unwrap (reason/response/Error/error), rune-safe byte cap (Rsources.failedKeys.maxErrorLength, default 2048, reloadable) then UTF-8 sanitize, gated by captureErrorDetail AND the per-connection capture_error opt-in AND reloadable connection/workspace blacklists. Suppression drops only the message; record and code are untouched. Served additively as the v2 failed-records 'error' leaf. Aggregate capture telemetry per publish; message bodies are never logged.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #7295 +/- ##
==========================================
- Coverage 80.12% 79.81% -0.32%
==========================================
Files 601 606 +5
Lines 67217 67893 +676
==========================================
+ Hits 53857 54186 +329
- Misses 10184 10521 +337
- Partials 3176 3186 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…etadata rebuild The user-transformation response assembly copies rETL metadata field by field and omitted CaptureError, zeroing the flag between the gateway and the router jobs - capture never fired end-to-end despite a correct stamp and strip. Found by the cross-repo E2E (PRO-5906): with this fix the full chain lands the destination's error text in the customer's rej_* table.
a7c37f3 to
a8e3adc
Compare
The opt-in now rides the X-Rudder-Capture-Error-Detail request header (exact value "true"), seeded into the auth context alongside the job and task run id headers, instead of context.sources.capture_error in the first event's payload. The per-event strip disappears: the payload field is no longer consulted, so a mapped column or constant can no longer forge the opt-in and user payloads pass through unmodified.
…for the rETL capture opt-in
|
Adopted — you were right, and the precedent held up all the way down. As of d92738e the opt-in travels as a Two knock-on simplifications came free:
Spec updated on rudder-specs#109 ( One follow-up your review of this area might have an opinion on: the honor gate still keys on Note: this push deliberately addresses only the carrier rework. The architects-panel review on the spec PR (B1 query-string redaction, M1 first-wins upsert) is being handled separately — B1/M1 fixes follow after the design discussion rather than riding this change. |
…n config, pinned per run Retires the X-Rudder-Capture-Error-Detail request intake and the gateway->processor carrier (EventParams/Metadata CaptureError). The flag is now born at the ParametersT marshal site: resolved from the connection's config.source.syncSettings.errorDetailsConfig.enabled in the processor's backend-config subscription, fail-closed, with the first resolution per job run id pinned (24h TTL, bounded store - job run ids are client-mintable) so every record of one sync carries the same decision and a toggle takes effect from the next sync. Nothing client-supplied feeds the decision; forged headers or payload fields are inert because no code reads them.
|
Update since my last comment here: we went a step further than the header. There's no wire signal at all anymore. The processor resolves errorDetailsConfig.enabled from the connection in its own backend-config subscription at the point it builds ParametersT, and pins the first answer per job run id so a mid-sync toggle can't split a run (24h TTL, bounded map, since run ids are client-mintable). The gateway intake and the EventParams/Metadata plumbing are gone as of e54b728, so a forged header or payload flag is inert, nothing reads them. Spec is updated on rudder-specs#109 (server LLD §3.6). Your original comment is what pushed it here: once the opt-in moved out of the payload, the natural end state was not trusting the client for it at all. |
atzoum
left a comment
There was a problem hiding this comment.
rETL error-capture decision store
Problem
The current opt-in mechanism is wrong:
- The decision lives in memory. A rudder-server restart during a sync loses it.
- Multiple rudder-server nodes take part in one sync. Each node makes its own decision.
- The opt-in bookkeeping leaked into statsCollector (
captureErrorJobIds).
Goal
Build one component. This component is the authority for one question: must a sync run (jobRunID) on a given connection (sourceID and destinationID) capture error responses?
Design
The component has two inputs:
- A pinned decision per jobRunID, stored in a central database table. Every node and every restart sees the same value.
- Operator settings from reloadable config, applied on each call.
See internal/drain-config for a component with a similar shape. Do not copy its code. Use the same idea: a shared or local database, with a maximum of 2 connections.
Interface
The component exposes one method:
GetErrorResponse(ctx context.Context, key statKey, status *jobsdb.JobStatusT) (string, error)The method returns the error text to store, or "" when capture is off for this combination. A database error propagates to the caller.
The method applies its checks in this order:
- If the raw error response is empty,
'', or{}, return"". Do not touch the cache or the database. - If the enabled/disabled flag is off, return
"". Do not touch the cache or the database. Do not insert a row. - Read the pinned decision for the jobRunID. If the decision is false, return
"". - If the connection or the workspace is on a blocklist, return
"". - Extract the error text. Clip it to the max length. Sanitize it. Return it.
Pinned decision
The decision value comes from backend config: source.syncSettings.errorDetailsConfig.enabled. The component subscribes to backend config when it starts. If backend config does not contain the connection, the decision is false.
The decision is stored in one table with 3 columns: job_run_id, store_error_responses, created_at. The key is job_run_id alone. This is sufficient because in rETL one jobRunID maps to one source.
Keep database queries and inserts to a minimum:
- On startup, load all entries from the database into a memory cache.
- On a cache miss for a jobRunID:
- Begin a transaction.
- Take a transaction-scoped advisory lock:
pg_advisory_xact_lock(classid, objid). Use a dedicated class id for this component and a hash of the jobRunID as the object id. The two-argument form has its own keyspace. It cannot collide with the single-argument locks that jobsdb (hash-derived ids) and the rsources handler (id100020001) already use. - Look up the jobRunID in the table.
- If the row is not found, compute the decision from backend config and insert it.
- Commit the transaction and cache the result.
Do not limit the number of jobRunIDs in the memory cache. The memory overhead is not important at this stage.
Operator settings
Move all configuration logic that now lives in statsCollector (errorCaptureSettings) into the new component:
- The enabled/disabled flag (
Rsources.failedKeys.captureErrorDetail), used in step 2. - The blocklists, used in step 4.
- The max-length cap, used in step 5.
Do not overengineer the solution. Do not use sync.Map or settings snapshotting at this stage. Plain reloadable config reads are sufficient.
Cleanup
The component periodically deletes entries older than a configured age. The default is 24 hours. drainConfigManager does something similar, but do not copy its patterns without review.
A run that is longer than the retention age loses its row. A later cache miss computes the decision again, and the value can change mid-run. This is acceptable.
Lifecycle
Create one instance per process, at application startup. Pass it to every NewStatsCollector caller. The component owns its cleanup goroutine. The application starts and stops the component.
Integration
Inject the component into statsCollector. Replace captureErrorResponse with a call to the new method. Remove captureErrorJobIds and errorCaptureSettings from statsCollector.
Change CollectFailedRecords to accept a context.Context and return an error. Errors from GetErrorResponse propagate through it.
Remove the plumbing that the new component makes obsolete:
processor/error_capture_optin.go(captureOptInPins), its wiring in processor, and its tests.gateway/handle_forged_capture_signals_test.go. The gateway header code is already gone. Only this test remains.
Names
Proposed names: table rsources_sync_settings, component SyncSettingDelegate. Both are open to better alternatives.
| @@ -0,0 +1,165 @@ | |||
| package gateway | |||
There was a problem hiding this comment.
we don't need this file do we?
There was a problem hiding this comment.
there should be no diff in the gateway package
What
First slice of "show all errors in rETL" (spec: rudder-specs #109,
show-all-errors-rudder-server-lld.md): capture the final recorded error per failed rETL record — discarded today at the rsources collector — cap and sanitize it, gate it, and carry it additively to the internal v2failed-recordsAPI as theerrorleaf.Changes
error_response text NOT NULL DEFAULT ''column onrsources_failed_keys_v2_records(ADD COLUMN IF NOT EXISTS, mirrors thecodeprecedent; no backfill).reason/response/Error/error, exact-key match) → rune-safe byte cap (Rsources.failedKeys.maxErrorLength, default 2048, reloadable) → UTF-8 sanitize, in that order.Rsources.failedKeys.captureErrorDetail(default false) AND per-connection opt-in on the job'sParametersTAND reloadableblockedConnections/blockedWorkspacesoperator blacklists (<sourceID>:<destID>keys). Suppression drops only the message;{record, code}and retries are untouched.config.source.syncSettings.errorDetailsConfig.enabledfrom the connection object in its own backend-config subscription (connectionConfigMap), at theParametersTmarshal site. The first resolution perjob_run_idis pinned (24h TTL, bounded store — run ids are client-mintable via the payload, so the store must not grow unboundedly), so every record of one sync carries the same decision and a toggle takes effect from the next sync. Fail-closed on unknown connection / absent path / non-bool. Nothing client-supplied feeds the decision.rsources_failed_records_error_{captured,clipped,suppressed}+ summary log). Message bodies are never logged.Carrier history (why the diff shows gateway deletions)
The opt-in had two earlier carriers on this branch, both retired in review: the
context.sources.capture_errorpayload stamp (leaks through old servers to destinations), then theX-Rudder-Capture-Error-Detailrequest header. Under Option 2 the gateway intake and the gateway→processor carrier (EventParams/Metadata) are deleted; the flag is born server-side at the processor. A forged header or payload field is inert because no code reads one — SEC-L1 is closed by construction, pinned by tests on both the gateway and processor sides.Notes for review
error(the cross-service wire contract in the spec README);error_responseis the column name. The LLD §3.2 snippet sayserror_response— flagged on the spec PR as a spec correction.reasonoutranksresponsein the unwrap: a drained job carries both a stale destination body and the drain reason, and the reason is the final recorded error (pinned by test).ParametersT.capture_error, which the processor now stamps from config. The durable landing gate lives in rudder-sources.Rollout
Ships first with
captureErrorDetailoff everywhere. Do not enable until the rudder-sources landing release is live (ordering invariant in the spec README).Testing
Test plan A1–A4 from the spec: envelope shapes, cap/UTF-8/NUL handling, predicate + runtime-reloadable blacklists, v2 transport both directions (old↔new interop) — including round-trips against real postgres. A3 (rewritten for Option 2): resolution true/false/fail-closed shapes, per-run pin across a live config flip (driven through a real
connectionConfigMaprebuild), pin TTL/sweep/cap lifecycle, forged header + payload signals inert on both gateway and processor. New tests race-clean;go build ./..., full gateway/processor/rsources suites, and the Makefile-pinned golangci-lint clean.Linear: PRO-5904