[#1555] Add observability state machine for deterministic, atomic, and recoverable request lifecycle - #1584
Merged
1nonlypiece merged 1 commit intoAug 30, 2026
Conversation
…, atomic, and recoverable request lifecycle
The three privacy-safe observability modules (privacy-logger, tracingMiddleware,
httpMetrics) previously operated independently with no coordination. Each
attached its own `res.on('finish', …)` handler, creating three concrete
failure modes:
1. Duplicate event recording when `finish` fires more than once (retry,
pipeline re-entry), inflating observability signals.
2. No atomic state transitions — a partial failure (e.g. serialization error
in the privacy-logger) left tracing/metrics in an inconsistent state.
3. No recovery from interrupted operations — downstream components had no
signal that an earlier phase had already completed or failed.
This introduces a shared, per-request state machine (`observabilityState.ts`)
that enforces deterministic transitions for logging, tracing, and metrics:
PENDING → IN_PROGRESS → DONE
↓
FAILED
Key changes:
- New `src/observability/observabilityState.ts`: WeakMap-based per-request
state with idempotent transitions and terminal-state guards.
- `privacy-logger.ts`: Registers `in_progress` on mount; `done`/`failed` on
finish. Idempotency guard prevents duplicate log lines. Catch block is
defensive against state-transition failures and Date construction errors.
- `tracingMiddleware.ts`: Same pattern — `in_progress` on mount, `done` on
finish, idempotent guard.
- `httpMetrics.ts`: Same pattern — `in_progress` on mount, `done` on finish,
idempotent guard. Excluded paths remain unaffected (state stays PENDING).
- 30 new tests covering: normal transitions, idempotency guards, terminal
state rejection, independent operation tracking, mixed terminal states,
middleware integration (duplicate finish events), state on serialization
failure, and cleanup helpers.
- Fixed pre-existing missing `jest` imports in `httpMetrics.test.ts` and
`privacy-logger.test.ts` that caused all tests in those suites to fail.
Refs Disciplr-Org#1555
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
|
Hey @Mhidesav! 👋 It looks like this PR isn't linked to any issue. If this PR is for one of the issues assigned to you as part of a Wave, please link it to ensure your contribution is tracked properly. You can do this by adding a keyword to the PR description (e.g.,
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What it fixes
Refs #1555
The three privacy-safe observability modules (
privacy-logger,tracingMiddleware,httpMetrics) previously operated independently with no coordination. Each attached its ownres.on('finish', …)handler, creating three concrete production-quality risk areas:finishevent fires more than once (retry, pipeline re-entry), the same request would produce duplicate log lines, metrics, or span completions, inflating observability signals and confusing dashboards.Additionally,
httpMetrics.test.tsandprivacy-logger.test.tshad missingjestimports that caused every test in those suites to fail (pre-existing breakage).Root cause
The three middleware modules shared no state. Each independently attached a
res.on('finish', …)handler and had no awareness of whether the others had already recorded their observability event for the same request. Theredact()function'sWeakSetfor cycle detection was also created fresh per-call rather than shared, and the privacy-logger'scatchblock could itself throw (e.g. ifnew Date().toISOString()failed during error logging).The fix and why
Introduced a shared, per-request state machine (
src/observability/observabilityState.ts) that enforces deterministic transitions for logging, tracing, and metrics:Design decisions
RequestDONEorFAILED, subsequenttransitionOperationcalls are no-ops (returnfalse). This is the core deduplication mechanism — iffinishfires twice, the second invocation is skipped.transitionOperationreturns booleantransitionOperationandnew Date().toISOString()in nested try-catch to prevent the error handler itself from throwing. This was the actual root cause of the serialization-failure test failing.privacyLogger,tracingMiddleware, andhttpMetricsMiddlewareretain their existing Express middleware signatures. The state machine is an internal coordination layer.Why not alternatives
WeakMapvs. request property — Usingreq.observabilityStatewould pollute the Express Request type and risk conflicts with other middleware. WeakMap keeps it invisible to the rest of the stack.How it was tested
Results: 7 suites, 154 tests, 0 failures, 1 snapshot.
Test coverage added (30 new tests)
getObservabilityStateidempotency and isolation;transitionOperationnormal paths (PENDING→IN_PROGRESS→DONE, PENDING→FAILED, IN_PROGRESS→FAILED), invalid transitions (PENDING→DONE rejected), terminal state rejection (DONE and FAILED are terminal)isTerminal/isFullyResolvedfinishevents produce only one log line; state transitions toDONEon success andFAILEDwith error message on serialization failure; catch block doesn't throw when Date mock is activeDONEon response finish; duplicate finish events export only one spanDONEon response finish; duplicate finish events callinconly once; excluded paths leave state asPENDING_resetObservabilityStateForTestingcreates fresh stateVerification commands
Design tradeoffs
_resetObservabilityStateForTestingprovides test-time accesstransitionOperationis synchronous — if future state transitions need async work (e.g. persisting audit logs)What could break
res.emit('finish')multiple times — these will now see the idempotency guard in action (which is the correct behavior). Existing tests already expect single invocation.req.observabilityStatedirectly — no such code exists; the state is accessed only through the exported API.app.ts— the three observability middlewares are already mounted in a consistent order (httpMetrics→tracing→ … →privacyLogger). The state machine doesn't depend on mount order; each operation tracks independently.Follow-up worth filing separately
DONE/FAILEDoutcomes to the audit log for compliance./api/observability/statedebug endpoint — exposeisFullyResolvedfor debugging stuck requests (disabled by default, behind auth).testPathIgnorePatternslist injest.config.cjsstill excludes ~20 test suites that target other runners.