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:
co-authored by
Claude Opus 5
parent
f7bec8274b
commit
67cc5b6d2c
@@ -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<HttpExecutionOutcome<WireOutput, Problem>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* §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<unknown, unknown>["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<unknown, unknown>,
|
||||
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<unknown, unknown>,
|
||||
): 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<WireOutput, Problem>,
|
||||
certainty: string,
|
||||
terminalReason: string,
|
||||
): HttpExecutionOutcome<WireOutput, Problem> => {
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user