178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
export const DIAGNOSTIC_LEVELS = Object.freeze([
|
|
"debug",
|
|
"info",
|
|
"warn",
|
|
"error",
|
|
] as const);
|
|
export { queueSizeBucket } from "./diagnostic-buckets.ts";
|
|
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";
|
|
}
|