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
+87 -11
View File
@@ -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.
}