diff --git a/apps/gateway/src/observability.test.ts b/apps/gateway/src/observability.test.ts index ae9465c56..d60c30efa 100644 --- a/apps/gateway/src/observability.test.ts +++ b/apps/gateway/src/observability.test.ts @@ -1,9 +1,14 @@ -import { PrometheusHttpMetrics } from '@life-os/observability'; +import { + CredentialFreeJsonLogger, + PrometheusHttpMetrics, + getRequestContext, +} from '@life-os/observability'; import { describe, expect, it, vi } from 'vitest'; import { createGatewayObservabilityMiddleware } from './observability'; const VALID_CORRELATION_ID = '018f47b2-c1d2-4a30-8c17-221fb579c042'; const GENERATED_CORRELATION_ID = 'd1191b96-b7f4-4d8f-b1f7-9e2838686d5f'; +const FIXED_TIMESTAMP = '2026-08-03T19:30:00.000Z'; type ResponseEvent = 'finish' | 'close'; @@ -40,34 +45,73 @@ function request(path: string, correlationId?: string) { }; } +function createLogger(lines: string[]): CredentialFreeJsonLogger { + return new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', + write: (line) => lines.push(line), + wallClock: () => FIXED_TIMESTAMP, + }); +} + +function parsedLines(lines: string[]): Array> { + return lines.map((line) => JSON.parse(line) as Record); +} + describe('gateway observability middleware', () => { - it('preserves a valid correlation ID and records the bounded route once', () => { + it('propagates context and records one bounded completion', () => { + const lines: string[] = []; const metrics = new PrometheusHttpMetrics({ serviceName: 'life-os-gateway', }); - const middleware = createGatewayObservabilityMiddleware(metrics); + const clock = [1000, 1250]; + const middleware = createGatewayObservabilityMiddleware( + metrics, + undefined, + createLogger(lines), + () => clock.shift() ?? 1250, + ); const response = new FakeResponse(); - const next = vi.fn(); + const next = vi.fn(() => { + expect(getRequestContext()?.correlationId).toBe(VALID_CORRELATION_ID); + }); middleware(request('/v1/today', VALID_CORRELATION_ID), response, next); response.finish(503); response.close(); expect(next).toHaveBeenCalledOnce(); + expect(getRequestContext()).toBeUndefined(); expect(response.headers.get('x-correlation-id')).toBe(VALID_CORRELATION_ID); expect(metrics.renderPrometheus()).toContain( 'route="/v1/today",status_class="5xx"} 1', ); expect(metrics.renderPrometheus()).not.toContain('status_class="4xx"} 1'); + expect(parsedLines(lines)).toEqual([ + { + timestamp: FIXED_TIMESTAMP, + level: 'error', + event: 'http.request.completed', + service: 'life-os-gateway', + correlation_id: VALID_CORRELATION_ID, + method: 'GET', + route: '/v1/today', + status_code: 503, + status_class: '5xx', + duration_seconds: 0.25, + }, + ]); }); it('replaces invalid input and collapses unknown concrete paths', () => { + const lines: string[] = []; const metrics = new PrometheusHttpMetrics({ serviceName: 'life-os-gateway', }); const middleware = createGatewayObservabilityMiddleware( metrics, () => GENERATED_CORRELATION_ID, + createLogger(lines), + () => 1000, ); const response = new FakeResponse(); const concretePath = '/v1/tasks/018f47b2-c1d2-4a30-8c17-221fb579c042'; @@ -80,19 +124,28 @@ describe('gateway observability middleware', () => { response.finish(404); const output = metrics.renderPrometheus(); + const serializedLogs = lines.join('\n'); expect(response.headers.get('x-correlation-id')).toBe( GENERATED_CORRELATION_ID, ); expect(output).toContain('route="/unmatched",status_class="4xx"} 1'); + expect(serializedLogs).toContain('"route":"/unmatched"'); expect(output).not.toContain(concretePath); - expect(output).not.toContain('secret'); + expect(serializedLogs).not.toContain(concretePath); + expect(serializedLogs).not.toContain('token=secret'); }); it('finalizes an aborted response as a client-closed request', () => { + const lines: string[] = []; const metrics = new PrometheusHttpMetrics({ serviceName: 'life-os-gateway', }); - const middleware = createGatewayObservabilityMiddleware(metrics); + const middleware = createGatewayObservabilityMiddleware( + metrics, + undefined, + createLogger(lines), + () => 1000, + ); const response = new FakeResponse(); middleware(request('/v1/today'), response, () => undefined); @@ -103,9 +156,16 @@ describe('gateway observability middleware', () => { expect(output).toContain( 'life_os_http_in_flight_requests{service="life-os-gateway"} 0', ); + expect(parsedLines(lines)[0]).toMatchObject({ + level: 'warn', + event: 'http.request.completed', + status_code: 499, + status_class: '4xx', + }); }); - it('isolates metric failures from response finalization', () => { + it('emits a sanitized record and completes when metrics fail', () => { + const lines: string[] = []; const metrics = new PrometheusHttpMetrics({ serviceName: 'life-os-gateway', maxSeries: 1, @@ -116,28 +176,82 @@ describe('gateway observability middleware', () => { statusCode: 200, durationSeconds: 0.1, }); - const reportMetricFailure = vi.fn(); const middleware = createGatewayObservabilityMiddleware( metrics, undefined, - reportMetricFailure, + createLogger(lines), + () => 1000, ); const response = new FakeResponse(); middleware(request('/v1/today'), response, () => undefined); expect(() => response.finish(200)).not.toThrow(); - expect(reportMetricFailure).toHaveBeenCalledOnce(); + expect(parsedLines(lines)).toEqual([ + { + timestamp: FIXED_TIMESTAMP, + level: 'error', + event: 'observability.failure', + service: 'life-os-gateway', + correlation_id: response.headers.get('x-correlation-id'), + operation: 'metrics.record', + }, + { + timestamp: FIXED_TIMESTAMP, + level: 'info', + event: 'http.request.completed', + service: 'life-os-gateway', + correlation_id: response.headers.get('x-correlation-id'), + method: 'GET', + route: '/v1/today', + status_code: 200, + status_class: '2xx', + duration_seconds: 0, + }, + ]); + expect(lines.join('\n')).not.toMatch(/series limit|stack|message|secret/i); expect(metrics.renderPrometheus()).toContain( 'life_os_http_in_flight_requests{service="life-os-gateway"} 0', ); }); + it('isolates structured-log writer failures from response finalization', () => { + const metrics = new PrometheusHttpMetrics({ + serviceName: 'life-os-gateway', + }); + const logger = new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', + write: () => { + throw new Error('writer unavailable'); + }, + wallClock: () => FIXED_TIMESTAMP, + }); + const middleware = createGatewayObservabilityMiddleware( + metrics, + undefined, + logger, + () => 1000, + ); + const response = new FakeResponse(); + + middleware(request('/v1/health'), response, () => undefined); + expect(() => response.finish(200)).not.toThrow(); + expect(metrics.renderPrometheus()).toContain( + 'route="/v1/health",status_class="2xx"} 1', + ); + }); + it('records synchronous middleware failures once and rethrows', () => { + const lines: string[] = []; const metrics = new PrometheusHttpMetrics({ serviceName: 'life-os-gateway', }); - const middleware = createGatewayObservabilityMiddleware(metrics); + const middleware = createGatewayObservabilityMiddleware( + metrics, + undefined, + createLogger(lines), + () => 1000, + ); const response = new FakeResponse(); expect(() => @@ -150,5 +264,12 @@ describe('gateway observability middleware', () => { const output = metrics.renderPrometheus(); expect(output).toContain('route="/v1/health",status_class="5xx"} 1'); expect(output).not.toContain('status_class="2xx"} 1'); + expect(parsedLines(lines)).toHaveLength(1); + expect(parsedLines(lines)[0]).toMatchObject({ + event: 'http.request.completed', + status_code: 500, + status_class: '5xx', + }); + expect(lines.join('\n')).not.toContain('synthetic failure'); }); }); diff --git a/apps/gateway/src/observability.ts b/apps/gateway/src/observability.ts index 5ec77aa37..58eeca230 100644 --- a/apps/gateway/src/observability.ts +++ b/apps/gateway/src/observability.ts @@ -1,6 +1,9 @@ import { + CredentialFreeJsonLogger, PrometheusHttpMetrics, normalizeCorrelationId, + runWithRequestContext, + type ObservabilityOperation, } from '@life-os/observability'; interface GatewayRequest { @@ -17,7 +20,7 @@ interface GatewayResponse { type GatewayNext = () => void; type CorrelationIdFactory = () => string; -type ObservabilityErrorReporter = (error: unknown) => void; +type MonotonicClock = () => number; const BOUNDED_ROUTE_TEMPLATES = new Set([ '/v1/health', @@ -25,12 +28,18 @@ const BOUNDED_ROUTE_TEMPLATES = new Set([ '/v1/today', ]); const CLIENT_CLOSED_REQUEST_STATUS = 499; +const MAX_DURATION_SECONDS = 3600; /** Shared bounded metrics registry for the gateway process. */ export const gatewayMetrics = new PrometheusHttpMetrics({ serviceName: 'life-os-gateway', }); +/** Shared credential-free structured logger for the gateway process. */ +export const gatewayLogger = new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', +}); + /** Maps concrete request paths to a fixed low-cardinality route inventory. */ function routeTemplate(path: string): string { return BOUNDED_ROUTE_TEMPLATES.has(path) ? path : '/unmatched'; @@ -44,26 +53,56 @@ function correlationHeader( return typeof value === 'string' ? value : undefined; } -/** Reports metric failures without allowing the reporter to break requests. */ -function safelyReportMetricFailure( - reporter: ObservabilityErrorReporter, - error: unknown, +/** Emits a sanitized failure record without allowing logging to break requests. */ +function safelyLogObservabilityFailure( + logger: CredentialFreeJsonLogger, + correlationId: string, + operation: ObservabilityOperation, ): void { try { - reporter(error); + logger.observabilityFailure({ correlationId, operation }); } catch { // Observability must remain isolated from the request pipeline. } } +/** Reads a finite monotonic timestamp or reports an unavailable clock. */ +function safelyReadClock( + now: MonotonicClock, + logger: CredentialFreeJsonLogger, + correlationId: string, +): number | undefined { + try { + const value = now(); + if (Number.isFinite(value)) return value; + } catch { + // Failure is reported below without exception details. + } + safelyLogObservabilityFailure(logger, correlationId, 'request.log'); + return undefined; +} + +/** Calculates a finite bounded duration or zero when timing is unavailable. */ +function elapsedSeconds( + startedAt: number | undefined, + finishedAt: number | undefined, +): number { + if (startedAt === undefined || finishedAt === undefined) return 0; + return Math.min( + MAX_DURATION_SECONDS, + Math.max(0, (finishedAt - startedAt) / 1000), + ); +} + /** - * Creates gateway middleware that emits bounded metrics and correlation IDs. - * Metric failures are isolated, and aborted responses are finalized as 499. + * Creates gateway middleware that emits bounded metrics, fixed-schema logs, + * and an async-safe correlation context. Observability failures are isolated. */ export function createGatewayObservabilityMiddleware( metrics: PrometheusHttpMetrics = gatewayMetrics, correlationIdFactory?: CorrelationIdFactory, - reportMetricFailure: ObservabilityErrorReporter = () => undefined, + logger: CredentialFreeJsonLogger = gatewayLogger, + now: MonotonicClock = () => performance.now(), ): ( request: GatewayRequest, response: GatewayResponse, @@ -76,20 +115,44 @@ export function createGatewayObservabilityMiddleware( ); response.setHeader('x-correlation-id', correlationId); - const finish = metrics.beginHttpRequest({ - method: request.method, - route: routeTemplate(request.path), - }); + const method = request.method; + const route = routeTemplate(request.path); + const startedAt = safelyReadClock(now, logger, correlationId); + let finishMetric: (statusCode: number) => boolean = () => false; + try { + finishMetric = metrics.beginHttpRequest({ method, route }); + } catch { + safelyLogObservabilityFailure(logger, correlationId, 'metrics.record'); + } + let completed = false; const recordCompletion = (statusCode: number): void => { if (completed) return; completed = true; + const durationSeconds = elapsedSeconds( + startedAt, + safelyReadClock(now, logger, correlationId), + ); + try { - finish(statusCode); - } catch (error) { - safelyReportMetricFailure(reportMetricFailure, error); + finishMetric(statusCode); + } catch { + safelyLogObservabilityFailure(logger, correlationId, 'metrics.record'); + } + + try { + logger.httpRequestCompleted({ + correlationId, + method, + route, + statusCode, + durationSeconds, + }); + } catch { + safelyLogObservabilityFailure(logger, correlationId, 'request.log'); } }; + response.once('finish', () => { recordCompletion(response.statusCode); }); @@ -97,9 +160,18 @@ export function createGatewayObservabilityMiddleware( recordCompletion(CLIENT_CLOSED_REQUEST_STATUS); }); + let callbackStarted = false; try { - next(); + runWithRequestContext(correlationId, () => { + callbackStarted = true; + next(); + }); } catch (error) { + if (!callbackStarted) { + safelyLogObservabilityFailure(logger, correlationId, 'request.context'); + next(); + return; + } recordCompletion(500); throw error; } diff --git a/docs/operations/service-level-objectives.md b/docs/operations/service-level-objectives.md index 81cdd9fcc..75487ff99 100644 --- a/docs/operations/service-level-objectives.md +++ b/docs/operations/service-level-objectives.md @@ -2,7 +2,7 @@ ## Scope and status -This specification governs the user-facing LifeOS gateway. It is the first observability slice and does not claim coverage for identity, planning, habit, review, AI, calendar, notification, or browser-only workflows. Those services must adopt the shared telemetry boundary before their reliability can be included in a product-wide objective. +This specification governs the user-facing LifeOS gateway. The gateway now exposes bounded metrics, async-safe correlation context, and credential-free structured completion records. It does not claim coverage for identity, planning, habit, review, AI, calendar, notification, or browser-only workflows. Those services must adopt the shared telemetry boundary before their reliability can be included in a product-wide objective. The source of truth is the Prometheus exposition at `GET /v1/metrics`, scraped by `infra/observability/prometheus.yml`. Health and metrics requests are operational probes and are excluded from user-facing service-level indicators. @@ -12,8 +12,11 @@ The source of truth is the Prometheus exposition at `GET /v1/metrics`, scraped b - Concrete UUIDs, numeric identifiers, query strings, workspace identifiers, actor identifiers, cookies, authorization values, session values, and correlation identifiers are forbidden as metric labels. - Unknown gateway paths collapse to `/unmatched`; they never become a new label value. - `x-correlation-id` is an opaque UUIDv4 used for request correlation. It is not authentication or authorization evidence. +- The request context contains only the validated correlation identifier and propagates through asynchronous work initiated by the gateway request. +- Each completed, failed, or client-aborted request emits one JSON record containing only timestamp, level, event, service, correlation identifier, bounded method, bounded route template, status code, status class, and bounded duration. +- Observability failures emit only timestamp, level, event, service, correlation identifier, and a fixed operation name. Exception messages, stacks, request headers, query strings, concrete paths, tenant data, and credentials are not serialized. - The metrics endpoint contains operational data and must be reachable only from the monitoring network in production. Public ingress must deny `/v1/metrics`. -- Metric retention, remote storage, and alert delivery must not introduce tenant data or credentials. +- Metric and log retention, remote storage, and alert delivery must not introduce tenant data or credentials. ## Availability objective @@ -88,20 +91,23 @@ A page is actionable only when the operator has access to the deployment, curren ## Incident and error-budget policy 1. Confirm the alert from raw Prometheus data and check whether the metrics target itself is failing. -2. Capture the response `x-correlation-id` from an affected client as incident evidence. This slice exposes the identifier only in the response header and does not yet emit correlated application logs or propagate it downstream; do not assume log lookup is available. -3. Mitigate user impact first through rollback, traffic reduction, or dependency isolation; preserve evidence for diagnosis. -4. Record the start, detection, mitigation, recovery, affected routes, release identifier, and consumed error budget. -5. When the rolling availability budget is exhausted, freeze reliability-risking feature releases until the responsible failure mode is corrected and verified. Security fixes and changes that reduce user impact may proceed with explicit review. -6. Review recurring alerts and adjust implementation or capacity. SLO targets may change only through a reviewed pull request and may not be weakened retroactively to hide a miss. +2. Capture the response `x-correlation-id` from an affected client and locate the matching `http.request.completed` JSON record. The record can identify the bounded gateway route, response status, and gateway duration without disclosing arbitrary request data. +3. Treat an `observability.failure` record as evidence that a metric, request-context, or request-log operation failed. The record intentionally omits exception details; use deployment health and controlled diagnostics rather than expanding the production log schema with secrets or request data. +4. Mitigate user impact first through rollback, traffic reduction, or dependency isolation; preserve evidence for diagnosis. +5. Record the start, detection, mitigation, recovery, affected routes, release identifier, correlation identifiers used as evidence, and consumed error budget. +6. When the rolling availability budget is exhausted, freeze reliability-risking feature releases until the responsible failure mode is corrected and verified. Security fixes and changes that reduce user impact may proceed with explicit review. +7. Review recurring alerts and adjust implementation or capacity. SLO targets may change only through a reviewed pull request and may not be weakened retroactively to hide a miss. ## Deployment requirements - Replace the reference static target `gateway:4000` with production service discovery while preserving the `life-os-gateway` job and service label contracts. - Load `alerts.yml` from the same trusted configuration bundle as `prometheus.yml`. - Restrict scrape access to the monitoring network and do not place credentials in Prometheus labels, query parameters, or repository files. -- Retain enough history to evaluate the 30-day objectives and protect monitoring storage with the same operational controls as other production infrastructure. +- Route standard output JSON records to access-controlled operational storage without adding request payloads, headers, exception text, or tenant fields. +- Define log retention and access policy before production deployment; correlation identifiers are operational metadata and must not become durable user identifiers. +- Retain enough metric history to evaluate the 30-day objectives and protect monitoring storage with the same operational controls as other production infrastructure. - Validate Prometheus configuration and rules with `promtool check config` and `promtool check rules` in the production-image build or deployment pipeline. ## Deferred coverage -Distributed trace propagation, structured log correlation, synthetic user journeys, service-specific saturation metrics, browser performance, downstream service objectives, alert delivery, dashboards, release annotations, and long-term metric storage are subsequent reviewable slices tracked by issue #64. +HTTP trace-context parsing, downstream correlation propagation, OpenTelemetry exporters, synthetic user journeys, service-specific saturation metrics, browser performance, downstream service objectives, alert delivery, dashboards, release annotations, and managed long-term metric and log storage are subsequent reviewable slices tracked by issue #64. diff --git a/docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md b/docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md new file mode 100644 index 000000000..183606e53 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md @@ -0,0 +1,35 @@ +# Gateway structured logging slice + +## Goal + +Implement issue #66 as the next bounded observability slice after #65: provide async-safe request correlation and one credential-free completion record for every normal, failed, or client-aborted gateway request. + +## Constraints + +- Request context may contain only a validated UUIDv4 correlation identifier. +- Structured logs must accept a fixed schema rather than arbitrary objects. +- Request headers, query strings, payloads, concrete resource paths, tenant identifiers, actor identifiers, credentials, exception messages, and stacks must not be serialized. +- Logging, clock, context, and metric failures must not make an otherwise valid request unavailable. +- Unknown routes must remain collapsed to `/unmatched`. +- This slice does not claim downstream propagation, distributed tracing, managed retention, or product-wide correlation. + +## Implementation + +1. Add an `AsyncLocalStorage` request context to `@life-os/observability` and expose immutable `runWithRequestContext` and `getRequestContext` boundaries. +2. Add `CredentialFreeJsonLogger` with fixed HTTP completion and sanitized observability-failure records. +3. Validate the service, UUIDv4 correlation identifier, method, route template, status, duration, wall clock, writer, and operation vocabulary before serialization. +4. Run gateway request processing inside the request context and retain the generated-or-preserved identifier in the response header. +5. Emit exactly one completion record from the existing `finish`, `close`, and synchronous-failure guard. Keep synthetic status `499` for client-aborted responses. +6. Isolate metric recording and structured-log writer failures. Emit sanitized failure records when the logger remains available, without serializing the original error. +7. Update the SLO runbook so operators can correlate a client response with the bounded completion record and understand the limits of that evidence. + +## Verification + +- package tests prove async propagation, nested restoration, immutable context, invalid-input rejection, exact JSON schemas, bounded status levels, route rejection, sanitized failures, and writer-failure behavior; +- gateway tests prove context availability, one-record completion, route collapse, secret exclusion, client-abort accounting, metric-failure reporting, writer-failure isolation, and synchronous-failure de-duplication; +- package `typecheck` validates the CommonJS implementation and TypeScript declaration surface; +- CI runs formatting, lint, type checking, tests, build, AppGuardrail, Semgrep, security scanning, commercial readiness, and CodeRabbit on the exact pull-request head. + +## Deferred + +HTTP `traceparent` parsing, correlation propagation headers to downstream services, service-to-service client instrumentation, OpenTelemetry exporters, sampling, remote log storage, retention automation, dashboards, release annotations, synthetic journeys, and alert delivery remain follow-up slices in issue #64. diff --git a/package.json b/package.json index 4d99037f2..c6b0fbb45 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/http-boundary.ts apps/habit-service/src/http-boundary.test.ts apps/habit-service/src/habit-service.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-http-api-slice.md apps/ai-service/package.json apps/ai-service/tsconfig.json apps/ai-service/src/main.ts apps/ai-service/src/proposal-service.ts apps/ai-service/src/proposal-service.test.ts apps/ai-service/src/no-silent-mutation.integration.test.ts apps/ai-service/migrations/README.md apps/ai-service/src/proposal-audit-domain.ts apps/ai-service/src/proposal-audit-domain.test.ts apps/ai-service/src/postgres-proposal-audit-repository.ts apps/ai-service/src/postgres-proposal-audit-repository.test.ts apps/ai-service/src/postgres-proposal-audit-repository.integration.test.ts docs/superpowers/plans/2026-08-04-ai-proposal-audit-repository-slice.md apps/gateway/package.json apps/gateway/src/app.module.ts apps/gateway/src/main.ts apps/gateway/src/observability.ts apps/gateway/src/observability.test.ts packages/observability/package.json packages/observability/src/index.cjs packages/observability/src/index.d.ts packages/observability/src/index.test.cjs infra/observability/prometheus.yml infra/observability/alerts.yml docs/operations/service-level-objectives.md docs/superpowers/plans/2026-08-04-observability-slo-foundation-slice.md apps/planning-service/src/observability.ts apps/planning-service/src/observability.test.ts infra/observability/planning-alerts.yml docs/operations/planning-service-level-objectives.md", + "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/http-boundary.ts apps/habit-service/src/http-boundary.test.ts apps/habit-service/src/habit-service.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-http-api-slice.md apps/ai-service/package.json apps/ai-service/tsconfig.json apps/ai-service/src/main.ts apps/ai-service/src/proposal-service.ts apps/ai-service/src/proposal-service.test.ts apps/ai-service/src/no-silent-mutation.integration.test.ts apps/ai-service/migrations/README.md apps/ai-service/src/proposal-audit-domain.ts apps/ai-service/src/proposal-audit-domain.test.ts apps/ai-service/src/postgres-proposal-audit-repository.ts apps/ai-service/src/postgres-proposal-audit-repository.test.ts apps/ai-service/src/postgres-proposal-audit-repository.integration.test.ts docs/superpowers/plans/2026-08-04-ai-proposal-audit-repository-slice.md apps/gateway/package.json apps/gateway/src/app.module.ts apps/gateway/src/main.ts apps/gateway/src/observability.ts apps/gateway/src/observability.test.ts packages/observability/package.json packages/observability/src/index.cjs packages/observability/src/index.d.ts packages/observability/src/index.test.cjs infra/observability/prometheus.yml infra/observability/alerts.yml docs/operations/service-level-objectives.md docs/superpowers/plans/2026-08-04-observability-slo-foundation-slice.md apps/planning-service/src/observability.ts apps/planning-service/src/observability.test.ts infra/observability/planning-alerts.yml docs/operations/planning-service-level-objectives.md docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md", "format": "prettier --single-quote --write ." }, "devDependencies": { diff --git a/packages/observability/src/index.cjs b/packages/observability/src/index.cjs index 3fb568054..92157b1f1 100644 --- a/packages/observability/src/index.cjs +++ b/packages/observability/src/index.cjs @@ -1,5 +1,6 @@ 'use strict'; +const { AsyncLocalStorage } = require('node:async_hooks'); const { randomUUID } = require('node:crypto'); const PROMETHEUS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'; @@ -10,6 +11,7 @@ const UUID_PATTERN = const SERVICE_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/; const ROUTE_SEGMENT_PATTERN = /^[A-Za-z0-9._~-]{1,64}$/; const ROUTE_PARAMETER_PATTERN = /^:[a-z][a-z0-9_]{1,63}$/; +const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const ALLOWED_METHODS = new Set([ 'DELETE', 'GET', @@ -19,9 +21,18 @@ const ALLOWED_METHODS = new Set([ 'POST', 'PUT', ]); +const OBSERVABILITY_OPERATIONS = new Set([ + 'metrics.record', + 'request.context', + 'request.log', +]); const DEFAULT_DURATION_BUCKETS = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2, 5]); const MAX_DURATION_SECONDS = 3600; +/** @typedef {{readonly correlationId: string}} RequestContext */ +/** @type {AsyncLocalStorage} */ +const requestContextStorage = new AsyncLocalStorage(); + /** Validates and returns a bounded telemetry service identifier. */ function requireServiceName(value) { if ( @@ -36,7 +47,7 @@ function requireServiceName(value) { return value; } -/** Normalizes an allowed HTTP method for metric labels. */ +/** Normalizes an allowed HTTP method for metric and log fields. */ function requireMethod(value) { if (typeof value !== 'string') { throw new TypeError('method must be a supported HTTP method'); @@ -141,6 +152,60 @@ function requireClock(value) { return value; } +/** Validates the wall-clock dependency used by structured logs. */ +function requireWallClock(value) { + if (typeof value !== 'function') { + throw new TypeError('wallClock must be a function'); + } + return value; +} + +/** Validates the structured-log writer dependency. */ +function requireWriter(value) { + if (typeof value !== 'function') { + throw new TypeError('write must be a function'); + } + return value; +} + +/** Validates one normalized UUIDv4 correlation identifier. */ +function requireCorrelationId(value) { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + throw new TypeError('correlationId must be a UUIDv4'); + } + return value.toLowerCase(); +} + +/** Validates a canonical UTC timestamp before it enters a log record. */ +function requireTimestamp(value) { + if ( + typeof value !== 'string' || + !ISO_TIMESTAMP_PATTERN.test(value) || + Number.isNaN(Date.parse(value)) + ) { + throw new TypeError('wallClock must return an ISO UTC timestamp'); + } + return value; +} + +/** Validates the fixed operation vocabulary for failure records. */ +function requireObservabilityOperation(value) { + if (typeof value !== 'string' || !OBSERVABILITY_OPERATIONS.has(value)) { + throw new TypeError( + 'operation must be a supported observability operation', + ); + } + return value; +} + +/** Validates a callback before entering async request context. */ +function requireCallback(value) { + if (typeof value !== 'function') { + throw new TypeError('callback must be a function'); + } + return value; +} + /** Escapes a bounded label value for Prometheus text exposition. */ function escapeLabel(value) { return value @@ -162,11 +227,23 @@ function metricNumber(value) { return String(Number(value.toFixed(9))); } +/** Returns a JSON-safe finite duration without avoidable floating-point noise. */ +function logDuration(value) { + return Number(value.toFixed(9)); +} + /** Maps an HTTP status code to a bounded class such as 2xx. */ function statusClass(statusCode) { return `${Math.floor(statusCode / 100)}xx`; } +/** Maps an HTTP status code to a bounded structured-log level. */ +function statusLevel(statusCode) { + if (statusCode >= 500) return 'error'; + if (statusCode >= 400) return 'warn'; + return 'info'; +} + /** Creates a collision-safe internal key for one metric series. */ function keyOf(method, route, responseClass) { return JSON.stringify([method, route, responseClass]); @@ -178,6 +255,11 @@ function parseKey(key) { return { method, route, status_class: responseClass }; } +/** Writes one line to standard output without inspecting request data. */ +function defaultStructuredLogWriter(line) { + process.stdout.write(`${line}\n`); +} + /** Preserves a valid UUIDv4 correlation ID or creates a replacement. */ function normalizeCorrelationId(value, generate = randomUUID) { if (typeof value === 'string' && UUID_V4_PATTERN.test(value)) { @@ -193,6 +275,80 @@ function normalizeCorrelationId(value, generate = randomUUID) { return generated.toLowerCase(); } +/** + * Runs a callback inside an async-safe request context containing only a + * validated correlation identifier. Nested contexts restore automatically. + */ +function runWithRequestContext(correlationId, callback) { + const context = Object.freeze({ + correlationId: requireCorrelationId(correlationId), + }); + return requestContextStorage.run(context, requireCallback(callback)); +} + +/** Returns the current immutable request context, when one is active. */ +function getRequestContext() { + return requestContextStorage.getStore(); +} + +/** + * Emits credential-free JSON records from a fixed schema. Arbitrary request + * fields and exception details are not accepted by this interface. + */ +class CredentialFreeJsonLogger { + /** Creates a structured logger with injectable deterministic boundaries. */ + constructor({ + serviceName, + write = defaultStructuredLogWriter, + wallClock = () => new Date().toISOString(), + }) { + this.serviceName = requireServiceName(serviceName); + this.write = requireWriter(write); + this.wallClock = requireWallClock(wallClock); + } + + /** Emits one bounded HTTP completion record and returns its serialized line. */ + httpRequestCompleted({ + correlationId, + method, + route, + statusCode, + durationSeconds, + }) { + const normalizedStatusCode = requireStatusCode(statusCode); + const record = { + timestamp: requireTimestamp(this.wallClock()), + level: statusLevel(normalizedStatusCode), + event: 'http.request.completed', + service: this.serviceName, + correlation_id: requireCorrelationId(correlationId), + method: requireMethod(method), + route: requireRouteTemplate(route), + status_code: normalizedStatusCode, + status_class: statusClass(normalizedStatusCode), + duration_seconds: logDuration(requireDuration(durationSeconds)), + }; + const line = JSON.stringify(record); + this.write(line); + return line; + } + + /** Emits a sanitized observability failure without exception details. */ + observabilityFailure({ correlationId, operation }) { + const record = { + timestamp: requireTimestamp(this.wallClock()), + level: 'error', + event: 'observability.failure', + service: this.serviceName, + correlation_id: requireCorrelationId(correlationId), + operation: requireObservabilityOperation(operation), + }; + const line = JSON.stringify(record); + this.write(line); + return line; + } +} + /** * Stores bounded HTTP counters, histograms, and an in-flight gauge in memory. * The registry has no ambient state and renders Prometheus text on demand. @@ -327,8 +483,11 @@ class PrometheusHttpMetrics { } module.exports = { + CredentialFreeJsonLogger, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, PrometheusHttpMetrics, + getRequestContext, normalizeCorrelationId, + runWithRequestContext, }; diff --git a/packages/observability/src/index.d.ts b/packages/observability/src/index.d.ts index e8be0be26..ff1b9f6c5 100644 --- a/packages/observability/src/index.d.ts +++ b/packages/observability/src/index.d.ts @@ -4,6 +4,69 @@ export declare const PROMETHEUS_CONTENT_TYPE: string; /** Default bounded request-duration histogram boundaries in seconds. */ export declare const DEFAULT_DURATION_BUCKETS: readonly number[]; +/** Immutable request-scoped observability context. */ +export interface RequestContext { + /** Validated lowercase UUIDv4 correlation identifier. */ + readonly correlationId: string; +} + +/** Runs a callback inside an async-safe request context. */ +export declare function runWithRequestContext( + correlationId: string, + callback: () => T, +): T; + +/** Returns the current request context when one is active. */ +export declare function getRequestContext(): RequestContext | undefined; + +/** Fixed sanitized operation vocabulary for observability failures. */ +export type ObservabilityOperation = + 'metrics.record' | 'request.context' | 'request.log'; + +/** Construction options for the credential-free structured logger. */ +export interface CredentialFreeJsonLoggerOptions { + /** Lowercase hyphenated service identifier included in every record. */ + serviceName: string; + /** Receives one serialized JSON line without a trailing newline. */ + write?: (line: string) => void; + /** Returns a canonical ISO UTC timestamp. */ + wallClock?: () => string; +} + +/** Bounded fields accepted by an HTTP completion record. */ +export interface HttpRequestCompletionLog { + /** Validated UUIDv4 correlation identifier. */ + correlationId: string; + /** Supported HTTP method. */ + method: string; + /** Low-cardinality route template without concrete identifiers. */ + route: string; + /** HTTP or synthetic status code from 100 through 599. */ + statusCode: number; + /** Finite request duration in seconds. */ + durationSeconds: number; +} + +/** Sanitized fields accepted by an observability failure record. */ +export interface ObservabilityFailureLog { + /** Validated UUIDv4 correlation identifier. */ + correlationId: string; + /** Fixed operation that failed. */ + operation: ObservabilityOperation; +} + +/** Emits fixed-schema credential-free JSON records. */ +export declare class CredentialFreeJsonLogger { + /** Creates a logger with injectable deterministic boundaries. */ + constructor(options: CredentialFreeJsonLoggerOptions); + + /** Emits one bounded HTTP completion record. */ + httpRequestCompleted(record: HttpRequestCompletionLog): string; + + /** Emits one sanitized observability failure record. */ + observabilityFailure(record: ObservabilityFailureLog): string; +} + /** Construction options for an isolated HTTP metrics registry. */ export interface PrometheusHttpMetricsOptions { /** Lowercase hyphenated service identifier included in every series. */ diff --git a/packages/observability/src/index.test.cjs b/packages/observability/src/index.test.cjs index bc21aa1ed..3bba2c09f 100644 --- a/packages/observability/src/index.test.cjs +++ b/packages/observability/src/index.test.cjs @@ -3,13 +3,17 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { + CredentialFreeJsonLogger, PROMETHEUS_CONTENT_TYPE, PrometheusHttpMetrics, + getRequestContext, normalizeCorrelationId, + runWithRequestContext, } = require('./index.cjs'); const FIRST_UUID = '018f47b2-c1d2-4a30-8c17-221fb579c042'; const SECOND_UUID = 'd1191b96-b7f4-4d8f-b1f7-9e2838686d5f'; +const FIXED_TIMESTAMP = '2026-08-03T19:30:00.000Z'; test('preserves valid UUIDv4 correlation IDs and replaces invalid input', () => { assert.equal(normalizeCorrelationId(FIRST_UUID.toUpperCase()), FIRST_UUID); @@ -23,6 +27,184 @@ test('preserves valid UUIDv4 correlation IDs and replaces invalid input', () => ); }); +test('propagates immutable request context across async boundaries', async () => { + assert.equal(getRequestContext(), undefined); + + const correlationId = await runWithRequestContext( + FIRST_UUID.toUpperCase(), + async () => { + await Promise.resolve(); + const context = getRequestContext(); + assert.deepEqual(context, { correlationId: FIRST_UUID }); + assert.equal(Object.isFrozen(context), true); + return context?.correlationId; + }, + ); + + assert.equal(correlationId, FIRST_UUID); + assert.equal(getRequestContext(), undefined); +}); + +test('restores an outer request context after a nested context', () => { + runWithRequestContext(FIRST_UUID, () => { + assert.equal(getRequestContext()?.correlationId, FIRST_UUID); + runWithRequestContext(SECOND_UUID, () => { + assert.equal(getRequestContext()?.correlationId, SECOND_UUID); + }); + assert.equal(getRequestContext()?.correlationId, FIRST_UUID); + }); + assert.equal(getRequestContext(), undefined); +}); + +test('rejects invalid request context inputs', () => { + assert.throws( + () => runWithRequestContext('session=secret', () => undefined), + /correlationId must be a UUIDv4/, + ); + assert.throws( + () => runWithRequestContext(FIRST_UUID, /** @type {never} */ (null)), + /callback must be a function/, + ); +}); + +test('emits a deterministic credential-free HTTP completion record', () => { + const lines = []; + const logger = new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', + write: (line) => lines.push(line), + wallClock: () => FIXED_TIMESTAMP, + }); + + const line = logger.httpRequestCompleted({ + correlationId: FIRST_UUID, + method: 'get', + route: '/v1/today', + statusCode: 503, + durationSeconds: 0.1250000001, + }); + + assert.equal(lines.length, 1); + assert.equal(lines[0], line); + assert.deepEqual(JSON.parse(line), { + timestamp: FIXED_TIMESTAMP, + level: 'error', + event: 'http.request.completed', + service: 'life-os-gateway', + correlation_id: FIRST_UUID, + method: 'GET', + route: '/v1/today', + status_code: 503, + status_class: '5xx', + duration_seconds: 0.125, + }); + assert.doesNotMatch(line, /authorization|cookie|session|secret|stack/i); +}); + +test('uses bounded levels and rejects concrete structured-log dimensions', () => { + const lines = []; + const logger = new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', + write: (line) => lines.push(line), + wallClock: () => FIXED_TIMESTAMP, + }); + + for (const [statusCode, level] of [ + [200, 'info'], + [404, 'warn'], + [500, 'error'], + ]) { + const record = JSON.parse( + logger.httpRequestCompleted({ + correlationId: FIRST_UUID, + method: 'GET', + route: '/v1/health', + statusCode, + durationSeconds: 0, + }), + ); + assert.equal(record.level, level); + } + + assert.equal(lines.length, 3); + assert.throws( + () => + logger.httpRequestCompleted({ + correlationId: FIRST_UUID, + method: 'GET', + route: `/v1/tasks/${SECOND_UUID}`, + statusCode: 200, + durationSeconds: 0.1, + }), + /concrete identifier/, + ); + assert.throws( + () => + logger.httpRequestCompleted({ + correlationId: FIRST_UUID, + method: 'TRACE', + route: '/v1/health', + statusCode: 200, + durationSeconds: 0.1, + }), + /supported HTTP method/, + ); +}); + +test('emits sanitized observability failures without exception details', () => { + const lines = []; + const logger = new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', + write: (line) => lines.push(line), + wallClock: () => FIXED_TIMESTAMP, + }); + + const line = logger.observabilityFailure({ + correlationId: FIRST_UUID, + operation: 'metrics.record', + }); + + assert.deepEqual(JSON.parse(line), { + timestamp: FIXED_TIMESTAMP, + level: 'error', + event: 'observability.failure', + service: 'life-os-gateway', + correlation_id: FIRST_UUID, + operation: 'metrics.record', + }); + assert.doesNotMatch(line, /message|stack|secret|header|path/i); + assert.equal(lines.length, 1); + assert.throws( + () => + logger.observabilityFailure({ + correlationId: FIRST_UUID, + operation: 'database.password', + }), + /supported observability operation/, + ); +}); + +test('surfaces writer failures for callers to isolate', () => { + const logger = new CredentialFreeJsonLogger({ + serviceName: 'life-os-gateway', + write: () => { + throw new Error('writer unavailable'); + }, + wallClock: () => FIXED_TIMESTAMP, + }); + + assert.throws( + () => + logger.httpRequestCompleted({ + correlationId: FIRST_UUID, + method: 'GET', + route: '/v1/health', + statusCode: 200, + durationSeconds: 0.1, + }), + /writer unavailable/, + ); +}); + test('records one bounded series and ignores duplicate request completion', () => { const clock = [1000, 1250]; const metrics = new PrometheusHttpMetrics({