merge: diagnostics and telemetry runtime

This commit is contained in:
donghyeon-ka
2026-07-26 16:42:31 +09:00
43 changed files with 1760 additions and 116 deletions
+6 -1
View File
@@ -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"
@@ -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와 안전 계약을 함께 되돌리지 않는다.
@@ -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 분리는 미완성
@@ -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`
**목표**
@@ -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
@@ -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
@@ -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. 금지 패턴
+20
View File
@@ -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: {
+3
View File
@@ -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",
+120
View File
@@ -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<string[]>} */
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<Record<string, string>>} */ ({
"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<Record<string, string>>} */ ({
"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`,
);
@@ -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<string, number>();
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<Record<string, unknown>> | 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;
}
+98 -20
View File
@@ -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<string, string | number>,
* searchParams?: unknown,
* signal?: AbortSignal,
* idempotencyKey?: string
* idempotencyKey?: string,
* correlationId?: string
* }} [legacyInput]
* @returns {Promise<HttpResult>}
*/
@@ -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",
);
}
}
}
+1
View File
@@ -8,6 +8,7 @@ export type OperationRequestInput = Readonly<{
body?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
correlationId?: string;
}>;
export type RequestTargetResult =
@@ -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, {
@@ -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.
}
}
+76 -13
View File
@@ -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<Record<string, unknown>>) => void,
* lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">
* }} 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<Record<string, unknown>>}>} */ (
@@ -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<Record<string, unknown>> | 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<string, unknown>} 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,
});
}
+60 -1
View File
@@ -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,
@@ -0,0 +1,5 @@
import type { DiagnosticRecordInput } from "../../contracts/diagnostics.js";
export type DiagnosticsPort = Readonly<{
record(input: DiagnosticRecordInput): void;
}>;
@@ -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<ReleaseSummary>;
@@ -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 }>;
}>;
+1
View File
@@ -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";
-5
View File
@@ -1,5 +0,0 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
+10
View File
@@ -0,0 +1,10 @@
import type { TelemetryEventName } from "../../contracts/telemetry.js";
export type { TelemetryEventName };
export type TelemetryPort = Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
+2
View File
@@ -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(<BootErrorShell {...safe} />);
}
}
+27 -6
View File
@@ -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<typeof createHttpClient>[0]["scheduler"]
* diagnostics?: Parameters<typeof createHttpClient>[0]["diagnostics"],
* telemetry?: Parameters<typeof createHttpClient>[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<string, unknown>} */ (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,
}),
+183
View File
@@ -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<Record<DiagnosticContextKey, string | number | boolean>>
>;
export type DiagnosticRecordInput = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
context?: Readonly<Record<string, unknown>>;
}>;
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<DiagnosticContextKey, string | number | boolean>
> = {};
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<typeof projectDiagnosticRecordUnsafe> {
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<Record<string, unknown>>;
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+";
}
+36
View File
@@ -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<Record<string, unknown>>;
}>;
export const TELEMETRY_ATTRIBUTE_ALLOWLIST: readonly string[];
export const TELEMETRY_FORBIDDEN_ATTRIBUTES: readonly string[];
export const TELEMETRY_REGISTRY: Readonly<
Record<TelemetryEventName, TelemetryDefinition>
>;
export function projectTelemetryEvent(
eventName: string,
attributes: Record<string, unknown>,
now?: () => number,
):
| Readonly<{ success: true; event: TelemetryEvent }>
| Readonly<{ success: false; reason: string }>;
+94 -4
View File
@@ -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<Record<string, (value: string) => 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<string, unknown>} attributes
* @param {() => number} [now]
*/
export function projectTelemetryEvent(eventName, attributes) {
function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) {
const registry =
/** @type {Record<string, (typeof TELEMETRY_REGISTRY)[keyof typeof TELEMETRY_REGISTRY]>} */ (
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<string, unknown>} 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",
};
}
}
@@ -341,6 +341,7 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
const { message } = useLocale();
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusRestoreGenerationRef = useRef(0);
const titleId = useId();
const descriptionId = useId();
useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
@@ -348,6 +349,8 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
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<HTMLDialogElement, DialogProps>(
}
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 (
+22 -3
View File
@@ -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 = (
<RouteInputContext.Provider value={parsed.data}>
<CanonicalRouteRedirect input={parsed.data} />
<RouteLifecycle definition={definition} />
<RouteLifecycle definition={definition} buildId={buildId} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
<ChunkRecoveryBoundary
chunkId={definition.chunkId}
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../../src/adapters/http/client.js";
import { createReferenceHttpGateway } from "../../../src/features/reference-feature/adapters/reference-http-gateway.js";
import { createReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.js";
import { REFERENCE_FEATURE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
import { mapReferenceOperation } from "../../../src/features/reference-feature/contracts/reference-mapper.js";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../../../src/features/reference-feature/contracts/reference-schemas.js";
describe("reference feature diagnostics correlation", () => {
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<Parameters<typeof createHttpClient>[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",
}),
});
});
});
@@ -0,0 +1,3 @@
export function leakToConsole(token: string) {
console.error("UNKNOWN_DIAGNOSTIC_EVENT", token);
}
+8
View File
@@ -0,0 +1,8 @@
export function leakSensitiveContext(telemetry: {
emit(eventName: string, context: Record<string, unknown>): void;
}) {
telemetry.emit("api.request.failed", {
authorization: "Bearer secret",
request_body: { email: "private@example.test" },
});
}
+11
View File
@@ -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", {});
+4 -2
View File
@@ -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<Record<string, unknown>>
@@ -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 ??
{
+193
View File
@@ -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<Record<string, unknown>>;
}>;
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<typeof fetch>()
.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([]);
});
});
+27 -2
View File
@@ -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(() =>
+27 -2
View File
@@ -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<ReturnType<typeof release>>;
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<DiagnosticsPort["record"]>();
const emit = vi.fn<TelemetryPort["emit"]>();
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",
+141
View File
@@ -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<string, unknown> = { 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<string, unknown> = {
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();
});
});
+15 -1
View File
@@ -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/,
);
});
});
+3
View File
@@ -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");
});
+16 -2
View File
@@ -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", () => {
+80 -3
View File
@@ -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));
});
});