244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
import type { MetricPoint } from "./metrics.ts";
|
|
import type { LogRecord } from "./logging.ts";
|
|
import type { MetricExporter } from "./server.ts";
|
|
import type { SpanExporter, SpanRecord, TraceContext } from "./trace.ts";
|
|
import { reportObservabilityFailure, type ObservabilityDiagnostic } from "./diagnostics.ts";
|
|
|
|
export type FrameworkSpanKind =
|
|
"database" | "cache" | "queue" | "realtime" | "server-action" | "application";
|
|
|
|
export interface OperationTracer {
|
|
span<T>(
|
|
kind: FrameworkSpanKind,
|
|
name: string,
|
|
operation: () => T | Promise<T>,
|
|
attributes?: Record<string, string | number | boolean>,
|
|
): Promise<T>;
|
|
}
|
|
|
|
const randomHex = (bytes: number) =>
|
|
Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (byte) =>
|
|
byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
|
|
export function createOperationTracer(
|
|
options: {
|
|
exporter?: SpanExporter;
|
|
context?: () => Partial<TraceContext>;
|
|
now?: () => number;
|
|
onExportError?: (error: unknown) => void;
|
|
diagnostic?: ObservabilityDiagnostic;
|
|
} = {},
|
|
): OperationTracer {
|
|
const now = options.now ?? Date.now;
|
|
return {
|
|
async span(kind, name, operation, attributes = {}) {
|
|
const context = options.context?.() ?? {};
|
|
const started = now();
|
|
let status: SpanRecord["status"] = "ok";
|
|
let failure: unknown;
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
status = "error";
|
|
failure = error;
|
|
throw error;
|
|
} finally {
|
|
const ended = now();
|
|
const record: SpanRecord = {
|
|
name,
|
|
traceId: context.traceId ?? randomHex(16),
|
|
spanId: randomHex(8),
|
|
...(context.spanId ? { parentSpanId: context.spanId } : {}),
|
|
sampled: context.sampled ?? true,
|
|
startTime: started,
|
|
endTime: ended,
|
|
durationMs: ended - started,
|
|
status,
|
|
attributes: { "wrnexus.span.kind": kind, ...attributes },
|
|
...(failure
|
|
? {
|
|
error: {
|
|
name: failure instanceof Error ? failure.name : "Error",
|
|
message: failure instanceof Error ? failure.message : String(failure),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
try {
|
|
await options.exporter?.export([record]);
|
|
} catch (error) {
|
|
if (options.onExportError) options.onExportError(error);
|
|
else
|
|
reportObservabilityFailure("operation span export failed", error, options.diagnostic);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
const safeMetricName = (name: string) => name.replace(/[^a-zA-Z0-9_:]/g, "_");
|
|
const labels = (point: MetricPoint) => {
|
|
const entries = Object.entries(point.labels);
|
|
return entries.length
|
|
? `{${entries.map(([key, value]) => `${safeMetricName(key)}=${JSON.stringify(String(value))}`).join(",")}}`
|
|
: "";
|
|
};
|
|
|
|
export function renderPrometheus(points: readonly MetricPoint[]): string {
|
|
return `${points.map((point) => `${safeMetricName(point.name)}${labels(point)} ${point.type === "histogram" ? (point.sum ?? 0) : point.value} ${point.timestamp}`).join("\n")}\n`;
|
|
}
|
|
|
|
export function createPrometheusPushExporter(
|
|
endpoint: string,
|
|
options: { fetch?: typeof fetch; headers?: HeadersInit } = {},
|
|
): MetricExporter {
|
|
return {
|
|
async export(points) {
|
|
const response = await (options.fetch ?? fetch)(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain; version=0.0.4",
|
|
...Object.fromEntries(new Headers(options.headers)),
|
|
},
|
|
body: renderPrometheus(points),
|
|
});
|
|
if (!response.ok) throw new Error(`Prometheus export failed with ${response.status}.`);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createZipkinExporter(
|
|
endpoint: string,
|
|
options: { fetch?: typeof fetch; serviceName?: string } = {},
|
|
): SpanExporter {
|
|
return {
|
|
async export(spans) {
|
|
const body = spans.map((span) => ({
|
|
traceId: span.traceId,
|
|
id: span.spanId,
|
|
parentId: span.parentSpanId,
|
|
name: span.name,
|
|
timestamp: Math.trunc(span.startTime * 1000),
|
|
duration: Math.trunc(span.durationMs * 1000),
|
|
localEndpoint: { serviceName: options.serviceName ?? "wrnexus" },
|
|
tags: Object.fromEntries(
|
|
Object.entries(span.attributes).map(([key, value]) => [key, String(value)]),
|
|
),
|
|
...(span.error ? { error: span.error.message } : {}),
|
|
}));
|
|
const response = await (options.fetch ?? fetch)(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) throw new Error(`Zipkin export failed with ${response.status}.`);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Jaeger accepts Zipkin v2 JSON at its compatibility endpoint. */
|
|
export const createJaegerExporter = createZipkinExporter;
|
|
|
|
export interface LogExporter {
|
|
export(records: readonly LogRecord[]): void | Promise<void>;
|
|
}
|
|
export function createOtlpLogExporter(
|
|
endpoint: string,
|
|
options: { fetch?: typeof fetch; headers?: HeadersInit } = {},
|
|
): LogExporter {
|
|
return {
|
|
async export(records) {
|
|
const response = await (options.fetch ?? fetch)(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...Object.fromEntries(new Headers(options.headers)),
|
|
},
|
|
body: JSON.stringify({
|
|
resourceLogs: [
|
|
{
|
|
scopeLogs: [
|
|
{
|
|
scope: { name: "@wrnexus/observability" },
|
|
logRecords: records.map((record) => ({
|
|
timeUnixNano: String(BigInt(new Date(record.timestamp).getTime()) * 1_000_000n),
|
|
severityText: record.level.toUpperCase(),
|
|
body: { stringValue: record.message },
|
|
attributes: Object.entries(record.attributes).map(([key, value]) => ({
|
|
key,
|
|
value: { stringValue: String(value) },
|
|
})),
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
if (!response.ok) throw new Error(`OTLP log export failed with ${response.status}.`);
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface ErrorReporter {
|
|
capture(error: unknown, context?: Record<string, unknown>): Promise<string>;
|
|
}
|
|
export function createSentryCompatibleReporter(
|
|
endpoint: string,
|
|
options: { fetch?: typeof fetch; publicKey?: string } = {},
|
|
): ErrorReporter {
|
|
return {
|
|
async capture(error, context = {}) {
|
|
const eventId = randomHex(16);
|
|
const failure = error instanceof Error ? error : new Error(String(error));
|
|
const response = await (options.fetch ?? fetch)(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(options.publicKey
|
|
? { "x-sentry-auth": `Sentry sentry_key=${options.publicKey}, sentry_version=7` }
|
|
: {}),
|
|
},
|
|
body: JSON.stringify({
|
|
event_id: eventId,
|
|
timestamp: new Date().toISOString(),
|
|
platform: "javascript",
|
|
level: "error",
|
|
exception: {
|
|
values: [{ type: failure.name, value: failure.message, stacktrace: failure.stack }],
|
|
},
|
|
contexts: context,
|
|
}),
|
|
});
|
|
if (!response.ok) throw new Error(`Error report failed with ${response.status}.`);
|
|
return eventId;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createPerformanceProfiler(
|
|
options: {
|
|
now?: () => number;
|
|
onProfile?: (profile: {
|
|
name: string;
|
|
durationMs: number;
|
|
attributes: Record<string, unknown>;
|
|
}) => void;
|
|
} = {},
|
|
) {
|
|
const now = options.now ?? performance.now.bind(performance);
|
|
return async <T>(
|
|
name: string,
|
|
operation: () => T | Promise<T>,
|
|
attributes: Record<string, unknown> = {},
|
|
): Promise<T> => {
|
|
const started = now();
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
options.onProfile?.({ name, durationMs: now() - started, attributes });
|
|
}
|
|
};
|
|
}
|