diff --git a/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md index bb7a2ec..28eb4c8 100644 --- a/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md +++ b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md @@ -37,6 +37,21 @@ vendor 결정 전에는 안전한 기본값이 아니다. abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다. `api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만 정확히 한 번 발행한다. +5-1. V2 client와 V3 contract executor는 각각 자신의 logical execution에 대해 + 이 규칙을 만족한다. V3에서는 execution site가 `HttpExecutionObservation` + typed record 하나만 만들고, composition root의 + `createHttpObservationProjector`가 유일한 projection authority다. observation은 + arbitrary context map이 아니며 projector는 `route_id`, `operation_id`, + `operation`, `outcome`, `error_kind`, `http_status_group`, + `attempt_count_bucket`, `duration_bucket`만 사용한다. raw attempt count, + duration, status, URL, intent, key, input identity와 내부 `terminalReason`은 + sink로 나가지 않는다. effect certainty가 운영상 필요해지면 `effect_certainty` + key와 닫힌 value policy를 contract·fixture·이 ADR에 동시에 추가한 뒤에만 + 전달한다. +5-2. caller cancellation과 scope fence는 API failure가 아니다. diagnostics는 한 + 번 남기고 `api.request.failed`는 발행하지 않는다. +5-3. `routeId`는 installed operation-executor 경계의 필수 입력이다. feature + gateway가 소유한 low-cardinality route identity를 URL에서 재구성하지 않는다. 6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`, `telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage 실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다. @@ -79,6 +94,11 @@ route/application/HTTP/cache/storage/bootstrap queue full, sink/observer failure와 pre-mount boot evidence를 검증한다. - HTTP integration은 success, retry recovery, terminal failure와 abort의 producer 횟수, route/operation/correlation context와 요청 값 비노출을 검증한다. +- `tests/integration/http-execution-v3-observability.test.ts`는 V3 terminal + outcome이 실제로 closed allowlist를 통과하는지, terminal non-abort failure가 + `api.request.failed`를 정확히 한 번 발행하는지, cancellation/scope fence가 + 발행하지 않는지, feature route ID가 executor 경계까지 보존되는지, sink 예외가 + HTTP 결과를 바꾸지 못하는지를 검증한다. - cache/storage/release/application/runtime test는 각 production wiring과 diagnostics failure isolation을 검증한다. diff --git a/docs/operations/adapter-remediation-ledger.md b/docs/operations/adapter-remediation-ledger.md index c1e1c6a..2f5f709 100644 --- a/docs/operations/adapter-remediation-ledger.md +++ b/docs/operations/adapter-remediation-ledger.md @@ -72,7 +72,7 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at | ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence | | --- | --- | --- | --- | --- | --- | --- | -| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | — | `NOT_STARTED` | diagnostics/telemetry producer gate regression | — | +| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | `fix: restore V3 HTTP observability` | `FIXED_NOT_RELEASED` | diagnostics/telemetry producer gate regression | Red 5/5 failed → green 5/5; `check:diagnostics` PASS (8 diagnostics, 5 telemetry producers); `check:types` PASS; `check:architecture` PASS | | N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | — | `NOT_STARTED` | authenticated request 4xx spike after profile enforcement | — | | N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts` | — | `NOT_STARTED` | command effect verdict regression | — | | N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts` | — | `NOT_STARTED` | telemetry delivery loss after teardown change | — | diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 7de66b1..8ab5568 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -134,6 +134,12 @@ export type CancellationOwner = | "DEADLINE"; export interface HttpExecutionContext { + /** + * §7.4. The low-cardinality route identity that owns this logical execution. + * It is required at the installed operation-executor boundary so a terminal + * outcome can always be attributed without reconstructing it from a URL. + */ + readonly routeId: string; readonly signal?: AbortSignal; readonly scope: CacheScopeSnapshot; readonly intent?: MutationIntent; @@ -147,13 +153,70 @@ export interface ContractHttpExecutor { ): Promise>; } +/** + * §7.4 / VD-07. One typed internal record per logical execution. It is not an + * arbitrary context map: the composition root owns the projection into the + * closed diagnostics and telemetry buckets, and raw attempt count, duration and + * status never leave that projection. + */ export type HttpExecutionObservation = Readonly<{ + routeId: string; + operationId: string; diagnosticsOperation: string; - outcome: string; - attempts: number; - certainty: string; + outcome: HttpExecutionOutcome["kind"]; + errorKind: string; + status?: number; + attemptCount: number; + durationMs: number; + effect: HttpEffectCertainty; + cancellationOwner?: CancellationOwner; + /** + * The internal terminal-reason label recorded by the execution site. It is + * evidence for the HTTP scenario catalog only; the composition-root + * projection never forwards it to diagnostics or telemetry. + */ + terminalReason: string; }>; +/** + * The outcome is the single authority for the observed error kind. The terminal + * reason only distinguishes an internal runtime failure from a transport + * failure, because both surface as the same public outcome. + */ +function observationErrorKind( + outcome: HttpExecutionOutcome, + terminalReason: string, +): string { + switch (outcome.kind) { + case "SUCCESS": + return "NONE"; + case "PROBLEM": + return "PROBLEM"; + case "UNAUTHENTICATED": + return "UNAUTHENTICATED"; + case "FORBIDDEN": + return "FORBIDDEN"; + case "RATE_LIMITED": + return "RATE_LIMITED"; + case "CONTRACT_VIOLATION": + return outcome.violation.kind; + case "TRANSPORT_FAILURE": + return terminalReason === "RUNTIME_FAILURE" + ? "RUNTIME_FAILURE" + : outcome.failure.kind; + case "CANCELLED": + return "REQUEST_ABORTED"; + } +} + +function observationStatus( + outcome: HttpExecutionOutcome, +): number | undefined { + return outcome.kind === "SUCCESS" || outcome.kind === "PROBLEM" + ? outcome.metadata.status + : undefined; +} + export type ContractHttpExecutorDependencies = Readonly<{ baseUrl: string; /** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */ @@ -308,7 +371,8 @@ export function createContractHttpExecutor( // §8.5. One monotonic deadline covers credential resolution, encoding, // backoff, every physical attempt, body read and validation. - const deadlineAt = now() + policy.totalDeadlineMs; + const startedAt = now(); + const deadlineAt = startedAt + policy.totalDeadlineMs; const remaining = () => deadlineAt - now(); let attemptState: PhysicalAttemptState = "PREPARING"; @@ -353,16 +417,28 @@ export function createContractHttpExecutor( const finish = ( outcome: HttpExecutionOutcome, - certainty: string, + terminalReason: string, ): HttpExecutionOutcome => { disposeLifetime(); try { - dependencies.observe?.({ - diagnosticsOperation: policy.diagnosticsOperation, - outcome: outcome.kind, - attempts, - certainty, - }); + const status = observationStatus(outcome); + dependencies.observe?.( + Object.freeze({ + routeId: context.routeId, + operationId: contract.operationId, + diagnosticsOperation: policy.diagnosticsOperation, + outcome: outcome.kind, + errorKind: observationErrorKind(outcome, terminalReason), + ...(status === undefined ? {} : { status }), + attemptCount: attempts, + durationMs: Math.max(0, now() - startedAt), + effect: outcome.effect, + terminalReason, + ...(terminalCancellation === null + ? {} + : { cancellationOwner: terminalCancellation }), + }), + ); } catch { // Observation is outside the execution authority. } diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index f0b5bbb..1e4842c 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -6,7 +6,17 @@ import { } from "../adapters/auth/external-session-adapter.ts"; import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts"; import { createHttpClient } from "../adapters/http/client.ts"; -import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts"; +import { + createContractHttpExecutor, + type HttpExecutionObservation, +} from "../adapters/http/http-execution-v3.ts"; +import { + attemptBucket, + durationBucket, + statusGroup, + type DiagnosticRecordInput, +} from "../contracts/diagnostics.ts"; +import type { TelemetryEventName } from "../contracts/telemetry.ts"; import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts"; import { createTanStackCacheCoordinator, @@ -162,6 +172,72 @@ export function createRuntimeHttpClient( }); } +/** + * VD-07. Exactly one diagnostic per logical V3 execution and exactly one + * `api.request.failed` telemetry event per terminal non-abort failure. + * + * The projection is closed: only registered context keys and bucketed values + * reach the sinks, and neither sink can change the HTTP outcome, because the + * caller invokes this inside the executor's isolated observation boundary. + */ +export function createHttpObservationProjector( + sinks: Readonly<{ + diagnostics: Readonly<{ record(input: DiagnosticRecordInput): void }>; + telemetry: Readonly<{ + emit( + eventName: TelemetryEventName, + attributes: Record, + ): void; + }>; + }>, +): (observation: HttpExecutionObservation) => void { + return (observation) => { + const safeAttributes = { + route_id: observation.routeId, + operation_id: observation.operationId, + error_kind: observation.errorKind, + http_status_group: statusGroup(observation.status), + attempt_count_bucket: attemptBucket(observation.attemptCount), + duration_bucket: durationBucket(observation.durationMs), + }; + try { + sinks.diagnostics.record({ + level: observation.outcome === "SUCCESS" ? "info" : "warn", + eventId: "http.request.completed", + context: { + ...safeAttributes, + operation: observation.diagnosticsOperation, + outcome: observation.outcome, + }, + }); + } catch { + // Diagnostics cannot change a contract execution outcome. + } + if (!isTerminalNonAbortFailure(observation)) return; + try { + sinks.telemetry.emit("api.request.failed", { ...safeAttributes }); + } catch { + // Telemetry cannot change a contract execution outcome. + } + }; +} + +/** + * Cancellation and scope fencing are caller- or generation-owned decisions, not + * API failures. They produce a diagnostic once and never `api.request.failed`. + */ +function isTerminalNonAbortFailure( + observation: HttpExecutionObservation, +): boolean { + if (observation.outcome === "SUCCESS") return false; + if (observation.outcome === "CANCELLED") return false; + if (observation.cancellationOwner !== undefined) return false; + return !( + observation.outcome === "CONTRACT_VIOLATION" && + observation.errorKind === "SCOPE_FENCED" + ); +} + export async function createRuntimeAdapters( context: RuntimeAdaptersContext, ) { @@ -328,32 +404,17 @@ export async function createRuntimeAdapters( return Object.freeze({ kind: "UNAVAILABLE" as const }); } }, - observe(observation) { - try { - diagnostics.record({ - level: - observation.outcome === "SUCCESS" ? "info" : "warn", - eventId: "http.request.completed", - context: { - operation_id: observation.diagnosticsOperation, - outcome: observation.outcome, - attempts: observation.attempts, - certainty: observation.certainty, - }, - }); - } catch { - // Diagnostics cannot change a contract execution outcome. - } - }, + observe: createHttpObservationProjector({ diagnostics, telemetry }), }); const contractOperations = Object.freeze({ async execute( operationId: string, input: unknown, executionContext: Readonly<{ + routeId: string; signal?: AbortSignal; intent?: MutationIntent; - }> = {}, + }>, ) { const operation = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); @@ -368,6 +429,7 @@ export async function createRuntimeAdapters( }); } const outcome = await contractHttp.execute(operation, input, { + routeId: executionContext.routeId, scope: serverStateScope.getSnapshot(), ...(executionContext.signal === undefined ? {} diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts index 3540253..9069d66 100644 --- a/src/features/reference-feature/adapters/create-reference-feature-input.ts +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -23,7 +23,8 @@ export type InstalledContractOperationExecutor = Readonly<{ execute( operationId: string, input: unknown, - context?: Readonly<{ + context: Readonly<{ + routeId: string; signal?: AbortSignal; intent?: MutationIntent; }>, @@ -48,6 +49,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{ operationId, input, { + // §7.4. The gateway owns the low-cardinality route identity; losing + // it here is what made every V3 diagnostic unattributable. + routeId: request.routeId, ...(signal === undefined ? {} : { signal }), ...(intent === undefined ? {} : { intent }), }, diff --git a/tests/integration/http-execution-contract.test.ts b/tests/integration/http-execution-contract.test.ts index eebedd5..1079ca5 100644 --- a/tests/integration/http-execution-contract.test.ts +++ b/tests/integration/http-execution-contract.test.ts @@ -98,7 +98,7 @@ describe("HTTP operation execution contract", () => { executor.execute( nonKeyedCommand, { name: "created" }, - { scope, intent }, + { routeId: "TEST_ROUTE", scope, intent }, ), ).resolves.toMatchObject({ kind: "SUCCESS" }); expect(observedKeys).toEqual([null]); @@ -109,7 +109,7 @@ describe("HTTP operation execution contract", () => { executor.execute( nonKeyedCommand, { name: "created" }, - { scope, intent: { ...intent, idempotencyKey: "unexpected-key" } }, + { routeId: "TEST_ROUTE", scope, intent: { ...intent, idempotencyKey: "unexpected-key" } }, ), ).resolves.toMatchObject({ kind: "CONTRACT_VIOLATION", diff --git a/tests/integration/http-execution-v3-observability.test.ts b/tests/integration/http-execution-v3-observability.test.ts new file mode 100644 index 0000000..168597c --- /dev/null +++ b/tests/integration/http-execution-v3-observability.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts"; +import type { + HttpExecutionObservation, +} from "../../src/adapters/http/http-execution-v3.ts"; +import { createHttpObservationProjector } from "../../src/bootstrap/runtime-adapters.ts"; +import { createReferenceFeatureInstalledInput } from "../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; +import { + DIAGNOSTIC_CONTEXT_ALLOWLIST, + projectDiagnosticRecord, + type DiagnosticRecordInput, +} from "../../src/contracts/diagnostics.ts"; +import { + projectTelemetryEvent, + type TelemetryEventName, +} from "../../src/contracts/telemetry.ts"; +import { + TEST_CREATE_HTTP_CONTRACT, + TEST_LIST_HTTP_CONTRACT, +} from "../helpers/external-contract-fixture.ts"; + +const ROUTE_ID = "TEST_ROUTE"; + +function scopeSnapshot(isCurrent: () => boolean = () => true) { + return Object.freeze({ + generation: 1, + fingerprint: "scope-1", + identities: Object.freeze({}) as never, + signal: new AbortController().signal, + isCurrent, + }); +} + +type RecordedDiagnostic = DiagnosticRecordInput; +type RecordedTelemetry = Readonly<{ + eventName: TelemetryEventName; + attributes: Readonly>; +}>; + +function recordingSinks() { + const diagnostics: RecordedDiagnostic[] = []; + const telemetry: RecordedTelemetry[] = []; + return { + diagnostics, + telemetry, + projector: createHttpObservationProjector({ + diagnostics: { + record(input: DiagnosticRecordInput) { + diagnostics.push(input); + }, + }, + telemetry: { + emit( + eventName: TelemetryEventName, + attributes: Record, + ) { + telemetry.push(Object.freeze({ eventName, attributes })); + }, + }, + }), + }; +} + +describe("V3 HTTP observability projection", () => { + it("projects every V3 terminal outcome through the closed diagnostics allowlist", async () => { + const sinks = recordingSinks(); + + const cases: readonly Readonly<{ + label: string; + fetcher: typeof fetch; + }>[] = [ + { + label: "SUCCESS", + fetcher: (async () => + Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch, + }, + { + label: "PROBLEM", + fetcher: (async () => + Response.json( + { type: "about:blank", title: "nope", status: 400 }, + { status: 400 }, + )) as unknown as typeof fetch, + }, + { + label: "TRANSPORT_FAILURE", + fetcher: (async () => { + throw new TypeError("network down"); + }) as unknown as typeof fetch, + }, + { + label: "CONTRACT_VIOLATION", + fetcher: (async () => + new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + })) as unknown as typeof fetch, + }, + ]; + + for (const testCase of cases) { + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY" as const, + headers: {}, + credentials: "omit" as const, + }), + fetcher: testCase.fetcher, + observe: sinks.projector, + }); + await executor.execute( + TEST_LIST_HTTP_CONTRACT, + { limit: 5 }, + { routeId: ROUTE_ID, scope: scopeSnapshot() }, + ); + } + + expect(sinks.diagnostics).toHaveLength(cases.length); + for (const recorded of sinks.diagnostics) { + expect(recorded.eventId).toBe("http.request.completed"); + const contextKeys = Object.keys(recorded.context ?? {}); + expect(contextKeys).toEqual( + expect.arrayContaining([ + "route_id", + "operation_id", + "operation", + "outcome", + "error_kind", + "http_status_group", + "attempt_count_bucket", + "duration_bucket", + ]), + ); + for (const key of contextKeys) { + expect(DIAGNOSTIC_CONTEXT_ALLOWLIST).toContain(key); + } + expect(contextKeys).not.toContain("attempts"); + expect(contextKeys).not.toContain("certainty"); + const projection = projectDiagnosticRecord(recorded); + expect(projection.success).toBe(true); + } + }); + + it("emits one failure telemetry event for a non-abort terminal failure", async () => { + const sinks = recordingSinks(); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY" as const, + headers: {}, + credentials: "omit" as const, + }), + fetcher: (async () => { + throw new TypeError("network down"); + }) as unknown as typeof fetch, + observe: sinks.projector, + }); + + const outcome = await executor.execute( + TEST_LIST_HTTP_CONTRACT, + { limit: 5 }, + { routeId: ROUTE_ID, scope: scopeSnapshot() }, + ); + + expect(outcome.kind).toBe("TRANSPORT_FAILURE"); + expect(sinks.telemetry).toHaveLength(1); + const emitted = sinks.telemetry[0]; + expect(emitted?.eventName).toBe("api.request.failed"); + expect(emitted?.attributes).toMatchObject({ + route_id: ROUTE_ID, + operation_id: "TEST_LIST_ENTITIES", + error_kind: "NETWORK_FAILURE", + http_status_group: "none", + attempt_count_bucket: "1", + }); + const projected = projectTelemetryEvent( + emitted?.eventName ?? "api.request.failed", + emitted?.attributes ?? {}, + ); + expect(projected.success).toBe(true); + expect(sinks.diagnostics).toHaveLength(1); + }); + + it("does not emit failure telemetry for caller cancellation or scope fencing", async () => { + const cancelled = recordingSinks(); + const callerController = new AbortController(); + callerController.abort(); + const cancelledExecutor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY" as const, + headers: {}, + credentials: "omit" as const, + }), + fetcher: (async () => + Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch, + observe: cancelled.projector, + }); + const cancelledOutcome = await cancelledExecutor.execute( + TEST_LIST_HTTP_CONTRACT, + { limit: 5 }, + { + routeId: ROUTE_ID, + scope: scopeSnapshot(), + signal: callerController.signal, + }, + ); + expect(cancelledOutcome.kind).toBe("CANCELLED"); + expect(cancelled.diagnostics).toHaveLength(1); + expect(cancelled.telemetry).toHaveLength(0); + + const fenced = recordingSinks(); + const fencedExecutor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY" as const, + headers: {}, + credentials: "omit" as const, + }), + fetcher: (async () => + Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch, + observe: fenced.projector, + }); + const fencedOutcome = await fencedExecutor.execute( + TEST_LIST_HTTP_CONTRACT, + { limit: 5 }, + { routeId: ROUTE_ID, scope: scopeSnapshot(() => false) }, + ); + expect(fencedOutcome.kind).toBe("CONTRACT_VIOLATION"); + expect(fenced.diagnostics).toHaveLength(1); + expect(fenced.telemetry).toHaveLength(0); + }); + + it("preserves the feature route id through the installed operation executor", async () => { + const seen: Array>> = []; + const installed = createReferenceFeatureInstalledInput({ + contractOperations: Object.freeze({ + async execute( + _operationId: string, + _input: unknown, + context?: Readonly>, + ) { + seen.push(Object.freeze({ ...(context ?? {}) })); + return Object.freeze({ + kind: "SUCCESS" as const, + value: [], + metadata: Object.freeze({ status: 200 }), + effect: "NOT_APPLICABLE" as const, + }); + }, + }), + }); + + await installed.input.listResources({ limit: 20 }); + await installed.input.getResource("resource-1"); + + expect(seen.map((context) => context.routeId)).toEqual([ + "REFERENCE_RESOURCE_LIST", + "REFERENCE_RESOURCE_DETAIL", + ]); + }); + + it("cannot change the HTTP result when diagnostics or telemetry throws", async () => { + const projector = createHttpObservationProjector({ + diagnostics: { + record() { + throw new Error("diagnostics sink exploded"); + }, + }, + telemetry: { + emit() { + throw new Error("telemetry sink exploded"); + }, + }, + }); + const observe = vi.fn((observation: HttpExecutionObservation) => { + projector(observation); + }); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY" as const, + headers: {}, + credentials: "omit" as const, + }), + fetcher: (async () => + Response.json({ id: "created", name: "Created" }, { + status: 201, + })) as unknown as typeof fetch, + observe, + }); + + const outcome = await executor.execute( + TEST_CREATE_HTTP_CONTRACT, + { name: "Created" }, + { + routeId: ROUTE_ID, + scope: scopeSnapshot(), + intent: Object.freeze({ + intentId: "intent-1", + operationId: "TEST_CREATE_ENTITY", + canonicalInputIdentity: "opaque-input-identity", + idempotencyKey: "key-1", + createdAtMonotonicMs: 1, + }), + }, + ); + + expect(outcome.kind).toBe("SUCCESS"); + expect(observe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/integration/http-scenario-catalog.test.ts b/tests/integration/http-scenario-catalog.test.ts index 7cc783c..2ed6166 100644 --- a/tests/integration/http-scenario-catalog.test.ts +++ b/tests/integration/http-scenario-catalog.test.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readBoundedBytes } from "../../src/adapters/http/bounded-body-reader.ts"; -import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts"; +import { + createContractHttpExecutor, + type HttpExecutionObservation, +} from "../../src/adapters/http/http-execution-v3.ts"; import { type InstalledHttpContract, } from "../../src/contracts/external-contract-runtime.ts"; @@ -30,6 +33,13 @@ import { type HttpScenarioOperationId, } from "../mocks/scenarios/catalog.ts"; +/** The reference gateway owns these low-cardinality route identities. */ +function routeIdFor(operationId: HttpScenarioOperationId): string { + return operationId === "GET_REFERENCE_RESOURCE" + ? "REFERENCE_RESOURCE_DETAIL" + : "REFERENCE_RESOURCE_LIST"; +} + const RECEIPT_PATH = path.resolve( "artifacts/tests/http-scenario-executions.json", ); @@ -191,11 +201,7 @@ async function executeScenario( ): Promise { const physicalAttempts: AttemptTrace[] = []; const sleeps: RetryReason[] = []; - const observations: Array> = []; + const observations: HttpExecutionObservation[] = []; const caller = new AbortController(); const scopeLifetime = new AbortController(); let scopeCurrent = true; @@ -265,6 +271,7 @@ async function executeScenario( try { const execution = executor.execute(operation, inputFor(entry.operationId), { + routeId: routeIdFor(entry.operationId), scope, signal: caller.signal, ...(entry.operationId === "CREATE_REFERENCE_RESOURCE" @@ -296,7 +303,7 @@ async function executeScenario( const observation = observations[0]!; const observedSignal = scopeLifetime.signal.aborted ? "ABORTED" : "ACTIVE"; const cancellationOwner = - observation.certainty === "TIMEOUT" + observation.terminalReason === "TIMEOUT" ? "DEADLINE" : caller.signal.aborted ? "CALLER" @@ -314,13 +321,13 @@ async function executeScenario( }), effect: Object.freeze({ outcome: String(outcome.effect), - observer: observation.certainty, + observer: observation.terminalReason, }), retry: Object.freeze({ count: sleeps.length, reasons: Object.freeze(sleeps) }), fetch: Object.freeze({ count: physicalAttempts.length, - observerAttempts: observation.attempts, - agrees: physicalAttempts.length === observation.attempts, + observerAttempts: observation.attemptCount, + agrees: physicalAttempts.length === observation.attemptCount, }), media: Object.freeze({ attempts: Object.freeze(physicalAttempts.map((attempt) => attempt.media)), diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts index 555f913..e9b165e 100644 --- a/tests/unit/http-execution-v3.test.ts +++ b/tests/unit/http-execution-v3.test.ts @@ -10,6 +10,8 @@ import { const installed: InstalledHttpContract = TEST_LIST_HTTP_CONTRACT; +const ROUTE_ID = "TEST_ROUTE"; + const scope = Object.freeze({ generation: 1, fingerprint: "scope-1", @@ -82,7 +84,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { }); await expect( - executor.execute(installed, { limit: 20 }, { scope }), + executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope }), ).resolves.toMatchObject({ kind: "RATE_LIMITED", effect: "NOT_APPLICABLE", @@ -119,7 +121,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { executor.execute( createInstalled, { name: "created" }, - { scope, ...(intent === undefined ? {} : { intent }) }, + { routeId: ROUTE_ID, scope, ...(intent === undefined ? {} : { intent }) }, ), ).resolves.toMatchObject({ kind: "CONTRACT_VIOLATION", @@ -154,7 +156,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { }); await expect( - executor.execute(installed, { limit: 20 }, { scope, intent }), + executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, intent }), ).resolves.toMatchObject({ kind: "CONTRACT_VIOLATION", violation: { @@ -173,14 +175,14 @@ describe("descriptor-driven HTTP execution lifetime", () => { label: "query with the canonical reserved header", operation: installed, input: { limit: 20 }, - context: { scope }, + context: { routeId: ROUTE_ID, scope }, headerName: "Idempotency-Key", }, { label: "valid KEYED command with a case-variant reserved header", operation: createInstalled, input: { name: "created" }, - context: { scope, intent: mutationIntent() }, + context: { routeId: ROUTE_ID, scope, intent: mutationIntent() }, headerName: "iDeMpOtEnCy-KeY", }, ])( @@ -252,7 +254,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { executor.execute( retryingCreate, { name: "created" }, - { scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) }, + { routeId: ROUTE_ID, scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) }, ), ).resolves.toMatchObject({ kind: "SUCCESS" }); expect(fetcher).toHaveBeenCalledTimes(2); @@ -284,7 +286,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { executor.execute( installed, { limit: 20 }, - { scope }, + { routeId: ROUTE_ID, scope }, ), ).resolves.toMatchObject({ kind: "SUCCESS" }); expect(fetcher).toHaveBeenCalledOnce(); @@ -317,6 +319,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { createInstalled, { name: "created" }, { + routeId: ROUTE_ID, scope, intent: Object.freeze({ intentId: "private-intent-id", @@ -353,7 +356,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { fetcher, }); - await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({ + await expect(executor.execute(installed, input, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({ kind: "SUCCESS", }); expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain( @@ -389,11 +392,11 @@ describe("descriptor-driven HTTP execution lifetime", () => { }, } as unknown as typeof installed; - await expect(executor.execute(throwing, { limit: 20 }, { scope })).resolves.toMatchObject({ + await expect(executor.execute(throwing, { limit: 20 }, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({ kind: "CONTRACT_VIOLATION", effect: "NOT_APPLICABLE", }); - await expect(executor.execute(malformed, { limit: 20 }, { scope })).resolves.toMatchObject({ + await expect(executor.execute(malformed, { limit: 20 }, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({ kind: "CONTRACT_VIOLATION", effect: "NOT_APPLICABLE", }); @@ -416,6 +419,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { createInstalled, { name: "created" }, { + routeId: ROUTE_ID, scope, intent: mutationIntent(), }, @@ -458,6 +462,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { createInstalled, { name: "created" }, { + routeId: ROUTE_ID, scope: fencedScope, intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }), }, @@ -479,7 +484,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { }); const result = executor - .execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope }) + .execute(operation({ deadlineMs: 5 }), { limit: 20 }, { routeId: ROUTE_ID, scope }) .then((outcome) => { settled = true; return outcome; @@ -532,7 +537,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { const result = executor.execute( operation({ deadlineMs: 5 }), { limit: 20 }, - { scope }, + { routeId: ROUTE_ID, scope }, ); await vi.advanceTimersByTimeAsync(5); await flushMicrotasks(); @@ -548,7 +553,14 @@ describe("descriptor-driven HTTP execution lifetime", () => { expect(observe).toHaveBeenCalledTimes(iterationCount); for (const [observation] of observe.mock.calls) { expect(observation).toEqual( - expect.objectContaining({ attempts: 1, certainty: "TIMEOUT" }), + expect.objectContaining({ + attemptCount: 1, + errorKind: "TIMEOUT", + terminalReason: "TIMEOUT", + cancellationOwner: "DEADLINE", + routeId: ROUTE_ID, + operationId: "TEST_LIST_ENTITIES", + }), ); } } finally { @@ -582,7 +594,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { }); const result = executor - .execute(installed, { limit: 20 }, { scope, signal: caller.signal }) + .execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, signal: caller.signal }) .then((outcome) => { settled = true; return outcome; @@ -617,7 +629,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { executor.execute( operation({ responseBody: "NONE" }), { limit: 20 }, - { scope }, + { routeId: ROUTE_ID, scope }, ), ).resolves.toMatchObject({ kind: "TRANSPORT_FAILURE",