feat: add diagnostics and telemetry runtime

This commit is contained in:
donghyeon-ka
2026-07-26 16:42:27 +09:00
parent 2fa0baa577
commit 5173b6c8d6
43 changed files with 1760 additions and 116 deletions
+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 =