fix: restore V3 HTTP observability

Project one typed HttpExecutionObservation per logical V3 execution through a
closed composition-root projector: only registered diagnostic context keys and
bucketed values reach the sinks, and terminal non-abort failures now emit
exactly one api.request.failed telemetry event. Caller cancellation and scope
fencing record a diagnostic but never a failure event.

routeId becomes a required input at the installed operation-executor boundary
so the feature gateway's low-cardinality route identity survives to the sink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 22:42:51 +09:00
co-authored by Claude Opus 5
parent f7bec8274b
commit 67cc5b6d2c
9 changed files with 559 additions and 59 deletions
+81 -19
View File
@@ -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<string, unknown>,
): 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
? {}