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
@@ -0,0 +1,102 @@
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js";
import {
projectDiagnosticRecord,
safeErrorKind,
type DiagnosticRecord,
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.js";
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
});
export function createDiagnosticsAdapter(
options: Readonly<{
maxEntries?: number;
now?: () => number;
sink?: (record: DiagnosticRecord) => void;
}> = {},
) {
const maxEntries = Math.max(1, options.maxEntries ?? 100);
const entries: DiagnosticRecord[] = [];
const droppedReasons = new Map<string, number>();
function drop(reason: string) {
droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1);
}
function record(input: DiagnosticRecordInput) {
try {
const projected = projectDiagnosticRecord(input, options.now);
if (!projected.success) {
drop(projected.reason);
return;
}
if (entries.length >= maxEntries) {
entries.shift();
drop("queue-full");
}
entries.push(projected.record);
try {
options.sink?.(projected.record);
} catch {
drop("sink-failure");
}
} catch {
drop("serialization-failure");
}
}
return Object.freeze({
record,
entries: () => structuredClone(entries) as readonly DiagnosticRecord[],
dropped: () => Object.freeze(Object.fromEntries(droppedReasons)),
});
}
type BootSafeContext = Readonly<{
kind?: string;
buildId?: string;
configSchemaVersion?: string;
supportReference?: string;
}>;
let lastBootEvidence:
| Readonly<{
diagnostic: DiagnosticRecord | null;
telemetry: Readonly<Record<string, unknown>> | null;
}>
| undefined;
export function recordBootFailure(
error: unknown,
safe: BootSafeContext,
now: () => number = Date.now,
) {
const errorKind =
typeof safe.kind === "string" ? safe.kind : safeErrorKind(error);
const attributes = {
error_kind: errorKind,
build_id: safe.buildId ?? "unknown",
config_schema_version: safe.configSchemaVersion ?? "unknown",
};
const diagnostic = projectDiagnosticRecord(
{
level: "error",
eventId: "app.boot.failed",
context: attributes,
},
now,
);
const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now);
lastBootEvidence = Object.freeze({
diagnostic: diagnostic.success ? diagnostic.record : null,
telemetry: telemetry.success ? telemetry.event : null,
});
return lastBootEvidence;
}
export function getLastBootEvidence() {
return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined;
}
+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 =
@@ -1,6 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.js";
import { safeErrorKind } from "../../contracts/diagnostics.js";
export const QUERY_CACHE_DEFAULTS = Object.freeze({
staleTime: 30_000,
@@ -11,8 +12,32 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({
persistence: false,
});
export function createQueryClient() {
/**
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
*/
export function createQueryClient(dependencies = {}) {
/** @param {string} operation @param {unknown} error */
function report(operation, error) {
try {
dependencies.diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation,
error_kind: safeErrorKind(error),
},
});
} catch {
// Query behavior remains independent from diagnostics.
}
}
return new QueryClient({
queryCache: new QueryCache({
onError: (error) => report("query", error),
}),
mutationCache: new MutationCache({
onError: (error) => report("mutation", error),
}),
defaultOptions: {
queries: {
staleTime: QUERY_CACHE_DEFAULTS.staleTime,
@@ -29,15 +54,16 @@ export function createQueryClient() {
/**
* @param {QueryClient} queryClient
* @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies]
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
*/
export function createQueryCacheAdapter(queryClient) {
export function createQueryCacheAdapter(queryClient, dependencies = {}) {
return Object.freeze({
read(key) {
try {
return { ok: true, value: queryClient.getQueryData(key) };
} catch {
return cacheFailure("read", key);
return cacheFailure("read", key, dependencies.diagnostics);
}
},
write(key, value) {
@@ -45,7 +71,7 @@ export function createQueryCacheAdapter(queryClient) {
queryClient.setQueryData(key, structuredClone(value));
return { ok: true };
} catch {
return cacheFailure("write", key);
return cacheFailure("write", key, dependencies.diagnostics);
}
},
async invalidate(namespace) {
@@ -53,15 +79,31 @@ export function createQueryCacheAdapter(queryClient) {
await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
return { ok: true };
} catch {
return cacheFailure("invalidate", namespace);
return cacheFailure("invalidate", namespace, dependencies.diagnostics);
}
},
});
}
/** @param {string} phase @param {readonly unknown[]} key */
function cacheFailure(phase, key) {
/**
* @param {string} phase
* @param {readonly unknown[]} key
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function cacheFailure(phase, key, diagnostics) {
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
try {
diagnostics?.record({
level: "warn",
eventId: "cache.operation.failed",
context: {
operation: phase,
error_kind: "QUERY_CACHE_FAILURE",
},
});
} catch {
// Cache behavior remains independent from diagnostics.
}
return {
ok: /** @type {false} */ (false),
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
@@ -5,7 +5,8 @@ import { getStorageDefinition } from "../../contracts/storage-keys.js";
* @param {{
* localStorage?: Storage,
* sessionStorage?: Storage,
* now?: () => number
* now?: () => number,
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort
* }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort}
*/
@@ -26,7 +27,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("read", logicalName);
return unavailable("read", logicalName, dependencies.diagnostics);
}
const backend = backendFor(definition.backend);
@@ -50,7 +51,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
}
return { ok: true, value: structuredClone(envelope.value) };
} catch {
return unavailable("read", logicalName);
return unavailable("read", logicalName, dependencies.diagnostics);
}
},
@@ -59,7 +60,7 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("write", logicalName);
return unavailable("write", logicalName, dependencies.diagnostics);
}
const expiresAt =
@@ -82,12 +83,24 @@ export function createBrowserStorageAdapter(dependencies = {}) {
if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value));
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: "memory",
};
}
recordStorageFailure(
dependencies.diagnostics,
"write",
logicalName,
quota,
);
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
@@ -101,14 +114,14 @@ export function createBrowserStorageAdapter(dependencies = {}) {
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("remove", logicalName);
return unavailable("remove", logicalName, dependencies.diagnostics);
}
try {
backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey);
return { ok: true };
} catch {
return unavailable("remove", logicalName);
return unavailable("remove", logicalName, dependencies.diagnostics);
}
},
});
@@ -128,10 +141,38 @@ function storageFailure(quota, phase, logicalName) {
);
}
/** @param {string} phase @param {string} logicalName */
function unavailable(phase, logicalName) {
/**
* @param {string} phase
* @param {string} logicalName
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
*/
function unavailable(phase, logicalName, diagnostics) {
recordStorageFailure(diagnostics, phase, logicalName, false);
return {
ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName),
};
}
/**
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
* @param {string} phase
* @param {string} logicalName
* @param {boolean} quota
*/
function recordStorageFailure(diagnostics, phase, logicalName, quota) {
try {
diagnostics?.record({
level: "warn",
eventId: "storage.operation.failed",
context: {
operation: `${phase}:${logicalName}`,
error_kind: quota
? "STORAGE_QUOTA_EXCEEDED"
: "STORAGE_UNAVAILABLE",
},
});
} catch {
// Storage behavior remains independent from diagnostics.
}
}
+76 -13
View File
@@ -1,7 +1,14 @@
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
import { queueSizeBucket } from "../../contracts/diagnostics.js";
export const noOpTelemetry = Object.freeze({
emit: () => {},
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
dropReasons: () => Object.freeze({}),
deliveryEvidence: () => null,
dispose: () => {},
});
/**
@@ -10,22 +17,20 @@ export const noOpTelemetry = Object.freeze({
* endpoint?: string,
* fetcher?: typeof fetch,
* maxQueue?: number,
* schedule?: (callback: () => void) => void
* schedule?: (callback: () => void) => void,
* now?: () => number,
* onDrop?: (event: Readonly<Record<string, unknown>>) => void,
* lifecycle?: Pick<EventTarget, "addEventListener" | "removeEventListener">
* }} options
*/
export function createTelemetryAdapter(options) {
if (!options.enabled || !options.endpoint) {
return Object.freeze({
...noOpTelemetry,
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
});
return noOpTelemetry;
}
const endpoint = /** @type {string} */ (options.endpoint);
const fetcher = options.fetcher ?? fetch;
const maxQueue = options.maxQueue ?? 100;
const maxQueue = Math.max(1, options.maxQueue ?? 100);
const schedule = options.schedule ?? queueMicrotask;
const queue =
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
@@ -34,18 +39,63 @@ export function createTelemetryAdapter(options) {
let scheduled = false;
let flushing = false;
let dropped = 0;
const dropReasons = new Map();
let lastDeliveryEvidence =
/** @type {Readonly<Record<string, unknown>> | null} */ (null);
const lifecycle =
options.lifecycle ??
(typeof globalThis.addEventListener === "function" &&
typeof globalThis.removeEventListener === "function"
? globalThis
: undefined);
/** @param {string} reason @param {number} count */
function recordDrop(reason, count = 1) {
const safeReason =
{
"queue-full": "queue-full",
"sink-failure": "sink-failure",
"serialization-failure": "serialization-failure",
"unknown-attributes": "invalid-context",
"invalid-attribute-value": "invalid-context",
"missing-required-attributes": "invalid-context",
"unregistered-event": "invalid-event",
}[reason] ?? "invalid-event";
dropped += count;
dropReasons.set(safeReason, (dropReasons.get(safeReason) ?? 0) + count);
const internal = projectTelemetryEvent(
"telemetry.delivery.dropped",
{
reason: safeReason,
queue_size_bucket: queueSizeBucket(queue.length),
},
options.now,
);
if (internal.success) {
lastDeliveryEvidence = internal.event;
try {
options.onDrop?.(internal.event);
} catch {
// Drop observers are deliberately nonrecursive.
}
}
}
/** @param {string} eventName @param {Record<string, unknown>} attributes */
function emit(eventName, attributes) {
const projected = projectTelemetryEvent(eventName, attributes);
const projected = projectTelemetryEvent(
eventName,
attributes,
options.now,
);
if (!projected.success) {
dropped += 1;
recordDrop(projected.reason);
return;
}
if (queue.length >= maxQueue) {
queue.shift();
dropped += 1;
recordDrop("queue-full");
}
queue.push(projected.event);
@@ -69,19 +119,32 @@ export function createTelemetryAdapter(options) {
body: JSON.stringify({ events: batch }),
keepalive: true,
});
if (!response.ok) dropped += batch.length;
if (!response.ok) recordDrop("sink-failure", batch.length);
} catch {
dropped += batch.length;
recordDrop("sink-failure", batch.length);
} finally {
flushing = false;
}
}
const flushBeforePageExit = () => {
void flush();
};
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
function dispose() {
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
}
return Object.freeze({
emit,
flush,
pendingCount: () => queue.length,
droppedCount: () => dropped,
dropReasons: () => Object.freeze(Object.fromEntries(dropReasons)),
deliveryEvidence: () =>
lastDeliveryEvidence ? structuredClone(lastDeliveryEvidence) : null,
dispose,
});
}
+60 -1
View File
@@ -3,6 +3,7 @@ import type {
ApplicationApi,
ColorSchemePreference,
RenderFailureReport,
RouteChangedReport,
} from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js";
@@ -43,7 +44,20 @@ export function createApplication(
const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) {
try {
outputPorts.diagnostics.emit("ui.render.failed", {
outputPorts.diagnostics.record({
level: "error",
eventId: "ui.render.failed",
context: {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
},
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
try {
outputPorts.telemetry.emit("ui.render.failed", {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
@@ -52,6 +66,20 @@ export function createApplication(
// Diagnostics are best-effort and cannot become an application failure.
}
},
reportRouteChanged(report: RouteChangedReport) {
try {
outputPorts.diagnostics.record({
level: "info",
eventId: "route.changed",
context: {
route_id: report.routeId,
build_id: report.buildId,
},
});
} catch {
// Diagnostics are best-effort and cannot become navigation failure.
}
},
});
const runtime = Object.freeze({
@@ -74,6 +102,37 @@ export function createApplication(
try {
const current = await outputPorts.releaseInfo.getCurrent();
const active = await outputPorts.releaseInfo.refresh();
if (
current.buildId !== active.buildId ||
current.releaseId !== active.releaseId
) {
const mismatchKind =
current.buildId !== active.buildId
? "BUILD_MISMATCH"
: "RELEASE_MISMATCH";
try {
outputPorts.diagnostics.record({
level: "warn",
eventId: "release.mismatch.detected",
context: {
build_id: current.buildId,
active_release_id: active.releaseId,
mismatch_kind: mismatchKind,
},
});
} catch {
// Recovery remains independent from diagnostics.
}
try {
outputPorts.telemetry.emit("release.mismatch.detected", {
build_id: current.buildId,
active_release_id: active.releaseId,
mismatch_kind: mismatchKind,
});
} catch {
// Recovery remains independent from telemetry.
}
}
if (!active.routeChunks[input.chunkId]) {
return {
action: "support" as const,
@@ -0,0 +1,5 @@
import type { DiagnosticRecordInput } from "../../contracts/diagnostics.js";
export type DiagnosticsPort = Readonly<{
record(input: DiagnosticRecordInput): void;
}>;
@@ -11,6 +11,11 @@ export type RenderFailureReport = Readonly<{
boundaryName: "route" | "feature";
}>;
export type RouteChangedReport = Readonly<{
routeId: string;
buildId: string;
}>;
export type ReleaseSummary = Readonly<{
buildId: string;
releaseId: string;
@@ -34,6 +39,7 @@ export type ApplicationApi = Readonly<{
}>;
diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void;
reportRouteChanged(report: RouteChangedReport): void;
}>;
runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>;
@@ -2,6 +2,7 @@ import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js";
import type { DiagnosticsPort } from "../diagnostics-port.js";
/**
* Capabilities required by application use cases. Implementations live in
@@ -13,7 +14,8 @@ export type ApplicationOutputPorts = Readonly<{
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>;
preferences: StoragePort;
diagnostics: TelemetryPort;
diagnostics: DiagnosticsPort;
telemetry: TelemetryPort;
releaseInfo: ReleaseInfoPort;
navigation: Readonly<{ reload(): void }>;
}>;
+1
View File
@@ -9,3 +9,4 @@ export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js";
export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js";
export type { DiagnosticsPort } from "../diagnostics-port.js";
-5
View File
@@ -1,5 +0,0 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
+10
View File
@@ -0,0 +1,10 @@
import type { TelemetryEventName } from "../../contracts/telemetry.js";
export type { TelemetryEventName };
export type TelemetryPort = Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
+2
View File
@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client";
import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js";
@@ -27,6 +28,7 @@ async function boot() {
? error.safe
: { supportReference: "boot:unknown" };
recordBootFailure(error, safe);
root.render(<BootErrorShell {...safe} />);
}
}
+27 -6
View File
@@ -4,6 +4,7 @@ import {
createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.js";
import {
createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js";
@@ -53,6 +54,8 @@ function storageOrUndefined(value) {
* fetcher?: typeof fetch,
* clock?: import("../application/ports/clock-port.js").ClockPort,
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
* diagnostics?: Parameters<typeof createHttpClient>[0]["diagnostics"],
* telemetry?: Parameters<typeof createHttpClient>[0]["telemetry"]
* }} context
*/
export function createRuntimeHttpClient(context, contract = {}) {
@@ -64,6 +67,8 @@ export function createRuntimeHttpClient(context, contract = {}) {
fetcher: context.fetcher,
clock: context.clock,
scheduler: context.scheduler,
diagnostics: context.diagnostics,
telemetry: context.telemetry,
...contract,
});
}
@@ -86,15 +91,28 @@ export async function createRuntimeAdapters(context) {
: externalOwner
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const queryClient = createQueryClient();
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const diagnostics = createDiagnosticsAdapter();
const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
onDrop(event) {
const attributes =
event.attributes && typeof event.attributes === "object"
? event.attributes
: {};
diagnostics.record({
level: "warn",
eventId: "telemetry.delivery.dropped",
context: /** @type {Record<string, unknown>} */ (attributes),
});
},
});
const queryClient = createQueryClient({ diagnostics });
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
diagnostics,
});
const releaseInfo = Object.freeze({
async getCurrent() {
@@ -125,6 +143,8 @@ export async function createRuntimeAdapters(context) {
runtime: context.runtime,
authSession,
fetcher: context.fetcher,
diagnostics,
telemetry,
},
contract,
),
@@ -134,7 +154,8 @@ export async function createRuntimeAdapters(context) {
outputPorts: Object.freeze({
session: authSession,
preferences: storage,
diagnostics: telemetry,
diagnostics,
telemetry,
releaseInfo,
navigation,
}),
+183
View File
@@ -0,0 +1,183 @@
export const DIAGNOSTIC_LEVELS = Object.freeze([
"debug",
"info",
"warn",
"error",
] as const);
export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number];
export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({
"app.boot.failed": Object.freeze({ level: "error" }),
"http.request.completed": Object.freeze({ level: "info" }),
"cache.operation.failed": Object.freeze({ level: "warn" }),
"storage.operation.failed": Object.freeze({ level: "warn" }),
"route.changed": Object.freeze({ level: "info" }),
"ui.render.failed": Object.freeze({ level: "error" }),
"release.mismatch.detected": Object.freeze({ level: "warn" }),
"telemetry.delivery.dropped": Object.freeze({ level: "warn" }),
});
export type DiagnosticEventId = keyof typeof DIAGNOSTIC_EVENT_REGISTRY;
export const DIAGNOSTIC_CONTEXT_ALLOWLIST = Object.freeze([
"app_version",
"build_id",
"release_id",
"active_release_id",
"config_schema_version",
"api_contract_version",
"route_id",
"operation_id",
"correlation_id",
"error_kind",
"outcome",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
"component_boundary",
"mismatch_kind",
"operation",
"reason",
"queue_size_bucket",
] as const);
export type DiagnosticContextKey =
(typeof DIAGNOSTIC_CONTEXT_ALLOWLIST)[number];
export type DiagnosticContext = Readonly<
Partial<Record<DiagnosticContextKey, string | number | boolean>>
>;
export type DiagnosticRecordInput = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
context?: Readonly<Record<string, unknown>>;
}>;
export type DiagnosticRecord = Readonly<{
level: DiagnosticLevel;
eventId: DiagnosticEventId;
timestamp: string;
context: DiagnosticContext;
}>;
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
function projectDiagnosticRecordUnsafe(
input: DiagnosticRecordInput,
now: () => number = Date.now,
):
| Readonly<{ success: true; record: DiagnosticRecord }>
| Readonly<{ success: false; reason: string }> {
if (!Object.hasOwn(DIAGNOSTIC_EVENT_REGISTRY, input.eventId)) {
return { success: false, reason: "unregistered-event" };
}
if (!DIAGNOSTIC_LEVELS.includes(input.level)) {
return { success: false, reason: "invalid-level" };
}
const contextEntries = Object.entries(input.context ?? {});
if (contextEntries.length > DIAGNOSTIC_CONTEXT_ALLOWLIST.length) {
return { success: false, reason: "invalid-context" };
}
const projected: Partial<
Record<DiagnosticContextKey, string | number | boolean>
> = {};
for (const [key, value] of contextEntries) {
if (
!DIAGNOSTIC_CONTEXT_ALLOWLIST.includes(key as DiagnosticContextKey)
) {
return { success: false, reason: "unknown-context" };
}
if (typeof value === "string") {
if (!SAFE_VALUE.test(value)) {
return { success: false, reason: "invalid-context" };
}
projected[key as DiagnosticContextKey] = value;
} else if (typeof value === "number" && Number.isFinite(value)) {
projected[key as DiagnosticContextKey] = value;
} else if (typeof value === "boolean") {
projected[key as DiagnosticContextKey] = value;
} else {
return { success: false, reason: "invalid-context" };
}
}
let timestamp: string;
try {
timestamp = new Date(now()).toISOString();
} catch {
timestamp = new Date(0).toISOString();
}
return {
success: true,
record: Object.freeze({
level: input.level,
eventId: input.eventId,
timestamp,
context: Object.freeze(projected),
}),
};
}
export function projectDiagnosticRecord(
input: DiagnosticRecordInput,
now: () => number = Date.now,
): ReturnType<typeof projectDiagnosticRecordUnsafe> {
try {
return projectDiagnosticRecordUnsafe(input, now);
} catch {
return { success: false, reason: "serialization-failure" };
}
}
export function safeErrorKind(error: unknown): string {
try {
if (error && typeof error === "object") {
const record = error as Readonly<Record<string, unknown>>;
if (
typeof record.kind === "string" &&
/^[A-Z][A-Z0-9_]{0,63}$/.test(record.kind)
) {
return record.kind;
}
if (
typeof record.name === "string" &&
/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(record.name)
) {
const normalized = record.name
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toUpperCase();
return /^[A-Z][A-Z0-9_]{0,63}$/.test(normalized)
? normalized
: "UNKNOWN_FAILURE";
}
}
} catch {
return "UNKNOWN_FAILURE";
}
return "UNKNOWN_FAILURE";
}
export function statusGroup(status: number | undefined): string {
return typeof status === "number" && Number.isFinite(status)
? `${Math.max(0, Math.min(9, Math.floor(status / 100)))}xx`
: "none";
}
export function attemptBucket(attemptCount: number): string {
if (attemptCount <= 1) return "1";
if (attemptCount === 2) return "2";
if (attemptCount <= 4) return "3-4";
return "5+";
}
export function durationBucket(durationMs: number): string {
if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown";
if (durationMs < 100) return "lt100ms";
if (durationMs < 500) return "100-499ms";
if (durationMs < 2_000) return "500-1999ms";
return "gte2000ms";
}
export function queueSizeBucket(size: number): string {
if (size <= 0) return "0";
if (size <= 10) return "1-10";
if (size <= 50) return "11-50";
return "51+";
}
+36
View File
@@ -0,0 +1,36 @@
export type TelemetryEventName =
| "app.boot.failed"
| "api.request.failed"
| "ui.render.failed"
| "release.mismatch.detected"
| "telemetry.delivery.dropped";
export type TelemetryDefinition = Readonly<{
eventName: TelemetryEventName;
trigger: string;
requiredAttributes: readonly string[];
optionalAttributes: readonly string[];
forbiddenAttributes: readonly string[];
sampling: string;
delivery: "best-effort";
}>;
export type TelemetryEvent = Readonly<{
eventName: TelemetryEventName;
timestamp: string;
attributes: Readonly<Record<string, unknown>>;
}>;
export const TELEMETRY_ATTRIBUTE_ALLOWLIST: readonly string[];
export const TELEMETRY_FORBIDDEN_ATTRIBUTES: readonly string[];
export const TELEMETRY_REGISTRY: Readonly<
Record<TelemetryEventName, TelemetryDefinition>
>;
export function projectTelemetryEvent(
eventName: string,
attributes: Record<string, unknown>,
now?: () => number,
):
| Readonly<{ success: true; event: TelemetryEvent }>
| Readonly<{ success: false; reason: string }>;
+94 -4
View File
@@ -81,7 +81,7 @@ export const TELEMETRY_REGISTRY = Object.freeze({
"http_status_group",
"attempt_count_bucket",
"route_id",
]),
], ["operation_id", "duration_bucket"]),
"ui.render.failed": event("ui.render.failed", "React boundary catch", [
"route_id",
"build_id",
@@ -101,11 +101,44 @@ export const TELEMETRY_REGISTRY = Object.freeze({
),
});
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/;
const ATTRIBUTE_VALUE_POLICIES =
/** @type {Readonly<Record<string, (value: string) => boolean>>} */ (
Object.freeze({
route_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
operation_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
error_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
http_status_group: (value) => /^(?:[1-5]xx|none)$/.test(value),
attempt_count_bucket: (value) => /^(?:1|2|3|3-4|5\+)$/.test(value),
duration_bucket: (value) =>
/^(?:lt100ms|100-499ms|500-1999ms|gte2000ms|unknown)$/.test(
value,
),
component_boundary: (value) =>
/^(?:route|feature|boot)$/.test(value),
mismatch_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value),
reason: (value) =>
/^(?:queue-full|sink-failure|invalid-event|invalid-context|serialization-failure)$/.test(
value,
),
queue_size_bucket: (value) =>
/^(?:0|1-10|11-50|51\+)$/.test(value),
})
);
/** @param {string} key @param {unknown} value */
function validAttributeValue(key, value) {
if (typeof value !== "string") return false;
const policy = ATTRIBUTE_VALUE_POLICIES[key];
return policy ? policy(value) : SAFE_IDENTIFIER.test(value);
}
/**
* @param {string} eventName
* @param {Record<string, unknown>} attributes
* @param {() => number} [now]
*/
export function projectTelemetryEvent(eventName, attributes) {
function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) {
const registry =
/** @type {Record<string, (typeof TELEMETRY_REGISTRY)[keyof typeof TELEMETRY_REGISTRY]>} */ (
TELEMETRY_REGISTRY
@@ -118,13 +151,47 @@ export function projectTelemetryEvent(eventName, attributes) {
};
}
const attributeKeys = Object.keys(attributes);
if (
attributeKeys.length >
TELEMETRY_ATTRIBUTE_ALLOWLIST.length +
TELEMETRY_FORBIDDEN_ATTRIBUTES.length
) {
return {
success: /** @type {false} */ (false),
reason: "invalid-attribute-value",
};
}
const unknown = attributeKeys.filter(
(key) =>
!TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key),
);
if (unknown.length > 0) {
return {
success: /** @type {false} */ (false),
reason: "unknown-attributes",
};
}
const projected = Object.fromEntries(
Object.entries(attributes).filter(
([key]) =>
([key, value]) =>
TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key),
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key) &&
validAttributeValue(key, value),
),
);
const invalid = Object.entries(attributes).filter(
([key, value]) =>
TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!validAttributeValue(key, value),
);
if (invalid.length > 0) {
return {
success: /** @type {false} */ (false),
reason: "invalid-attribute-value",
};
}
const missing = definition.requiredAttributes.filter(
(key) => projected[key] === undefined,
);
@@ -135,11 +202,34 @@ export function projectTelemetryEvent(eventName, attributes) {
};
}
let timestamp;
try {
timestamp = new Date(now()).toISOString();
} catch {
timestamp = new Date(0).toISOString();
}
return {
success: /** @type {true} */ (true),
event: Object.freeze({
eventName,
timestamp,
attributes: Object.freeze(projected),
}),
};
}
/**
* @param {string} eventName
* @param {Record<string, unknown>} attributes
* @param {() => number} [now]
*/
export function projectTelemetryEvent(eventName, attributes, now = Date.now) {
try {
return projectTelemetryEventUnsafe(eventName, attributes, now);
} catch {
return {
success: /** @type {false} */ (false),
reason: "serialization-failure",
};
}
}
@@ -341,6 +341,7 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
const { message } = useLocale();
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusRestoreGenerationRef = useRef(0);
const titleId = useId();
const descriptionId = useId();
useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
@@ -348,6 +349,8 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const generation = focusRestoreGenerationRef.current + 1;
focusRestoreGenerationRef.current = generation;
if (open) {
previousFocusRef.current =
@@ -371,9 +374,19 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
}
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
queueMicrotask(() => {
if (previousFocus?.isConnected) previousFocus.focus();
});
const restoreFocus = () => {
if (
focusRestoreGenerationRef.current === generation &&
previousFocus?.isConnected
) {
previousFocus.focus();
}
};
if (typeof globalThis.requestAnimationFrame === "function") {
const frame = globalThis.requestAnimationFrame(restoreFocus);
return () => globalThis.cancelAnimationFrame(frame);
}
queueMicrotask(restoreFocus);
}, [open]);
return (
+22 -3
View File
@@ -105,9 +105,16 @@ function InvalidRouteSurface({ code }: { code: string }) {
);
}
function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
function RouteLifecycle({
definition,
buildId,
}: {
definition: RouteDefinition;
buildId: string;
}) {
const location = useLocation();
const { message, resolve } = useLocale();
const { diagnostics } = useApplication();
useEffect(() => {
document.title = message("route.documentTitle", {
title: resolve(`route.${definition.routeId}.title`),
@@ -122,7 +129,19 @@ function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
} catch {
// Non-browser test hosts may not implement scrolling.
}
}, [definition, location.key, location.pathname, message, resolve]);
diagnostics.reportRouteChanged({
routeId: definition.routeId,
buildId,
});
}, [
buildId,
definition,
diagnostics,
location.key,
location.pathname,
message,
resolve,
]);
return null;
}
@@ -244,7 +263,7 @@ function RegisteredRoute({
const content = (
<RouteInputContext.Provider value={parsed.data}>
<CanonicalRouteRedirect input={parsed.data} />
<RouteLifecycle definition={definition} />
<RouteLifecycle definition={definition} buildId={buildId} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
<ChunkRecoveryBoundary
chunkId={definition.chunkId}