From 5173b6c8d6c8e8ab475dfd2cb7d0deb8e0d1a3d4 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sun, 26 Jul 2026 16:42:27 +0900 Subject: [PATCH] feat: add diagnostics and telemetry runtime --- config/ci/gates.json | 7 +- ...D-07-diagnostics-and-telemetry-exporter.md | 97 +++++++++ .../frontend-platform-capability-review.md | 30 +-- ...rontend-platform-implementation-roadmap.md | 22 ++ .../frontend-ports-adapters-and-boundaries.md | 30 ++- .../typescript-state-and-data-flow.md | 22 +- .../frontend-platform-testing-strategy.md | 5 +- eslint.config.js | 20 ++ package.json | 3 + scripts/check-diagnostics.mjs | 120 +++++++++++ .../diagnostics/bounded-diagnostics.ts | 102 +++++++++ src/adapters/http/client.js | 118 +++++++++-- src/adapters/http/request-builder.ts | 1 + .../query-cache/tanstack-query-cache.js | 58 +++++- .../storage/browser-storage-adapter.js | 57 +++++- .../telemetry/best-effort-telemetry.js | 89 ++++++-- src/application/create-application.ts | 61 +++++- src/application/ports/diagnostics-port.ts | 5 + src/application/ports/in/application-api.ts | 6 + .../ports/out/application-output-ports.ts | 4 +- src/application/ports/out/index.ts | 1 + src/application/ports/telemetry-port.js | 5 - src/application/ports/telemetry-port.ts | 10 + src/bootstrap/main.jsx | 2 + src/bootstrap/runtime-adapters.js | 33 ++- src/contracts/diagnostics.ts | 183 +++++++++++++++++ src/contracts/telemetry.d.ts | 36 ++++ src/contracts/telemetry.js | 98 ++++++++- .../design-system/primitives/core.tsx | 19 +- src/presentation/routes/app-router.tsx | 25 ++- .../reference-diagnostics.test.ts | 67 ++++++ .../diagnostics/forbidden/direct-console.ts | 3 + .../diagnostics/forbidden/raw-context.ts | 8 + .../typecheck/invalid-diagnostics-port.ts | 11 + tests/helpers/create-test-application.js | 6 +- tests/integration/http-diagnostics.test.ts | 193 ++++++++++++++++++ tests/unit/application-boundary.test.ts | 29 ++- tests/unit/chunk-recovery-runtime.test.ts | 29 ++- tests/unit/diagnostics.test.ts | 141 +++++++++++++ tests/unit/query-cache.test.js | 16 +- tests/unit/runtime-adapters.test.js | 3 + tests/unit/storage-registry.test.js | 18 +- tests/unit/telemetry.test.js | 83 +++++++- 43 files changed, 1760 insertions(+), 116 deletions(-) create mode 100644 docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md create mode 100644 scripts/check-diagnostics.mjs create mode 100644 src/adapters/diagnostics/bounded-diagnostics.ts create mode 100644 src/application/ports/diagnostics-port.ts delete mode 100644 src/application/ports/telemetry-port.js create mode 100644 src/application/ports/telemetry-port.ts create mode 100644 src/contracts/diagnostics.ts create mode 100644 src/contracts/telemetry.d.ts create mode 100644 tests/features/reference-feature/reference-diagnostics.test.ts create mode 100644 tests/fixtures/diagnostics/forbidden/direct-console.ts create mode 100644 tests/fixtures/diagnostics/forbidden/raw-context.ts create mode 100644 tests/fixtures/typecheck/invalid-diagnostics-port.ts create mode 100644 tests/integration/http-diagnostics.test.ts create mode 100644 tests/unit/diagnostics.test.ts diff --git a/config/ci/gates.json b/config/ci/gates.json index 1e16676..cd5b07a 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -84,7 +84,8 @@ { "script": "check:types:fixture:page-action", "expect": "fail" }, { "script": "check:types:fixture:icon-button", "expect": "fail" }, { "script": "check:types:fixture:i18n-key", "expect": "fail" }, - { "script": "check:types:fixture:i18n-params", "expect": "fail" } + { "script": "check:types:fixture:i18n-params", "expect": "fail" }, + { "script": "check:types:fixture:diagnostics", "expect": "fail" } ], "logPath": "artifacts/quality/check-types.txt", "evidence": ["artifacts/quality/check-types.txt"], @@ -158,6 +159,8 @@ { "script": "check:design-system:fixture", "expect": "fail" }, { "script": "check:i18n", "expect": "pass" }, { "script": "check:i18n:fixture", "expect": "fail" }, + { "script": "check:diagnostics", "expect": "pass" }, + { "script": "check:diagnostics:fixture", "expect": "fail" }, { "script": "check:registries", "expect": "pass" }, { "script": "check:registries:fixture", "expect": "fail" }, { "script": "check:routes:fixture", "expect": "fail" } @@ -169,6 +172,8 @@ "artifacts/quality/design-system-fixture.json", "artifacts/quality/i18n.json", "artifacts/quality/i18n-fixture.json", + "artifacts/quality/diagnostics.json", + "artifacts/quality/diagnostics-fixture.json", "artifacts/quality/registries.json", "artifacts/quality/registry-fixture.json", "artifacts/quality/route-registry-fixture.json" diff --git a/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md new file mode 100644 index 0000000..c45c585 --- /dev/null +++ b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md @@ -0,0 +1,97 @@ +# VD-07: Diagnostics와 telemetry exporter 경계 + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-diagnostics-telemetry-runtime` +- 재검토: 실제 운영 sink, consent가 필요한 analytics 또는 분산 tracing provider가 + 선정될 때 + +## 배경 + +기존 telemetry registry와 best-effort HTTP queue는 있었지만 운영 진단 record와 +semantic event의 책임이 하나의 telemetry port에 섞여 있었다. boot, HTTP, +cache, storage, route와 release failure의 선언도 실제 production producer와 +완전히 연결되지 않았다. 이 상태에서는 retry attempt마다 같은 사건을 발행하거나 +raw URL, query, request body와 오류 객체가 queue에 들어갈 위험이 있다. + +반면 skeleton 단계에는 실제 관측 vendor, endpoint의 운영 보안 정책, analytics +consent와 보존 기간이 결정되지 않았다. 특정 SDK를 기본 번들에 설치하는 것은 +vendor 결정 전에는 안전한 기본값이 아니다. + +## 결정 + +1. level 기반 운영 진단은 `DiagnosticsPort`, registry 기반 semantic event는 + `TelemetryPort`로 분리한다. application은 두 port의 concrete adapter나 + exporter SDK를 알지 못한다. +2. diagnostics의 level, event ID와 context key는 닫힌 registry/allowlist다. + telemetry도 event별 required/optional attribute와 value policy를 적용한다. + 등록되지 않은 event·context·고카디널리티 값은 전송하지 않는다. +3. 기본 diagnostics adapter는 bounded in-memory evidence이고 telemetry는 + 설정이 없으면 true no-op이다. endpoint가 있을 때만 bounded oldest-drop + queue와 best-effort HTTP sink를 사용한다. +4. raw path/URL/query/body/response/storage value, credential, cookie, email, + stack과 오류 객체 전체는 context에 넣지 않는다. route ID, operation ID, + correlation ID, release ID, error kind, status/attempt/duration bucket만 + 허용한다. +5. HTTP logical execution은 success, retry recovery, terminal failure 또는 + abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다. + `api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만 + 정확히 한 번 발행한다. +6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`, + `telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage + 실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다. +7. queue full, invalid event/context, serialization과 sink failure는 제한된 + reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를 + 발행하지 않는 nonrecursive 경계다. +8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition, + storage/cache fallback과 React error surface를 바꾸지 않는다. +9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로 + 만들며 untrusted error message, stack과 support 입력을 serialize하지 않는다. +10. 실제 error reporter, RUM, analytics나 tracing SDK는 같은 port 뒤의 외부 + adapter로만 추가한다. SDK type과 event API를 application/feature/presentation + public contract에 노출하지 않는다. + +## 실행 경계 + +```text +route/application/HTTP/cache/storage/bootstrap + -> typed DiagnosticsPort 또는 TelemetryPort + -> registry + allowlist + value policy + -> bounded memory/no-op 또는 best-effort HTTP adapter + -> 프로젝트가 선택한 외부 sink +``` + +- diagnostics contract: `src/contracts/diagnostics.ts` +- telemetry contract: `src/contracts/telemetry.js` +- application ports: `src/application/ports/diagnostics-port.ts`, + `telemetry-port.ts` +- bounded diagnostics: `src/adapters/diagnostics/bounded-diagnostics.ts` +- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.js` +- composition: `src/bootstrap/runtime-adapters.js` + +## 검증 + +- `check:diagnostics`는 모든 registry event에 production producer가 있는지와 + source의 direct console/sensitive context 우회를 검사한다. +- negative source fixture는 direct console, unknown event와 raw context를 실제로 + 거절하며 TypeScript fixture는 잘못된 level/event ID를 거절한다. +- unit test는 allowlist, hostile/circular error, bounded diagnostics, no-op, + queue full, sink/observer failure와 pre-mount boot evidence를 검증한다. +- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer + 횟수, route/operation/correlation context와 요청 값 비노출을 검증한다. +- cache/storage/release/application/runtime test는 각 production wiring과 + diagnostics failure isolation을 검증한다. + +## 한계와 재검토 조건 + +기본 adapter는 운영 log 검색, source map 연계, session replay, distributed span, +analytics consent, sampling budget과 장기 보존을 제공하지 않는다. 실제 sink를 +선정할 때 데이터 처리 지역, 보존 기간, consent, CSP, source map 접근 제어, +sampling과 비용 상한을 별도 결정해야 한다. + +## Rollback + +telemetry exporter는 runtime 설정을 끄거나 adapter wiring을 `noOpTelemetry`로 +바꾸어 독립적으로 제거할 수 있다. 이때도 `DiagnosticsPort`, registry, +redaction/value policy, producer-count와 negative fixture는 유지한다. 외부 SDK +문제로 application producer와 안전 계약을 함께 되돌리지 않는다. diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index 0b4b7b1..8669872 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -33,14 +33,14 @@ 특히 다음은 선행 해결이 필요하다. -RP-01~RP-08에서 TypeScript 도구 안전망, application runtime 주입, +RP-01~RP-09에서 TypeScript 도구 안전망, application runtime 주입, query/mutation inbound adapter, HTTP 실행 계약과 executable route/release recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과 -i18n 실행 경계는 구현됐다. 현재 선행 해결 +i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다. 현재 선행 해결 대상은 다음과 같다. -1. diagnostics/telemetry 실제 producer 연결 -2. registry evidence, 공급망과 optional adapter recipe 심화 게이트 +1. registry evidence와 실제 compatibility diff +2. 공급망과 optional adapter recipe 심화 게이트 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 더 정확한 표현은 다음과 같다. @@ -66,7 +66,7 @@ i18n 실행 경계는 구현됐다. 현재 선행 해결 | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | | application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 | | HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 | -| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 | +| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off, logical execution당 bounded diagnostics | terminal event 중복 방지 계약 유지 | | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 검증 | 준비됨 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | @@ -81,8 +81,8 @@ i18n 실행 경계는 구현됐다. 현재 선행 해결 | 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 | | 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 | | 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 | -| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | -| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | +| logging/diagnostics | 준비됨 | 별도 `DiagnosticsPort`, 8-event registry, allowlist, bounded/no-op adapter와 production producer | 실제 프로젝트의 remote sink는 port 뒤에서 선택 | +| telemetry | 준비됨/프로젝트 선택 | 5-event registry, bounded queue, redaction/value policy, boot·HTTP·render·release·drop producer | analytics/RUM/error vendor와 consent는 프로젝트에서 선택 | | 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 | | 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 | | UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | @@ -217,11 +217,14 @@ guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 rend failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage 실패는 지원 표면으로 fail-closed된다. -#### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다 +#### RP-09에서 diagnostics/telemetry 실행 깊이 보강 -telemetry registry에는 여러 사건이 있지만 실제 production producer는 제한적이다. -boot, API attempt/final failure, auth recovery, storage/cache degradation, release/chunk -recovery를 registry 사건에 연결해야 한다. +`DiagnosticsPort`와 `TelemetryPort`를 분리하고 boot, HTTP logical outcome, +render, cache, storage, route, release mismatch와 delivery drop을 production +producer에 연결했다. HTTP retry는 attempt별 terminal event를 발행하지 않고 +logical execution 종료 시 한 번만 bounded outcome을 남긴다. allowlist와 +value policy가 raw URL/query/body/storage value/error object를 거절하고 queue와 +sink failure는 nonrecursive drop evidence로 제한된다. registry/compatibility 검사는 다음까지 확장한다. @@ -243,7 +246,7 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해 - token → primitive → pattern → template로 이어지는 디자인 시스템 - Lucide를 감싼 local icon registry와 `IconButton` - typed message key, locale provider, formatter, pseudo-locale/RTL smoke -- redacted structured diagnostics/logger와 telemetry wiring +- RP-09에서 완료한 redacted structured diagnostics와 telemetry wiring 유지 - Storybook 또는 동급 isolated UI workshop - Playwright visual baseline, shared MSW scenarios, built-dist E2E - React Hooks, JSX accessibility, TanStack Query 관련 lint @@ -380,7 +383,8 @@ bootstrap → React TSX → tests 순서로 이동한다. - retry: `src/adapters/http/retry-policy.js`, 부분 준비 - API client: `src/adapters/http/client.js`, 부분 준비 -- logger: 없음. telemetry와 분리하거나 diagnostics port로 합치는 결정 필요 +- logger: `DiagnosticsPort`로 telemetry와 분리해 구현. closed event/level, + allowlist와 bounded/no-op adapter 제공 - token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유 - error: `src/contracts/errors.js`와 HTTP normalization, 부분 준비 - validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성 diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md index 82f6367..bae45af 100644 --- a/docs/architecture/frontend-platform-implementation-roadmap.md +++ b/docs/architecture/frontend-platform-implementation-roadmap.md @@ -723,6 +723,28 @@ render와 release 사건을 실제 producer에 연결한다. RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다. diagnostics port와 redaction test는 유지한다. +**구현 증거 (2026-07-26)** + +- VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic `TelemetryPort`를 + 분리하고 bounded memory/no-op 또는 설정 기반 best-effort HTTP exporter를 + 채택했다. +- diagnostics 8종과 telemetry 5종의 registry, context/attribute allowlist, + 고카디널리티 value policy, timestamp와 status/attempt/duration/queue bucket을 + 구현했다. +- boot, route, render, HTTP logical outcome, cache, storage, release mismatch와 + telemetry drop을 production composition에 연결했다. HTTP는 success/recovery/ + terminal/abort 각각 logical execution당 diagnostics 한 번, terminal + non-abort failure telemetry 한 번만 발행한다. +- bounded oldest-drop queue, drop reason 집계, sink/observer failure 격리와 + nonrecursive delivery evidence를 구현했다. exporter가 없으면 network와 queue + side effect가 없는 true no-op이다. +- `check:diagnostics`, source negative fixture와 TypeScript negative fixture가 + producer 누락, direct console, unknown event/context, raw URL/query/body/ + credential 경계를 거절한다. +- unit/integration test가 circular/hostile error, pre-mount boot, queue/sink + failure, no-op, cache/storage/release producer, success/retry recovery/terminal/ + abort 횟수와 reference route/operation/correlation context를 검증한다. + ### 10. `feature-frontend-test-registry-evidence-hardening` **목표** diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 9ff1fc4..88c7cb2 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -779,22 +779,31 @@ Logger와 telemetry는 같은 것이 아니다. - Telemetry: registry에 정의된 semantic event와 metric - Error reporter: 예외 집계와 release correlation -기본 `Logger` output port는 safe context만 받는다. +VD-07에 따라 기본 구현은 임의 message 문자열을 받는 `Logger`가 아니라 닫힌 +event ID와 safe context만 받는 `DiagnosticsPort`다. ```ts -export interface Logger { - debug(message: string, context?: SafeLogContext): void; - info(message: string, context?: SafeLogContext): void; - warn(message: string, context?: SafeLogContext): void; - error(message: string, context?: SafeLogContext): void; +export interface DiagnosticsPort { + record(input: { + level: DiagnosticLevel; + eventId: DiagnosticEventId; + context?: DiagnosticContext; + }): void; } ``` -개발 환경에는 redacted console adapter, production에는 allowlist 기반 -remote adapter, 테스트에는 recording 또는 no-op adapter를 연결한다. +기본 runtime에는 bounded in-memory diagnostics와 설정 기반 best-effort +telemetry adapter, 테스트에는 recording 또는 no-op adapter를 연결한다. +direct `console`은 redaction 경계를 우회하므로 source gate가 거절한다. 민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서 강제한다. +현재 production producer는 boot, logical HTTP outcome, cache, storage, route, +render, release mismatch와 telemetry delivery drop을 포함한다. HTTP terminal +telemetry는 모든 retry가 끝난 뒤 한 번만 발행하며 abort에는 발행하지 않는다. +raw path/query/body/error 대신 route/operation/correlation ID와 +status/attempt/duration bucket만 전달한다. + ## 16. Feature 경계와 removable reference feature Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야 @@ -1223,9 +1232,10 @@ contract와 실패 분기를 우선한다. - [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다. - [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다. - [ ] session UI API와 credential attachment가 분리되어 있다. -- [ ] logger와 telemetry가 HTTP/render/storage/cache failure 경로에 +- [x] diagnostics와 telemetry가 HTTP/render/storage/cache failure 경로에 연결된다. -- [ ] page lifecycle에서 필요한 telemetry flush/cleanup이 수행된다. +- [x] `pagehide`에서 bounded telemetry queue를 flush하고 adapter `dispose`가 + lifecycle listener를 정리한다. ### 26.3 HTTP와 validation diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index 541d837..cd3b185 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -468,23 +468,31 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log ### 7.3 diagnostics와 telemetry -현재 telemetry event contract와 별도로 개발·진단용 structured logger가 필요하다. -다음 두 설계 중 하나를 ADR로 결정한다. - -1. `DiagnosticsPort`가 log/event/span을 내부 method로 구분 -2. `LoggerPort`와 `TelemetryPort`를 분리 +VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic +`TelemetryPort`를 분리했다. diagnostics는 8개 event ID와 safe context +allowlist를, telemetry는 event별 required/optional attribute와 value policy를 +사용한다. 공통 요구: - log level과 event key는 닫힌 union - attribute allowlist와 중앙 redaction -- dev adapter는 console을 사용하되 동일 redaction 적용 -- production adapter는 provider SDK를 감싸며 앱 코드는 SDK를 import하지 않음 +- 기본 adapter는 bounded memory/no-op이고 endpoint가 있을 때만 best-effort + HTTP queue를 사용 +- production provider SDK를 추가할 때도 port 뒤에서 감싸며 앱 코드는 SDK를 + import하지 않음 - 오류 객체 전체를 그대로 serialize하지 않음 - trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결 - logging failure가 제품 flow를 실패시키지 않음 - consent가 필요한 analytics와 essential diagnostics를 분리 +HTTP는 route/operation/correlation ID를 logical execution context로 생성하고 +success/recovered/failed/aborted 종료 시 diagnostics를 한 번만 기록한다. +terminal non-abort failure만 telemetry를 한 번 발행한다. cache/storage와 boot +producer는 raw key, value, message, stack을 버리고 error kind와 bounded +operation만 남긴다. queue full과 sink failure는 제한된 reason bucket이며 drop +observer가 실패해도 재귀 발행하지 않는다. + ### 7.4 locale, message와 표시 값 RP-08부터 locale은 presentation-owned React context다. server/application diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 9ca2ba2..1250d77 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -410,6 +410,8 @@ Page / AsyncSurface / Form pattern - filter 순서가 달라도 canonical key가 같다. - route unmount 또는 superseded input에서 request를 abort한다. - aborted request는 terminal error나 telemetry failure로 오분류되지 않는다. +- success와 retry recovery는 terminal failure telemetry를 만들지 않고, + exhausted retry는 logical execution당 한 번만 발행한다. - offline/paused와 loading을 구분한다. - 401 복구는 한 번만 수행한다. - 재로그인 후 허용된 operation만 다시 실행한다. @@ -1283,7 +1285,8 @@ CI registry에 추가한다. - [ ] high-risk module branch 목표를 충족한다. - [ ] 신규 코드의 미검증 branch에 승인 없는 예외가 없다. - [ ] bundle/performance budget을 통과한다. -- [ ] telemetry/error output에 민감 정보가 없다. +- [x] diagnostics/telemetry output의 allowlist, value policy와 negative fixture가 + raw URL/query/body/storage value/error object를 거절한다. - [ ] 관련 gate와 evidence registry가 갱신되었다. ## 19. 금지 패턴 diff --git a/eslint.config.js b/eslint.config.js index 1c2d577..0226cf8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,6 +76,7 @@ export default [ "artifacts/**", "tests/fixtures/typecheck/**", "tests/fixtures/architecture/forbidden/**", + "tests/fixtures/diagnostics/forbidden/**", "tests/fixtures/i18n/forbidden/**", "tests/fixtures/security/forbidden/**", ], @@ -116,6 +117,25 @@ export default [ "no-unused-vars": "off", }, }, + { + files: ["**/*.d.ts"], + languageOptions: { + ...commonLanguageOptions, + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + plugins: [ + ["@babel/plugin-syntax-typescript", { dts: true }], + ], + }, + }, + }, + rules: { + "no-undef": "off", + "no-unused-vars": "off", + }, + }, { files: ["**/*.tsx"], languageOptions: { diff --git a/package.json b/package.json index d88af41..9844fcb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "check:design-system:fixture": "node scripts/check-design-system.mjs --fixture", "check:i18n": "node scripts/check-i18n.mjs", "check:i18n:fixture": "node scripts/check-i18n.mjs --fixture", + "check:diagnostics": "node scripts/check-diagnostics.mjs", + "check:diagnostics:fixture": "node scripts/check-diagnostics.mjs --fixture", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test", "check:types:app": "tsc --project tsconfig.app.json", "check:types:node": "tsc --project tsconfig.node.json", @@ -34,6 +36,7 @@ "check:types:fixture:icon-button": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx", "check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts", "check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts", + "check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", diff --git a/scripts/check-diagnostics.mjs b/scripts/check-diagnostics.mjs new file mode 100644 index 0000000..84c7721 --- /dev/null +++ b/scripts/check-diagnostics.mjs @@ -0,0 +1,120 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate. +import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts"; +import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.js"; + +const fixtureMode = process.argv.includes("--fixture"); +const failures = []; +const extensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/; + +/** @param {string} directory @returns {Promise} */ +async function filesBelow(directory) { + const result = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...(await filesBelow(target))); + else if (extensions.test(entry.name)) result.push(target); + } + return result; +} + +const telemetryProducerFiles = + /** @type {Readonly>} */ ({ + "app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts", + "api.request.failed": "src/adapters/http/client.js", + "ui.render.failed": "src/application/create-application.ts", + "release.mismatch.detected": "src/application/create-application.ts", + "telemetry.delivery.dropped": + "src/adapters/telemetry/best-effort-telemetry.js", + }); +const diagnosticProducerFiles = + /** @type {Readonly>} */ ({ + "app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts", + "http.request.completed": "src/adapters/http/client.js", + "cache.operation.failed": + "src/adapters/query-cache/tanstack-query-cache.js", + "storage.operation.failed": + "src/adapters/storage/browser-storage-adapter.js", + "route.changed": "src/application/create-application.ts", + "ui.render.failed": "src/application/create-application.ts", + "release.mismatch.detected": "src/application/create-application.ts", + "telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.js", + }); + +if (!fixtureMode) { + for (const eventName of Object.keys(TELEMETRY_REGISTRY)) { + const producer = telemetryProducerFiles[eventName]; + if (!producer) { + failures.push(`telemetry event has no declared producer: ${eventName}`); + continue; + } + const source = await readFile(producer, "utf8"); + if (!source.includes(`"${eventName}"`)) { + failures.push(`telemetry producer is not executable: ${eventName}`); + } + } + for (const eventId of Object.keys(DIAGNOSTIC_EVENT_REGISTRY)) { + const producer = diagnosticProducerFiles[eventId]; + if (!producer) { + failures.push(`diagnostic event has no declared producer: ${eventId}`); + continue; + } + const source = await readFile(producer, "utf8"); + if (!source.includes(`"${eventId}"`)) { + failures.push(`diagnostic producer is not executable: ${eventId}`); + } + } +} + +const sources = fixtureMode + ? await filesBelow("tests/fixtures/diagnostics/forbidden") + : await filesBelow("src"); +const sensitiveContext = + /\b(?:authorization|cookie|access_token|refresh_token|request_body|response_body|raw_url|query_string|email|user_name)\b/i; +for (const file of sources) { + const source = await readFile(file, "utf8"); + if (file.includes("contracts/telemetry.js")) continue; + if (source.includes("console.")) { + failures.push(`direct console diagnostics bypass in ${file}`); + } + const calls = source.match( + /(?:\.record\(\{|\.emit\()[\s\S]{0,700}?(?:\}\)|\}\);)/g, + ) ?? []; + if (calls.some((call) => sensitiveContext.test(call))) { + failures.push(`sensitive diagnostic or telemetry context in ${file}`); + } + if ( + file.includes("tests/fixtures/diagnostics/forbidden") && + source.includes("UNKNOWN_DIAGNOSTIC_EVENT") + ) { + failures.push(`unknown diagnostic event in ${file}`); + } +} + +const report = { + schemaVersion: 1, + mode: fixtureMode ? "negative-fixture" : "source", + telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length, + diagnosticEventCount: Object.keys(DIAGNOSTIC_EVENT_REGISTRY).length, + checkedFiles: sources.length, + failures, + passed: failures.length === 0, +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + fixtureMode + ? "artifacts/quality/diagnostics-fixture.json" + : "artifacts/quality/diagnostics.json", + `${JSON.stringify(report, null, 2)}\n`, +); +if (failures.length > 0) { + process.stderr.write( + `Diagnostics contract failed:\n${failures.join("\n")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Diagnostics contract: ${report.diagnosticEventCount} diagnostics and ${report.telemetryEventCount} telemetry producers PASS\n`, +); diff --git a/src/adapters/diagnostics/bounded-diagnostics.ts b/src/adapters/diagnostics/bounded-diagnostics.ts new file mode 100644 index 0000000..8f7285d --- /dev/null +++ b/src/adapters/diagnostics/bounded-diagnostics.ts @@ -0,0 +1,102 @@ +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js"; +import { + projectDiagnosticRecord, + safeErrorKind, + type DiagnosticRecord, + type DiagnosticRecordInput, +} from "../../contracts/diagnostics.js"; +import { projectTelemetryEvent } from "../../contracts/telemetry.js"; + +export const noOpDiagnostics: DiagnosticsPort = Object.freeze({ + record() {}, +}); + +export function createDiagnosticsAdapter( + options: Readonly<{ + maxEntries?: number; + now?: () => number; + sink?: (record: DiagnosticRecord) => void; + }> = {}, +) { + const maxEntries = Math.max(1, options.maxEntries ?? 100); + const entries: DiagnosticRecord[] = []; + const droppedReasons = new Map(); + + function drop(reason: string) { + droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1); + } + + function record(input: DiagnosticRecordInput) { + try { + const projected = projectDiagnosticRecord(input, options.now); + if (!projected.success) { + drop(projected.reason); + return; + } + if (entries.length >= maxEntries) { + entries.shift(); + drop("queue-full"); + } + entries.push(projected.record); + try { + options.sink?.(projected.record); + } catch { + drop("sink-failure"); + } + } catch { + drop("serialization-failure"); + } + } + + return Object.freeze({ + record, + entries: () => structuredClone(entries) as readonly DiagnosticRecord[], + dropped: () => Object.freeze(Object.fromEntries(droppedReasons)), + }); +} + +type BootSafeContext = Readonly<{ + kind?: string; + buildId?: string; + configSchemaVersion?: string; + supportReference?: string; +}>; + +let lastBootEvidence: + | Readonly<{ + diagnostic: DiagnosticRecord | null; + telemetry: Readonly> | null; + }> + | undefined; + +export function recordBootFailure( + error: unknown, + safe: BootSafeContext, + now: () => number = Date.now, +) { + const errorKind = + typeof safe.kind === "string" ? safe.kind : safeErrorKind(error); + const attributes = { + error_kind: errorKind, + build_id: safe.buildId ?? "unknown", + config_schema_version: safe.configSchemaVersion ?? "unknown", + }; + const diagnostic = projectDiagnosticRecord( + { + level: "error", + eventId: "app.boot.failed", + context: attributes, + }, + now, + ); + const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now); + lastBootEvidence = Object.freeze({ + diagnostic: diagnostic.success ? diagnostic.record : null, + telemetry: telemetry.success ? telemetry.event : null, + }); + return lastBootEvidence; +} + +export function getLastBootEvidence() { + return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined; +} diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index d456193..cb4a661 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -14,6 +14,11 @@ import { validateOperationRequest, } from "./schema-registry.js"; import { buildRequestTarget } from "./request-builder.js"; +import { + attemptBucket, + durationBucket, + statusGroup, +} from "../../contracts/diagnostics.js"; const noAuthSession = /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ @@ -54,7 +59,10 @@ const noAuthSession = * timeoutMs?: number, * maxRetryAttempts?: number, * scheduler?: Scheduler, - * getOperation?: typeof getApiOperation + * getOperation?: typeof getApiOperation, + * diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort, + * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, + * correlationIdFactory?: () => string * }} dependencies */ export function createHttpClient(dependencies) { @@ -72,6 +80,11 @@ export function createHttpClient(dependencies) { const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000; const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2; const selectOperation = dependencies.getOperation ?? getApiOperation; + const diagnostics = dependencies.diagnostics; + const telemetry = dependencies.telemetry; + const correlationIdFactory = + dependencies.correlationIdFactory ?? + (() => `request-${Math.floor(random() * 1_000_000).toString(36)}`); const scheduler = dependencies.scheduler ?? /** @type {Scheduler} */ ({ @@ -91,7 +104,8 @@ export function createHttpClient(dependencies) { * pathParams?: Record, * searchParams?: unknown, * signal?: AbortSignal, - * idempotencyKey?: string + * idempotencyKey?: string, + * correlationId?: string * }} [legacyInput] * @returns {Promise} */ @@ -106,9 +120,55 @@ export function createHttpClient(dependencies) { body: legacyInput.body, signal: legacyInput.signal, idempotencyKey: legacyInput.idempotencyKey, + correlationId: legacyInput.correlationId, } : request; const operation = selectOperation(input.operationId); + const startedAt = clock.now(); + const correlationId = input.correlationId ?? correlationIdFactory(); + /** + * @param {HttpResult} outcome + * @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind + */ + function finalize(outcome, outcomeKind) { + const error = outcome.ok ? undefined : outcome.error; + const context = { + route_id: input.routeId, + operation_id: input.operationId, + correlation_id: correlationId, + outcome: outcomeKind, + error_kind: error?.kind ?? "NONE", + http_status_group: statusGroup(error?.httpStatus), + attempt_count_bucket: attemptBucket( + error?.attemptCount ?? retryCount + 1, + ), + duration_bucket: durationBucket(clock.now() - startedAt), + }; + try { + diagnostics?.record({ + level: error ? "warn" : "info", + eventId: "http.request.completed", + context, + }); + } catch { + // Diagnostics cannot change the HTTP result. + } + if (error && outcomeKind !== "aborted") { + try { + telemetry?.emit("api.request.failed", { + error_kind: context.error_kind, + http_status_group: context.http_status_group, + attempt_count_bucket: context.attempt_count_bucket, + route_id: context.route_id, + operation_id: context.operation_id, + duration_bucket: context.duration_bucket, + }); + } catch { + // Telemetry cannot change the HTTP result. + } + } + return outcome; + } const logicalIdempotencyKey = operation.idempotency === "keyed" ? input.idempotencyKey ?? idempotencyKeyFactory() @@ -126,28 +186,40 @@ export function createHttpClient(dependencies) { idempotencyKey: logicalIdempotencyKey, }); - if (outcome.ok) return outcome; + if (outcome.ok) { + return finalize( + outcome, + retryCount > 0 || recoveryUsed ? "recovered" : "success", + ); + } if (outcome.error.httpStatus === 401 && !recoveryUsed) { recoveryUsed = true; - const recovered = await recoverSession(authSession, operation, outcome.error); - if (!recovered.ok) return recovered; + const recovered = await recoverSession( + authSession, + operation, + outcome.error, + ); + if (!recovered.ok) return finalize(recovered, "failed"); if (operation.idempotency === "none") { - return { - ok: false, - error: { - ...outcome.error, - retryable: false, - action: "retry", + return finalize( + { + ok: false, + error: { + ...outcome.error, + retryable: false, + action: "retry", + }, }, - }; + "failed", + ); } continue; } if (outcome.error.httpStatus === 401 && recoveryUsed) { authSession.onUnauthenticated(); - return outcome; + return finalize(outcome, "failed"); } if ( @@ -158,7 +230,10 @@ export function createHttpClient(dependencies) { maxRetryAttempts, ) ) { - return outcome; + return finalize( + outcome, + outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed", + ); } const delay = retryDelay(outcome.error, retryCount, random, clock.now()); @@ -167,12 +242,15 @@ export function createHttpClient(dependencies) { try { await clock.sleep(delay, input.signal); } catch { - return { - ok: false, - error: failure("REQUEST_ABORTED", input.operationId, retryCount, { - code: "REQUEST_ABORTED", - }), - }; + return finalize( + { + ok: false, + error: failure("REQUEST_ABORTED", input.operationId, retryCount, { + code: "REQUEST_ABORTED", + }), + }, + "aborted", + ); } } } diff --git a/src/adapters/http/request-builder.ts b/src/adapters/http/request-builder.ts index 76ef2b0..527411b 100644 --- a/src/adapters/http/request-builder.ts +++ b/src/adapters/http/request-builder.ts @@ -8,6 +8,7 @@ export type OperationRequestInput = Readonly<{ body?: unknown; signal?: AbortSignal; idempotencyKey?: string; + correlationId?: string; }>; export type RequestTargetResult = diff --git a/src/adapters/query-cache/tanstack-query-cache.js b/src/adapters/query-cache/tanstack-query-cache.js index 690ecb6..83d6ab8 100644 --- a/src/adapters/query-cache/tanstack-query-cache.js +++ b/src/adapters/query-cache/tanstack-query-cache.js @@ -1,6 +1,7 @@ -import { QueryClient } from "@tanstack/react-query"; +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { createFailure } from "../../contracts/errors.js"; +import { safeErrorKind } from "../../contracts/diagnostics.js"; export const QUERY_CACHE_DEFAULTS = Object.freeze({ staleTime: 30_000, @@ -11,8 +12,32 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({ persistence: false, }); -export function createQueryClient() { +/** + * @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies] + */ +export function createQueryClient(dependencies = {}) { + /** @param {string} operation @param {unknown} error */ + function report(operation, error) { + try { + dependencies.diagnostics?.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation, + error_kind: safeErrorKind(error), + }, + }); + } catch { + // Query behavior remains independent from diagnostics. + } + } return new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => report("query", error), + }), + mutationCache: new MutationCache({ + onError: (error) => report("mutation", error), + }), defaultOptions: { queries: { staleTime: QUERY_CACHE_DEFAULTS.staleTime, @@ -29,15 +54,16 @@ export function createQueryClient() { /** * @param {QueryClient} queryClient + * @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies] * @returns {import("../../application/ports/query-cache-port.js").QueryCachePort} */ -export function createQueryCacheAdapter(queryClient) { +export function createQueryCacheAdapter(queryClient, dependencies = {}) { return Object.freeze({ read(key) { try { return { ok: true, value: queryClient.getQueryData(key) }; } catch { - return cacheFailure("read", key); + return cacheFailure("read", key, dependencies.diagnostics); } }, write(key, value) { @@ -45,7 +71,7 @@ export function createQueryCacheAdapter(queryClient) { queryClient.setQueryData(key, structuredClone(value)); return { ok: true }; } catch { - return cacheFailure("write", key); + return cacheFailure("write", key, dependencies.diagnostics); } }, async invalidate(namespace) { @@ -53,15 +79,31 @@ export function createQueryCacheAdapter(queryClient) { await queryClient.invalidateQueries({ queryKey: namespace, exact: false }); return { ok: true }; } catch { - return cacheFailure("invalidate", namespace); + return cacheFailure("invalidate", namespace, dependencies.diagnostics); } }, }); } -/** @param {string} phase @param {readonly unknown[]} key */ -function cacheFailure(phase, key) { +/** + * @param {string} phase + * @param {readonly unknown[]} key + * @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics + */ +function cacheFailure(phase, key, diagnostics) { const namespace = typeof key[0] === "string" ? key[0] : "unknown"; + try { + diagnostics?.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation: phase, + error_kind: "QUERY_CACHE_FAILURE", + }, + }); + } catch { + // Cache behavior remains independent from diagnostics. + } return { ok: /** @type {false} */ (false), error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, { diff --git a/src/adapters/storage/browser-storage-adapter.js b/src/adapters/storage/browser-storage-adapter.js index e05c5bb..b00a247 100644 --- a/src/adapters/storage/browser-storage-adapter.js +++ b/src/adapters/storage/browser-storage-adapter.js @@ -5,7 +5,8 @@ import { getStorageDefinition } from "../../contracts/storage-keys.js"; * @param {{ * localStorage?: Storage, * sessionStorage?: Storage, - * now?: () => number + * now?: () => number, + * diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort * }} [dependencies] * @returns {import("../../application/ports/storage-port.js").StoragePort} */ @@ -26,7 +27,7 @@ export function createBrowserStorageAdapter(dependencies = {}) { try { definition = getStorageDefinition(logicalName); } catch { - return unavailable("read", logicalName); + return unavailable("read", logicalName, dependencies.diagnostics); } const backend = backendFor(definition.backend); @@ -50,7 +51,7 @@ export function createBrowserStorageAdapter(dependencies = {}) { } return { ok: true, value: structuredClone(envelope.value) }; } catch { - return unavailable("read", logicalName); + return unavailable("read", logicalName, dependencies.diagnostics); } }, @@ -59,7 +60,7 @@ export function createBrowserStorageAdapter(dependencies = {}) { try { definition = getStorageDefinition(logicalName); } catch { - return unavailable("write", logicalName); + return unavailable("write", logicalName, dependencies.diagnostics); } const expiresAt = @@ -82,12 +83,24 @@ export function createBrowserStorageAdapter(dependencies = {}) { if (definition.quotaFallback === "memory") { memory.set(definition.physicalKey, structuredClone(value)); + recordStorageFailure( + dependencies.diagnostics, + "write", + logicalName, + quota, + ); return { ok: false, error: storageFailure(quota, "write", logicalName), fallback: "memory", }; } + recordStorageFailure( + dependencies.diagnostics, + "write", + logicalName, + quota, + ); return { ok: false, error: storageFailure(quota, "write", logicalName), @@ -101,14 +114,14 @@ export function createBrowserStorageAdapter(dependencies = {}) { try { definition = getStorageDefinition(logicalName); } catch { - return unavailable("remove", logicalName); + return unavailable("remove", logicalName, dependencies.diagnostics); } try { backendFor(definition.backend)?.removeItem(definition.physicalKey); memory.delete(definition.physicalKey); return { ok: true }; } catch { - return unavailable("remove", logicalName); + return unavailable("remove", logicalName, dependencies.diagnostics); } }, }); @@ -128,10 +141,38 @@ function storageFailure(quota, phase, logicalName) { ); } -/** @param {string} phase @param {string} logicalName */ -function unavailable(phase, logicalName) { +/** + * @param {string} phase + * @param {string} logicalName + * @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics + */ +function unavailable(phase, logicalName, diagnostics) { + recordStorageFailure(diagnostics, phase, logicalName, false); return { ok: /** @type {false} */ (false), error: storageFailure(false, phase, logicalName), }; } + +/** + * @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics + * @param {string} phase + * @param {string} logicalName + * @param {boolean} quota + */ +function recordStorageFailure(diagnostics, phase, logicalName, quota) { + try { + diagnostics?.record({ + level: "warn", + eventId: "storage.operation.failed", + context: { + operation: `${phase}:${logicalName}`, + error_kind: quota + ? "STORAGE_QUOTA_EXCEEDED" + : "STORAGE_UNAVAILABLE", + }, + }); + } catch { + // Storage behavior remains independent from diagnostics. + } +} diff --git a/src/adapters/telemetry/best-effort-telemetry.js b/src/adapters/telemetry/best-effort-telemetry.js index 116289c..0b17a13 100644 --- a/src/adapters/telemetry/best-effort-telemetry.js +++ b/src/adapters/telemetry/best-effort-telemetry.js @@ -1,7 +1,14 @@ import { projectTelemetryEvent } from "../../contracts/telemetry.js"; +import { queueSizeBucket } from "../../contracts/diagnostics.js"; export const noOpTelemetry = Object.freeze({ emit: () => {}, + flush: async () => {}, + pendingCount: () => 0, + droppedCount: () => 0, + dropReasons: () => Object.freeze({}), + deliveryEvidence: () => null, + dispose: () => {}, }); /** @@ -10,22 +17,20 @@ export const noOpTelemetry = Object.freeze({ * endpoint?: string, * fetcher?: typeof fetch, * maxQueue?: number, - * schedule?: (callback: () => void) => void + * schedule?: (callback: () => void) => void, + * now?: () => number, + * onDrop?: (event: Readonly>) => void, + * lifecycle?: Pick * }} options */ export function createTelemetryAdapter(options) { if (!options.enabled || !options.endpoint) { - return Object.freeze({ - ...noOpTelemetry, - flush: async () => {}, - pendingCount: () => 0, - droppedCount: () => 0, - }); + return noOpTelemetry; } const endpoint = /** @type {string} */ (options.endpoint); const fetcher = options.fetcher ?? fetch; - const maxQueue = options.maxQueue ?? 100; + const maxQueue = Math.max(1, options.maxQueue ?? 100); const schedule = options.schedule ?? queueMicrotask; const queue = /** @type {Array<{eventName: string, attributes: Readonly>}>} */ ( @@ -34,18 +39,63 @@ export function createTelemetryAdapter(options) { let scheduled = false; let flushing = false; let dropped = 0; + const dropReasons = new Map(); + let lastDeliveryEvidence = + /** @type {Readonly> | null} */ (null); + const lifecycle = + options.lifecycle ?? + (typeof globalThis.addEventListener === "function" && + typeof globalThis.removeEventListener === "function" + ? globalThis + : undefined); + + /** @param {string} reason @param {number} count */ + function recordDrop(reason, count = 1) { + const safeReason = + { + "queue-full": "queue-full", + "sink-failure": "sink-failure", + "serialization-failure": "serialization-failure", + "unknown-attributes": "invalid-context", + "invalid-attribute-value": "invalid-context", + "missing-required-attributes": "invalid-context", + "unregistered-event": "invalid-event", + }[reason] ?? "invalid-event"; + dropped += count; + dropReasons.set(safeReason, (dropReasons.get(safeReason) ?? 0) + count); + const internal = projectTelemetryEvent( + "telemetry.delivery.dropped", + { + reason: safeReason, + queue_size_bucket: queueSizeBucket(queue.length), + }, + options.now, + ); + if (internal.success) { + lastDeliveryEvidence = internal.event; + try { + options.onDrop?.(internal.event); + } catch { + // Drop observers are deliberately nonrecursive. + } + } + } /** @param {string} eventName @param {Record} attributes */ function emit(eventName, attributes) { - const projected = projectTelemetryEvent(eventName, attributes); + const projected = projectTelemetryEvent( + eventName, + attributes, + options.now, + ); if (!projected.success) { - dropped += 1; + recordDrop(projected.reason); return; } if (queue.length >= maxQueue) { queue.shift(); - dropped += 1; + recordDrop("queue-full"); } queue.push(projected.event); @@ -69,19 +119,32 @@ export function createTelemetryAdapter(options) { body: JSON.stringify({ events: batch }), keepalive: true, }); - if (!response.ok) dropped += batch.length; + if (!response.ok) recordDrop("sink-failure", batch.length); } catch { - dropped += batch.length; + recordDrop("sink-failure", batch.length); } finally { flushing = false; } } + const flushBeforePageExit = () => { + void flush(); + }; + lifecycle?.addEventListener("pagehide", flushBeforePageExit); + + function dispose() { + lifecycle?.removeEventListener("pagehide", flushBeforePageExit); + } + return Object.freeze({ emit, flush, pendingCount: () => queue.length, droppedCount: () => dropped, + dropReasons: () => Object.freeze(Object.fromEntries(dropReasons)), + deliveryEvidence: () => + lastDeliveryEvidence ? structuredClone(lastDeliveryEvidence) : null, + dispose, }); } diff --git a/src/application/create-application.ts b/src/application/create-application.ts index ee76069..74644cc 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -3,6 +3,7 @@ import type { ApplicationApi, ColorSchemePreference, RenderFailureReport, + RouteChangedReport, } from "./ports/in/application-api.js"; import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js"; import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js"; @@ -43,7 +44,20 @@ export function createApplication( const diagnostics = Object.freeze({ reportRenderFailure(report: RenderFailureReport) { try { - outputPorts.diagnostics.emit("ui.render.failed", { + outputPorts.diagnostics.record({ + level: "error", + eventId: "ui.render.failed", + context: { + route_id: report.routeId, + build_id: report.buildId, + component_boundary: report.boundaryName, + }, + }); + } catch { + // Diagnostics are best-effort and cannot become an application failure. + } + try { + outputPorts.telemetry.emit("ui.render.failed", { route_id: report.routeId, build_id: report.buildId, component_boundary: report.boundaryName, @@ -52,6 +66,20 @@ export function createApplication( // Diagnostics are best-effort and cannot become an application failure. } }, + reportRouteChanged(report: RouteChangedReport) { + try { + outputPorts.diagnostics.record({ + level: "info", + eventId: "route.changed", + context: { + route_id: report.routeId, + build_id: report.buildId, + }, + }); + } catch { + // Diagnostics are best-effort and cannot become navigation failure. + } + }, }); const runtime = Object.freeze({ @@ -74,6 +102,37 @@ export function createApplication( try { const current = await outputPorts.releaseInfo.getCurrent(); const active = await outputPorts.releaseInfo.refresh(); + if ( + current.buildId !== active.buildId || + current.releaseId !== active.releaseId + ) { + const mismatchKind = + current.buildId !== active.buildId + ? "BUILD_MISMATCH" + : "RELEASE_MISMATCH"; + try { + outputPorts.diagnostics.record({ + level: "warn", + eventId: "release.mismatch.detected", + context: { + build_id: current.buildId, + active_release_id: active.releaseId, + mismatch_kind: mismatchKind, + }, + }); + } catch { + // Recovery remains independent from diagnostics. + } + try { + outputPorts.telemetry.emit("release.mismatch.detected", { + build_id: current.buildId, + active_release_id: active.releaseId, + mismatch_kind: mismatchKind, + }); + } catch { + // Recovery remains independent from telemetry. + } + } if (!active.routeChunks[input.chunkId]) { return { action: "support" as const, diff --git a/src/application/ports/diagnostics-port.ts b/src/application/ports/diagnostics-port.ts new file mode 100644 index 0000000..125d5ed --- /dev/null +++ b/src/application/ports/diagnostics-port.ts @@ -0,0 +1,5 @@ +import type { DiagnosticRecordInput } from "../../contracts/diagnostics.js"; + +export type DiagnosticsPort = Readonly<{ + record(input: DiagnosticRecordInput): void; +}>; diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts index 68b62f7..ec13f7a 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -11,6 +11,11 @@ export type RenderFailureReport = Readonly<{ boundaryName: "route" | "feature"; }>; +export type RouteChangedReport = Readonly<{ + routeId: string; + buildId: string; +}>; + export type ReleaseSummary = Readonly<{ buildId: string; releaseId: string; @@ -34,6 +39,7 @@ export type ApplicationApi = Readonly<{ }>; diagnostics: Readonly<{ reportRenderFailure(report: RenderFailureReport): void; + reportRouteChanged(report: RouteChangedReport): void; }>; runtime: Readonly<{ getReleaseSummary(): Promise; diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts index 4d39fb8..417af46 100644 --- a/src/application/ports/out/application-output-ports.ts +++ b/src/application/ports/out/application-output-ports.ts @@ -2,6 +2,7 @@ import type { AuthSessionPort } from "../auth-session-port.js"; import type { ReleaseInfoPort } from "../release-info-port.js"; import type { StoragePort } from "../storage-port.js"; import type { TelemetryPort } from "../telemetry-port.js"; +import type { DiagnosticsPort } from "../diagnostics-port.js"; /** * Capabilities required by application use cases. Implementations live in @@ -13,7 +14,8 @@ export type ApplicationOutputPorts = Readonly<{ "getState" | "subscribe" | "beginSignIn" | "signOut" | "recover" >; preferences: StoragePort; - diagnostics: TelemetryPort; + diagnostics: DiagnosticsPort; + telemetry: TelemetryPort; releaseInfo: ReleaseInfoPort; navigation: Readonly<{ reload(): void }>; }>; diff --git a/src/application/ports/out/index.ts b/src/application/ports/out/index.ts index b16cb7e..e83c511 100644 --- a/src/application/ports/out/index.ts +++ b/src/application/ports/out/index.ts @@ -9,3 +9,4 @@ export type { QueryCachePort } from "../query-cache-port.js"; export type { ReleaseInfoPort } from "../release-info-port.js"; export type { StoragePort } from "../storage-port.js"; export type { TelemetryPort } from "../telemetry-port.js"; +export type { DiagnosticsPort } from "../diagnostics-port.js"; diff --git a/src/application/ports/telemetry-port.js b/src/application/ports/telemetry-port.js deleted file mode 100644 index e660e62..0000000 --- a/src/application/ports/telemetry-port.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @typedef {{ emit(eventName: string, attributes: Record): void }} TelemetryPort - */ - -export {}; diff --git a/src/application/ports/telemetry-port.ts b/src/application/ports/telemetry-port.ts new file mode 100644 index 0000000..a19d97e --- /dev/null +++ b/src/application/ports/telemetry-port.ts @@ -0,0 +1,10 @@ +import type { TelemetryEventName } from "../../contracts/telemetry.js"; + +export type { TelemetryEventName }; + +export type TelemetryPort = Readonly<{ + emit( + eventName: TelemetryEventName, + attributes: Record, + ): void; +}>; diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index c7d646a..3454208 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; +import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.js"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { createRuntimeComposition } from "./create-runtime-composition.js"; import { initializeColorScheme } from "./initialize-color-scheme.js"; @@ -27,6 +28,7 @@ async function boot() { ? error.safe : { supportReference: "boot:unknown" }; + recordBootFailure(error, safe); root.render(); } } diff --git a/src/bootstrap/runtime-adapters.js b/src/bootstrap/runtime-adapters.js index 90c89b8..454ca4c 100644 --- a/src/bootstrap/runtime-adapters.js +++ b/src/bootstrap/runtime-adapters.js @@ -4,6 +4,7 @@ import { createUnavailableSessionAdapter, } from "../adapters/auth/external-session-adapter.js"; import { createHttpClient } from "../adapters/http/client.js"; +import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.js"; import { createQueryClient, } from "../adapters/query-cache/tanstack-query-cache.js"; @@ -53,6 +54,8 @@ function storageOrUndefined(value) { * fetcher?: typeof fetch, * clock?: import("../application/ports/clock-port.js").ClockPort, * scheduler?: Parameters[0]["scheduler"] + * diagnostics?: Parameters[0]["diagnostics"], + * telemetry?: Parameters[0]["telemetry"] * }} context */ export function createRuntimeHttpClient(context, contract = {}) { @@ -64,6 +67,8 @@ export function createRuntimeHttpClient(context, contract = {}) { fetcher: context.fetcher, clock: context.clock, scheduler: context.scheduler, + diagnostics: context.diagnostics, + telemetry: context.telemetry, ...contract, }); } @@ -86,15 +91,28 @@ export async function createRuntimeAdapters(context) { : externalOwner ? createExternalAuthSessionAdapter(externalOwner) : createUnavailableSessionAdapter(); - const queryClient = createQueryClient(); - const storage = createBrowserStorageAdapter({ - localStorage: storageOrUndefined(host.localStorage), - sessionStorage: storageOrUndefined(host.sessionStorage), - }); + const diagnostics = createDiagnosticsAdapter(); const telemetry = createTelemetryAdapter({ enabled: config.TELEMETRY_ENABLED, endpoint: config.TELEMETRY_ENDPOINT, fetcher: context.fetcher, + onDrop(event) { + const attributes = + event.attributes && typeof event.attributes === "object" + ? event.attributes + : {}; + diagnostics.record({ + level: "warn", + eventId: "telemetry.delivery.dropped", + context: /** @type {Record} */ (attributes), + }); + }, + }); + const queryClient = createQueryClient({ diagnostics }); + const storage = createBrowserStorageAdapter({ + localStorage: storageOrUndefined(host.localStorage), + sessionStorage: storageOrUndefined(host.sessionStorage), + diagnostics, }); const releaseInfo = Object.freeze({ async getCurrent() { @@ -125,6 +143,8 @@ export async function createRuntimeAdapters(context) { runtime: context.runtime, authSession, fetcher: context.fetcher, + diagnostics, + telemetry, }, contract, ), @@ -134,7 +154,8 @@ export async function createRuntimeAdapters(context) { outputPorts: Object.freeze({ session: authSession, preferences: storage, - diagnostics: telemetry, + diagnostics, + telemetry, releaseInfo, navigation, }), diff --git a/src/contracts/diagnostics.ts b/src/contracts/diagnostics.ts new file mode 100644 index 0000000..1ce571d --- /dev/null +++ b/src/contracts/diagnostics.ts @@ -0,0 +1,183 @@ +export const DIAGNOSTIC_LEVELS = Object.freeze([ + "debug", + "info", + "warn", + "error", +] as const); +export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number]; + +export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({ + "app.boot.failed": Object.freeze({ level: "error" }), + "http.request.completed": Object.freeze({ level: "info" }), + "cache.operation.failed": Object.freeze({ level: "warn" }), + "storage.operation.failed": Object.freeze({ level: "warn" }), + "route.changed": Object.freeze({ level: "info" }), + "ui.render.failed": Object.freeze({ level: "error" }), + "release.mismatch.detected": Object.freeze({ level: "warn" }), + "telemetry.delivery.dropped": Object.freeze({ level: "warn" }), +}); +export type DiagnosticEventId = keyof typeof DIAGNOSTIC_EVENT_REGISTRY; + +export const DIAGNOSTIC_CONTEXT_ALLOWLIST = Object.freeze([ + "app_version", + "build_id", + "release_id", + "active_release_id", + "config_schema_version", + "api_contract_version", + "route_id", + "operation_id", + "correlation_id", + "error_kind", + "outcome", + "http_status_group", + "attempt_count_bucket", + "duration_bucket", + "component_boundary", + "mismatch_kind", + "operation", + "reason", + "queue_size_bucket", +] as const); +export type DiagnosticContextKey = + (typeof DIAGNOSTIC_CONTEXT_ALLOWLIST)[number]; +export type DiagnosticContext = Readonly< + Partial> +>; + +export type DiagnosticRecordInput = Readonly<{ + level: DiagnosticLevel; + eventId: DiagnosticEventId; + context?: Readonly>; +}>; + +export type DiagnosticRecord = Readonly<{ + level: DiagnosticLevel; + eventId: DiagnosticEventId; + timestamp: string; + context: DiagnosticContext; +}>; + +const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/; + +function projectDiagnosticRecordUnsafe( + input: DiagnosticRecordInput, + now: () => number = Date.now, +): + | Readonly<{ success: true; record: DiagnosticRecord }> + | Readonly<{ success: false; reason: string }> { + if (!Object.hasOwn(DIAGNOSTIC_EVENT_REGISTRY, input.eventId)) { + return { success: false, reason: "unregistered-event" }; + } + if (!DIAGNOSTIC_LEVELS.includes(input.level)) { + return { success: false, reason: "invalid-level" }; + } + const contextEntries = Object.entries(input.context ?? {}); + if (contextEntries.length > DIAGNOSTIC_CONTEXT_ALLOWLIST.length) { + return { success: false, reason: "invalid-context" }; + } + const projected: Partial< + Record + > = {}; + for (const [key, value] of contextEntries) { + if ( + !DIAGNOSTIC_CONTEXT_ALLOWLIST.includes(key as DiagnosticContextKey) + ) { + return { success: false, reason: "unknown-context" }; + } + if (typeof value === "string") { + if (!SAFE_VALUE.test(value)) { + return { success: false, reason: "invalid-context" }; + } + projected[key as DiagnosticContextKey] = value; + } else if (typeof value === "number" && Number.isFinite(value)) { + projected[key as DiagnosticContextKey] = value; + } else if (typeof value === "boolean") { + projected[key as DiagnosticContextKey] = value; + } else { + return { success: false, reason: "invalid-context" }; + } + } + let timestamp: string; + try { + timestamp = new Date(now()).toISOString(); + } catch { + timestamp = new Date(0).toISOString(); + } + return { + success: true, + record: Object.freeze({ + level: input.level, + eventId: input.eventId, + timestamp, + context: Object.freeze(projected), + }), + }; +} + +export function projectDiagnosticRecord( + input: DiagnosticRecordInput, + now: () => number = Date.now, +): ReturnType { + try { + return projectDiagnosticRecordUnsafe(input, now); + } catch { + return { success: false, reason: "serialization-failure" }; + } +} + +export function safeErrorKind(error: unknown): string { + try { + if (error && typeof error === "object") { + const record = error as Readonly>; + if ( + typeof record.kind === "string" && + /^[A-Z][A-Z0-9_]{0,63}$/.test(record.kind) + ) { + return record.kind; + } + if ( + typeof record.name === "string" && + /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(record.name) + ) { + const normalized = record.name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toUpperCase(); + return /^[A-Z][A-Z0-9_]{0,63}$/.test(normalized) + ? normalized + : "UNKNOWN_FAILURE"; + } + } + } catch { + return "UNKNOWN_FAILURE"; + } + return "UNKNOWN_FAILURE"; +} + +export function statusGroup(status: number | undefined): string { + return typeof status === "number" && Number.isFinite(status) + ? `${Math.max(0, Math.min(9, Math.floor(status / 100)))}xx` + : "none"; +} + +export function attemptBucket(attemptCount: number): string { + if (attemptCount <= 1) return "1"; + if (attemptCount === 2) return "2"; + if (attemptCount <= 4) return "3-4"; + return "5+"; +} + +export function durationBucket(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown"; + if (durationMs < 100) return "lt100ms"; + if (durationMs < 500) return "100-499ms"; + if (durationMs < 2_000) return "500-1999ms"; + return "gte2000ms"; +} + +export function queueSizeBucket(size: number): string { + if (size <= 0) return "0"; + if (size <= 10) return "1-10"; + if (size <= 50) return "11-50"; + return "51+"; +} diff --git a/src/contracts/telemetry.d.ts b/src/contracts/telemetry.d.ts new file mode 100644 index 0000000..42decb6 --- /dev/null +++ b/src/contracts/telemetry.d.ts @@ -0,0 +1,36 @@ +export type TelemetryEventName = + | "app.boot.failed" + | "api.request.failed" + | "ui.render.failed" + | "release.mismatch.detected" + | "telemetry.delivery.dropped"; + +export type TelemetryDefinition = Readonly<{ + eventName: TelemetryEventName; + trigger: string; + requiredAttributes: readonly string[]; + optionalAttributes: readonly string[]; + forbiddenAttributes: readonly string[]; + sampling: string; + delivery: "best-effort"; +}>; + +export type TelemetryEvent = Readonly<{ + eventName: TelemetryEventName; + timestamp: string; + attributes: Readonly>; +}>; + +export const TELEMETRY_ATTRIBUTE_ALLOWLIST: readonly string[]; +export const TELEMETRY_FORBIDDEN_ATTRIBUTES: readonly string[]; +export const TELEMETRY_REGISTRY: Readonly< + Record +>; + +export function projectTelemetryEvent( + eventName: string, + attributes: Record, + now?: () => number, +): + | Readonly<{ success: true; event: TelemetryEvent }> + | Readonly<{ success: false; reason: string }>; diff --git a/src/contracts/telemetry.js b/src/contracts/telemetry.js index 301ac22..df3466c 100644 --- a/src/contracts/telemetry.js +++ b/src/contracts/telemetry.js @@ -81,7 +81,7 @@ export const TELEMETRY_REGISTRY = Object.freeze({ "http_status_group", "attempt_count_bucket", "route_id", - ]), + ], ["operation_id", "duration_bucket"]), "ui.render.failed": event("ui.render.failed", "React boundary catch", [ "route_id", "build_id", @@ -101,11 +101,44 @@ export const TELEMETRY_REGISTRY = Object.freeze({ ), }); +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/; +const ATTRIBUTE_VALUE_POLICIES = + /** @type {Readonly boolean>>} */ ( + Object.freeze({ + route_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + operation_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + error_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + http_status_group: (value) => /^(?:[1-5]xx|none)$/.test(value), + attempt_count_bucket: (value) => /^(?:1|2|3|3-4|5\+)$/.test(value), + duration_bucket: (value) => + /^(?:lt100ms|100-499ms|500-1999ms|gte2000ms|unknown)$/.test( + value, + ), + component_boundary: (value) => + /^(?:route|feature|boot)$/.test(value), + mismatch_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + reason: (value) => + /^(?:queue-full|sink-failure|invalid-event|invalid-context|serialization-failure)$/.test( + value, + ), + queue_size_bucket: (value) => + /^(?:0|1-10|11-50|51\+)$/.test(value), + }) + ); + +/** @param {string} key @param {unknown} value */ +function validAttributeValue(key, value) { + if (typeof value !== "string") return false; + const policy = ATTRIBUTE_VALUE_POLICIES[key]; + return policy ? policy(value) : SAFE_IDENTIFIER.test(value); +} + /** * @param {string} eventName * @param {Record} attributes + * @param {() => number} [now] */ -export function projectTelemetryEvent(eventName, attributes) { +function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) { const registry = /** @type {Record} */ ( TELEMETRY_REGISTRY @@ -118,13 +151,47 @@ export function projectTelemetryEvent(eventName, attributes) { }; } + const attributeKeys = Object.keys(attributes); + if ( + attributeKeys.length > + TELEMETRY_ATTRIBUTE_ALLOWLIST.length + + TELEMETRY_FORBIDDEN_ATTRIBUTES.length + ) { + return { + success: /** @type {false} */ (false), + reason: "invalid-attribute-value", + }; + } + const unknown = attributeKeys.filter( + (key) => + !TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && + !TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key), + ); + if (unknown.length > 0) { + return { + success: /** @type {false} */ (false), + reason: "unknown-attributes", + }; + } const projected = Object.fromEntries( Object.entries(attributes).filter( - ([key]) => + ([key, value]) => TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && - !TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key), + !TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key) && + validAttributeValue(key, value), ), ); + const invalid = Object.entries(attributes).filter( + ([key, value]) => + TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && + !validAttributeValue(key, value), + ); + if (invalid.length > 0) { + return { + success: /** @type {false} */ (false), + reason: "invalid-attribute-value", + }; + } const missing = definition.requiredAttributes.filter( (key) => projected[key] === undefined, ); @@ -135,11 +202,34 @@ export function projectTelemetryEvent(eventName, attributes) { }; } + let timestamp; + try { + timestamp = new Date(now()).toISOString(); + } catch { + timestamp = new Date(0).toISOString(); + } return { success: /** @type {true} */ (true), event: Object.freeze({ eventName, + timestamp, attributes: Object.freeze(projected), }), }; } + +/** + * @param {string} eventName + * @param {Record} attributes + * @param {() => number} [now] + */ +export function projectTelemetryEvent(eventName, attributes, now = Date.now) { + try { + return projectTelemetryEventUnsafe(eventName, attributes, now); + } catch { + return { + success: /** @type {false} */ (false), + reason: "serialization-failure", + }; + } +} diff --git a/src/presentation/design-system/primitives/core.tsx b/src/presentation/design-system/primitives/core.tsx index 633f796..b814be1 100644 --- a/src/presentation/design-system/primitives/core.tsx +++ b/src/presentation/design-system/primitives/core.tsx @@ -341,6 +341,7 @@ export const Dialog = forwardRef( const { message } = useLocale(); const dialogRef = useRef(null); const previousFocusRef = useRef(null); + const focusRestoreGenerationRef = useRef(0); const titleId = useId(); const descriptionId = useId(); useImperativeHandle(forwardedRef, () => dialogRef.current!, []); @@ -348,6 +349,8 @@ export const Dialog = forwardRef( useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; + const generation = focusRestoreGenerationRef.current + 1; + focusRestoreGenerationRef.current = generation; if (open) { previousFocusRef.current = @@ -371,9 +374,19 @@ export const Dialog = forwardRef( } const previousFocus = previousFocusRef.current; previousFocusRef.current = null; - queueMicrotask(() => { - if (previousFocus?.isConnected) previousFocus.focus(); - }); + const restoreFocus = () => { + if ( + focusRestoreGenerationRef.current === generation && + previousFocus?.isConnected + ) { + previousFocus.focus(); + } + }; + if (typeof globalThis.requestAnimationFrame === "function") { + const frame = globalThis.requestAnimationFrame(restoreFocus); + return () => globalThis.cancelAnimationFrame(frame); + } + queueMicrotask(restoreFocus); }, [open]); return ( diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index 6674849..d681470 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -105,9 +105,16 @@ function InvalidRouteSurface({ code }: { code: string }) { ); } -function RouteLifecycle({ definition }: { definition: RouteDefinition }) { +function RouteLifecycle({ + definition, + buildId, +}: { + definition: RouteDefinition; + buildId: string; +}) { const location = useLocation(); const { message, resolve } = useLocale(); + const { diagnostics } = useApplication(); useEffect(() => { document.title = message("route.documentTitle", { title: resolve(`route.${definition.routeId}.title`), @@ -122,7 +129,19 @@ function RouteLifecycle({ definition }: { definition: RouteDefinition }) { } catch { // Non-browser test hosts may not implement scrolling. } - }, [definition, location.key, location.pathname, message, resolve]); + diagnostics.reportRouteChanged({ + routeId: definition.routeId, + buildId, + }); + }, [ + buildId, + definition, + diagnostics, + location.key, + location.pathname, + message, + resolve, + ]); return null; } @@ -244,7 +263,7 @@ function RegisteredRoute({ const content = ( - + }> { + it("preserves route, operation and request correlation through the vertical path", async () => { + const record = vi.fn(); + const operations = + REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly< + Record< + string, + ReturnType< + NonNullable[0]["getOperation"]> + > + > + >; + const client = createHttpClient({ + baseUrl: "https://api.test", + fetcher: async () => + Response.json({ + success: true, + data: [{ id: "reference-1", name: "Reference" }], + meta: { requestId: "safe-request", traceId: "safe-trace" }, + }), + getOperation(operationId) { + const operation = operations[operationId]; + if (!operation) throw new Error("Unregistered reference operation"); + return operation; + }, + validatePayload: validateReferencePayload, + validateRequest: validateReferenceRequest, + mapPayload: mapReferenceOperation, + correlationIdFactory: () => "reference-correlation", + diagnostics: { record }, + scheduler: { + setTimeout: () => 1, + clearTimeout: () => {}, + }, + }); + const application = createReferenceFeatureInput( + createReferenceHttpGateway(client), + ); + + await expect( + application.listResources({ limit: 20 }), + ).resolves.toMatchObject({ ok: true }); + expect(record).toHaveBeenCalledOnce(); + expect(record).toHaveBeenCalledWith({ + level: "info", + eventId: "http.request.completed", + context: expect.objectContaining({ + route_id: "REFERENCE_RESOURCE_LIST", + operation_id: "LIST_REFERENCE_RESOURCES", + correlation_id: "reference-correlation", + outcome: "success", + }), + }); + }); +}); diff --git a/tests/fixtures/diagnostics/forbidden/direct-console.ts b/tests/fixtures/diagnostics/forbidden/direct-console.ts new file mode 100644 index 0000000..7215b0c --- /dev/null +++ b/tests/fixtures/diagnostics/forbidden/direct-console.ts @@ -0,0 +1,3 @@ +export function leakToConsole(token: string) { + console.error("UNKNOWN_DIAGNOSTIC_EVENT", token); +} diff --git a/tests/fixtures/diagnostics/forbidden/raw-context.ts b/tests/fixtures/diagnostics/forbidden/raw-context.ts new file mode 100644 index 0000000..7de3896 --- /dev/null +++ b/tests/fixtures/diagnostics/forbidden/raw-context.ts @@ -0,0 +1,8 @@ +export function leakSensitiveContext(telemetry: { + emit(eventName: string, context: Record): void; +}) { + telemetry.emit("api.request.failed", { + authorization: "Bearer secret", + request_body: { email: "private@example.test" }, + }); +} diff --git a/tests/fixtures/typecheck/invalid-diagnostics-port.ts b/tests/fixtures/typecheck/invalid-diagnostics-port.ts new file mode 100644 index 0000000..6dce186 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-diagnostics-port.ts @@ -0,0 +1,11 @@ +import type { DiagnosticsPort } from "../../../src/application/ports/diagnostics-port.js"; +import type { TelemetryPort } from "../../../src/application/ports/telemetry-port.js"; + +declare const diagnostics: DiagnosticsPort; +declare const telemetry: TelemetryPort; + +diagnostics.record({ + level: "fatal", + eventId: "UNKNOWN_DIAGNOSTIC_EVENT", +}); +telemetry.emit("unknown.telemetry.event", {}); diff --git a/tests/helpers/create-test-application.js b/tests/helpers/create-test-application.js index f254c02..7d59034 100644 --- a/tests/helpers/create-test-application.js +++ b/tests/helpers/create-test-application.js @@ -5,7 +5,8 @@ import { createApplication } from "../../src/application/create-application.js"; * @param {{ * session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort, * preferences?: import("../../src/application/ports/storage-port.js").StoragePort, - * diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort, + * diagnostics?: import("../../src/application/ports/diagnostics-port.js").DiagnosticsPort, + * telemetry?: import("../../src/application/ports/telemetry-port.js").TelemetryPort, * releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort, * navigation?: { reload(): void }, * featureInputs?: Readonly> @@ -22,7 +23,8 @@ export function createTestApplication(overrides = {}) { write: () => ({ ok: /** @type {const} */ (true) }), remove: () => ({ ok: /** @type {const} */ (true) }), }, - diagnostics: overrides.diagnostics ?? { emit: () => {} }, + diagnostics: overrides.diagnostics ?? { record: () => {} }, + telemetry: overrides.telemetry ?? { emit: () => {} }, releaseInfo: overrides.releaseInfo ?? { diff --git a/tests/integration/http-diagnostics.test.ts b/tests/integration/http-diagnostics.test.ts new file mode 100644 index 0000000..81a99d5 --- /dev/null +++ b/tests/integration/http-diagnostics.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createHttpClient } from "../../src/adapters/http/client.js"; +import type { DiagnosticRecordInput } from "../../src/contracts/diagnostics.js"; +import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js"; + +function successResponse(data: unknown) { + return Response.json({ + success: true, + data, + meta: { requestId: "request-1", traceId: "trace-1" }, + }); +} + +function failureResponse(status: number) { + return Response.json( + { + success: false, + error: { code: "TEMPORARY" }, + meta: { requestId: "request-1", traceId: "trace-1" }, + }, + { status }, + ); +} + +type EmittedEvent = Readonly<{ + eventName: string; + attributes: Readonly>; +}>; + +function harness(fetcher: typeof fetch, maxRetryAttempts = 1) { + const records: DiagnosticRecordInput[] = []; + const emitted: EmittedEvent[] = []; + let currentTime = 0; + const client = createHttpClient({ + ...TEST_HTTP_CONTRACT, + baseUrl: "https://api.test", + fetcher, + maxRetryAttempts, + clock: { + now: () => currentTime, + sleep: async () => { + currentTime += 150; + }, + }, + scheduler: { + setTimeout: () => 1, + clearTimeout: () => {}, + }, + correlationIdFactory: () => "correlation-fixed", + diagnostics: { + record(input) { + records.push(structuredClone(input)); + }, + }, + telemetry: { + emit(eventName, attributes) { + emitted.push({ + eventName, + attributes: structuredClone(attributes), + }); + }, + }, + }); + return { client, records, emitted }; +} + +describe("HTTP diagnostics and terminal telemetry", () => { + it("records one successful reference route outcome and no failure event", async () => { + const { client, records, emitted } = harness( + vi.fn(async () => successResponse([])), + ); + + await expect( + client.execute({ + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", + }), + ).resolves.toMatchObject({ ok: true }); + + expect(records).toEqual([ + { + level: "info", + eventId: "http.request.completed", + context: { + route_id: "TEST_ROUTE", + operation_id: "LIST_ENTITIES", + correlation_id: "correlation-fixed", + outcome: "success", + error_kind: "NONE", + http_status_group: "none", + attempt_count_bucket: "1", + duration_bucket: "lt100ms", + }, + }, + ]); + expect(emitted).toEqual([]); + }); + + it("summarizes retry recovery once without a terminal failure event", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(failureResponse(503)) + .mockResolvedValueOnce(successResponse([])); + const { client, records, emitted } = harness(fetcher); + + await expect( + client.execute({ + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", + }), + ).resolves.toMatchObject({ ok: true }); + + expect(fetcher).toHaveBeenCalledTimes(2); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + eventId: "http.request.completed", + context: { + outcome: "recovered", + correlation_id: "correlation-fixed", + duration_bucket: "100-499ms", + }, + }); + expect(emitted).toEqual([]); + }); + + it("emits one bounded terminal failure after all attempts", async () => { + const fetcher = vi.fn(async () => failureResponse(503)); + const { client, records, emitted } = harness(fetcher); + + await expect( + client.execute({ + operationId: "CREATE_ENTITY", + routeId: "TEST_ROUTE", + body: { name: "private user input" }, + idempotencyKey: "private-idempotency-key", + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "SERVER_FAILURE" }, + }); + + expect(fetcher).toHaveBeenCalledTimes(2); + expect(records).toHaveLength(1); + expect(emitted).toEqual([ + { + eventName: "api.request.failed", + attributes: { + error_kind: "SERVER_FAILURE", + http_status_group: "5xx", + attempt_count_bucket: "2", + route_id: "TEST_ROUTE", + operation_id: "CREATE_ENTITY", + duration_bucket: "100-499ms", + }, + }, + ]); + expect(JSON.stringify({ records, emitted })).not.toMatch( + /private user input|private-idempotency-key/, + ); + }); + + it("records navigation abort without failure telemetry", async () => { + const caller = new AbortController(); + caller.abort("navigation"); + const fetcher = vi.fn(async (request: RequestInfo | URL) => { + const signal = (request as Request).signal; + if (signal.aborted) throw new DOMException("aborted", "AbortError"); + return successResponse([]); + }); + const { client, records, emitted } = harness( + fetcher as unknown as typeof fetch, + ); + + await expect( + client.execute({ + operationId: "LIST_ENTITIES", + routeId: "TEST_ROUTE", + signal: caller.signal, + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "REQUEST_ABORTED" }, + }); + + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + eventId: "http.request.completed", + context: { outcome: "aborted", error_kind: "REQUEST_ABORTED" }, + }); + expect(emitted).toEqual([]); + }); +}); diff --git a/tests/unit/application-boundary.test.ts b/tests/unit/application-boundary.test.ts index 8cd4ffd..da50bf6 100644 --- a/tests/unit/application-boundary.test.ts +++ b/tests/unit/application-boundary.test.ts @@ -36,6 +36,7 @@ describe("application input/output boundary", () => { it("uses fake output ports for preference, session, and safe diagnostics flows", () => { const write = vi.fn(() => ({ ok: true as const })); const emit = vi.fn(); + const record = vi.fn(); const ports = { session: { getState: () => "authenticated" as const, @@ -49,7 +50,8 @@ describe("application input/output boundary", () => { write, remove: () => ({ ok: true as const }), }, - diagnostics: { emit }, + diagnostics: { record }, + telemetry: { emit }, releaseInfo: { getCurrent: async () => ({ buildId: "build-a", @@ -82,20 +84,43 @@ describe("application input/output boundary", () => { buildId: "build-a", boundaryName: "route", }); + expect(record).toHaveBeenCalledWith({ + level: "error", + eventId: "ui.render.failed", + context: { + route_id: "APP_HOME", + build_id: "build-a", + component_boundary: "route", + }, + }); expect(emit).toHaveBeenCalledWith("ui.render.failed", { route_id: "APP_HOME", build_id: "build-a", component_boundary: "route", }); + application.diagnostics.reportRouteChanged({ + routeId: "APP_HOME", + buildId: "build-a", + }); + expect(record).toHaveBeenCalledWith({ + level: "info", + eventId: "route.changed", + context: { route_id: "APP_HOME", build_id: "build-a" }, + }); }); it("does not let a failing diagnostics output escape into presentation", () => { const application = createTestApplication({ diagnostics: { - emit() { + record() { throw new Error("sink details"); }, }, + telemetry: { + emit() { + throw new Error("telemetry details"); + }, + }, }); expect(() => diff --git a/tests/unit/chunk-recovery-runtime.test.ts b/tests/unit/chunk-recovery-runtime.test.ts index dda1a6d..43ebca9 100644 --- a/tests/unit/chunk-recovery-runtime.test.ts +++ b/tests/unit/chunk-recovery-runtime.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { createApplication } from "../../src/application/create-application.js"; import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; import type { StoragePort } from "../../src/application/ports/storage-port.js"; +import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.js"; +import type { TelemetryPort } from "../../src/application/ports/telemetry-port.js"; type ReleaseFixture = { buildId: string; @@ -40,12 +42,15 @@ function applicationWith(options: { storage?: StoragePort; refresh?: () => Promise>; reload?: () => void; + diagnostics?: DiagnosticsPort; + telemetry?: TelemetryPort; }) { const current = release("build-a", "release-a"); return createApplication({ session: createAnonymousSessionAdapter(), preferences: options.storage ?? memoryStorage(), - diagnostics: { emit: () => {} }, + diagnostics: options.diagnostics ?? { record: () => {} }, + telemetry: options.telemetry ?? { emit: () => {} }, releaseInfo: { getCurrent: async () => current, refresh: @@ -59,7 +64,13 @@ function applicationWith(options: { describe("production chunk recovery application input", () => { it("reloads exactly once for one active build/release pair", async () => { const reload = vi.fn(); - const application = applicationWith({ reload }); + const record = vi.fn(); + const emit = vi.fn(); + const application = applicationWith({ + reload, + diagnostics: { record }, + telemetry: { emit }, + }); const input = { chunkId: "route-home", failureKind: "CHUNK_LOAD_FAILURE" as const, @@ -69,6 +80,20 @@ describe("production chunk recovery application input", () => { action: "reload-once", releasePair: "build-a/release-a->build-b/release-b", }); + expect(record).toHaveBeenCalledWith({ + level: "warn", + eventId: "release.mismatch.detected", + context: { + build_id: "build-a", + active_release_id: "release-b", + mismatch_kind: "BUILD_MISMATCH", + }, + }); + expect(emit).toHaveBeenCalledWith("release.mismatch.detected", { + build_id: "build-a", + active_release_id: "release-b", + mismatch_kind: "BUILD_MISMATCH", + }); await expect(application.recovery.recoverChunk(input)).resolves.toEqual({ action: "support", reason: "reload-already-attempted", diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts new file mode 100644 index 0000000..a3f88a2 --- /dev/null +++ b/tests/unit/diagnostics.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createDiagnosticsAdapter, + getLastBootEvidence, + noOpDiagnostics, + recordBootFailure, +} from "../../src/adapters/diagnostics/bounded-diagnostics.js"; +import { + projectDiagnosticRecord, + safeErrorKind, +} from "../../src/contracts/diagnostics.js"; + +describe("structured diagnostics contract", () => { + it("projects only registered, bounded context with deterministic time", () => { + const projected = projectDiagnosticRecord( + { + level: "info", + eventId: "route.changed", + context: { route_id: "APP_HOME", build_id: "build-a" }, + }, + () => 0, + ); + + expect(projected).toEqual({ + success: true, + record: { + level: "info", + eventId: "route.changed", + timestamp: "1970-01-01T00:00:00.000Z", + context: { route_id: "APP_HOME", build_id: "build-a" }, + }, + }); + expect( + projectDiagnosticRecord({ + level: "error", + eventId: "ui.render.failed", + context: { raw_url: "https://example.test/private?token=secret" }, + }), + ).toEqual({ success: false, reason: "unknown-context" }); + expect( + projectDiagnosticRecord({ + level: "error", + eventId: "ui.render.failed", + context: { route_id: "private value with whitespace" }, + }), + ).toEqual({ success: false, reason: "invalid-context" }); + }); + + it("bounds evidence and isolates throwing sinks and hostile error objects", () => { + const sink = vi.fn(() => { + throw new Error("sink-secret"); + }); + const adapter = createDiagnosticsAdapter({ + maxEntries: 1, + now: () => 0, + sink, + }); + const circular: Record = { name: "TypeError" }; + circular.self = circular; + const hostile = new Proxy( + {}, + { + get() { + throw new Error("private getter"); + }, + ownKeys() { + throw new Error("private keys"); + }, + }, + ); + + expect(safeErrorKind(circular)).toBe("TYPE_ERROR"); + expect(safeErrorKind(hostile)).toBe("UNKNOWN_FAILURE"); + expect( + projectDiagnosticRecord({ + level: "error", + eventId: "ui.render.failed", + context: hostile, + }), + ).toEqual({ success: false, reason: "serialization-failure" }); + expect(() => + adapter.record({ + level: "info", + eventId: "route.changed", + context: { route_id: "APP_HOME" }, + }), + ).not.toThrow(); + adapter.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { operation: "query", error_kind: "UNKNOWN_FAILURE" }, + }); + + expect(adapter.entries()).toHaveLength(1); + expect(adapter.entries()[0]?.eventId).toBe("cache.operation.failed"); + expect(adapter.dropped()).toEqual({ + "queue-full": 1, + "sink-failure": 2, + }); + expect(JSON.stringify(adapter.entries())).not.toMatch( + /sink-secret|private getter/, + ); + }); + + it("creates safe pre-mount boot evidence and supports a true no-op", () => { + const circular: Record = { + name: "TypeError", + token: "credential-value", + stack: "private-stack", + }; + circular.self = circular; + + expect(() => + recordBootFailure( + circular, + { + buildId: "build-a", + configSchemaVersion: "1", + supportReference: "support-private", + }, + () => 0, + ), + ).not.toThrow(); + const evidence = getLastBootEvidence(); + expect(evidence?.diagnostic?.eventId).toBe("app.boot.failed"); + expect(evidence?.telemetry).toMatchObject({ + eventName: "app.boot.failed", + timestamp: "1970-01-01T00:00:00.000Z", + }); + expect(JSON.stringify(evidence)).not.toMatch( + /credential-value|private-stack|support-private/, + ); + expect(() => + noOpDiagnostics.record({ + level: "error", + eventId: "app.boot.failed", + }), + ).not.toThrow(); + }); +}); diff --git a/tests/unit/query-cache.test.js b/tests/unit/query-cache.test.js index c5bac1f..9329c9d 100644 --- a/tests/unit/query-cache.test.js +++ b/tests/unit/query-cache.test.js @@ -52,7 +52,10 @@ describe("TanStack QueryCachePort adapter", () => { it("normalizes adapter exceptions without raw key data", async () => { const client = new QueryClient(); vi.spyOn(client, "invalidateQueries").mockRejectedValue(new Error("secret-key")); - const adapter = createQueryCacheAdapter(client); + const record = vi.fn(); + const adapter = createQueryCacheAdapter(client, { + diagnostics: { record }, + }); const result = await adapter.invalidate(["resource", "sensitive-filter"]); expect(result).toMatchObject({ @@ -60,5 +63,16 @@ describe("TanStack QueryCachePort adapter", () => { error: { kind: "QUERY_CACHE_FAILURE" }, }); expect(JSON.stringify(result)).not.toContain("sensitive-filter"); + expect(record).toHaveBeenCalledWith({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation: "invalidate", + error_kind: "QUERY_CACHE_FAILURE", + }, + }); + expect(JSON.stringify(record.mock.calls)).not.toMatch( + /sensitive-filter|secret-key/, + ); }); }); diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js index 40181d1..cfca4c5 100644 --- a/tests/unit/runtime-adapters.test.js +++ b/tests/unit/runtime-adapters.test.js @@ -46,6 +46,9 @@ describe("runtime adapter composition", () => { releaseId: "release-a", }); expect(adapters.infrastructure.queryClient).toBeDefined(); + expect(adapters.outputPorts.diagnostics.record).toEqual(expect.any(Function)); + expect(adapters.outputPorts.telemetry.emit).toEqual(expect.any(Function)); + expect(adapters.outputPorts.telemetry.pendingCount()).toBe(0); expect(adapters).not.toHaveProperty("http"); expect(adapters).not.toHaveProperty("storage"); }); diff --git a/tests/unit/storage-registry.test.js b/tests/unit/storage-registry.test.js index f5af2de..167d4b6 100644 --- a/tests/unit/storage-registry.test.js +++ b/tests/unit/storage-registry.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.js"; import { @@ -61,13 +61,27 @@ describe("storage registry", () => { it("falls back to memory when preference storage quota is exceeded", () => { const localStorage = createStorage({ quota: true }); - const adapter = createBrowserStorageAdapter({ localStorage }); + const record = vi.fn(); + const adapter = createBrowserStorageAdapter({ + localStorage, + diagnostics: { record }, + }); expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({ ok: false, fallback: "memory", error: { kind: "STORAGE_QUOTA_EXCEEDED" }, }); expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" }); + expect(record).toHaveBeenCalledOnce(); + expect(record).toHaveBeenCalledWith({ + level: "warn", + eventId: "storage.operation.failed", + context: { + operation: "write:COLOR_SCHEME", + error_kind: "STORAGE_QUOTA_EXCEEDED", + }, + }); + expect(JSON.stringify(record.mock.calls)).not.toContain("dark"); }); it("discards data from a previous schema version", () => { diff --git a/tests/unit/telemetry.test.js b/tests/unit/telemetry.test.js index c5c8106..ccf4e23 100644 --- a/tests/unit/telemetry.test.js +++ b/tests/unit/telemetry.test.js @@ -33,15 +33,26 @@ describe("telemetry registry and redaction", () => { } }); - it("uses a default-deny attribute projection", () => { + it("redacts forbidden attributes and rejects unknown context", () => { const projected = projectTelemetryEvent("api.request.failed", { ...validAttributes, raw_url: "https://api.test/path?token=secret", - unregistered: "private", }); expect(projected.success).toBe(true); - expect(JSON.stringify(projected)).not.toMatch(/raw_url|token|secret|unregistered/); + expect(JSON.stringify(projected)).not.toMatch(/raw_url|token|secret/); + expect( + projectTelemetryEvent("api.request.failed", { + ...validAttributes, + unregistered: "private", + }), + ).toEqual({ success: false, reason: "unknown-attributes" }); + expect( + projectTelemetryEvent("api.request.failed", { + ...validAttributes, + route_id: "/users/actual-user-id", + }), + ).toEqual({ success: false, reason: "invalid-attribute-value" }); }); it("validates traceparent without exposing invalid values", () => { @@ -71,10 +82,18 @@ describe("best-effort telemetry adapter", () => { expect(adapter.pendingCount()).toBe(2); expect(adapter.droppedCount()).toBe(1); + expect(adapter.dropReasons()).toEqual({ "queue-full": 1 }); + expect(adapter.deliveryEvidence()).toMatchObject({ + eventName: "telemetry.delivery.dropped", + attributes: { reason: "queue-full", queue_size_bucket: "1-10" }, + }); expect(scheduled).toHaveLength(1); }); it("degrades on sink failure without throwing or recursive events", async () => { + const onDrop = vi.fn(() => { + throw new Error("observer unavailable"); + }); const adapter = createTelemetryAdapter({ enabled: true, endpoint: "https://telemetry.test/events", @@ -82,13 +101,50 @@ describe("best-effort telemetry adapter", () => { fetcher: async () => { throw new Error("sink unavailable"); }, + onDrop, }); expect(() => adapter.emit("api.request.failed", validAttributes)).not.toThrow(); await expect(adapter.flush()).resolves.toBeUndefined(); expect(adapter.droppedCount()).toBe(1); + expect(adapter.dropReasons()).toEqual({ "sink-failure": 1 }); + expect(onDrop).toHaveBeenCalledOnce(); expect(adapter.pendingCount()).toBe(0); }); + it("never serializes circular or unbounded event context", () => { + const adapter = createTelemetryAdapter({ + enabled: true, + endpoint: "https://telemetry.test/events", + schedule: () => {}, + }); + const circular = {}; + circular.self = circular; + const hostile = new Proxy( + {}, + { + ownKeys() { + throw new Error("private proxy value"); + }, + }, + ); + + expect(() => + adapter.emit("api.request.failed", { + ...validAttributes, + unregistered: circular, + }), + ).not.toThrow(); + expect(adapter.pendingCount()).toBe(0); + expect(adapter.dropReasons()).toEqual({ "invalid-context": 1 }); + expect(() => + adapter.emit("api.request.failed", hostile), + ).not.toThrow(); + expect(adapter.dropReasons()).toEqual({ + "invalid-context": 1, + "serialization-failure": 1, + }); + }); + it("performs no network or queue work when disabled", async () => { const fetcher = vi.fn(); const adapter = createTelemetryAdapter({ @@ -102,4 +158,25 @@ describe("best-effort telemetry adapter", () => { expect(fetcher).not.toHaveBeenCalled(); expect(adapter.pendingCount()).toBe(0); }); + + it("flushes on page exit and removes the lifecycle listener", async () => { + const lifecycle = new EventTarget(); + const remove = vi.spyOn(lifecycle, "removeEventListener"); + const fetcher = vi.fn(async () => new Response(null, { status: 204 })); + const adapter = createTelemetryAdapter({ + enabled: true, + endpoint: "https://telemetry.test/events", + schedule: () => {}, + lifecycle, + fetcher, + }); + adapter.emit("api.request.failed", validAttributes); + + lifecycle.dispatchEvent(new Event("pagehide")); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + adapter.dispose(); + + expect(adapter.pendingCount()).toBe(0); + expect(remove).toHaveBeenCalledWith("pagehide", expect.any(Function)); + }); });