release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# @wrnexus/observability
|
||||
|
||||
Open-standard traces, metrics, logs, health checks, Web Vitals, error reporting and profiling.
|
||||
|
||||
Use `createOperationTracer()` for `database`, `cache`, `queue`, `realtime`, `server-action` or custom `application` spans. Export through OTLP, Prometheus, Zipkin/Jaeger, or the Sentry-compatible error reporter; Grafana can consume the Prometheus or OTLP signals.
|
||||
|
||||
Privacy-conscious counters, gauges, histograms, HTTP middleware, Web Vitals ingestion, browser collection, and exporter adapters. Request bodies and user identifiers are not collected by default.
|
||||
|
||||
```ts
|
||||
@@ -7,3 +11,68 @@ export default {
|
||||
observability: { enabled: true, serverTiming: true, sampleRate: 0.1, webVitals: true },
|
||||
};
|
||||
```
|
||||
|
||||
## Traces, correlated logs, and OTLP
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOtlpMetricExporter,
|
||||
createOtlpTraceExporter,
|
||||
createStructuredLogger,
|
||||
metricsMiddleware,
|
||||
traceMiddleware,
|
||||
} from "@wrnexus/observability";
|
||||
|
||||
const traces = createOtlpTraceExporter("https://collector.example/v1/traces", {
|
||||
serviceName: "checkout",
|
||||
headers: { authorization: `Bearer ${process.env.OTLP_TOKEN}` },
|
||||
});
|
||||
|
||||
export const tracing = traceMiddleware({
|
||||
serviceName: "checkout",
|
||||
sampleRate: 0.1,
|
||||
exporter: traces,
|
||||
onExportError(error) {
|
||||
console.error("trace export failed", error);
|
||||
},
|
||||
});
|
||||
|
||||
export const metrics = metricsMiddleware();
|
||||
export const metricExporter = createOtlpMetricExporter("https://collector.example/v1/metrics", {
|
||||
serviceName: "checkout",
|
||||
});
|
||||
|
||||
export const logger = createStructuredLogger({ service: "checkout" });
|
||||
// Request middleware can create a correlated child from ctx.locals.
|
||||
logger
|
||||
.child({
|
||||
traceId: ctx.locals.traceId,
|
||||
spanId: ctx.locals.spanId,
|
||||
requestId: ctx.locals.requestId,
|
||||
})
|
||||
.info("order accepted", { orderId });
|
||||
```
|
||||
|
||||
The tracing middleware accepts and validates W3C `traceparent`, creates a child server span,
|
||||
stores correlation identifiers in `ctx.locals`, installs the framework tracer on `ctx.tracer`,
|
||||
and returns `traceparent` plus `x-request-id`. Export failures are isolated from application
|
||||
responses when `onExportError` is configured.
|
||||
|
||||
## Liveness and readiness
|
||||
|
||||
```ts
|
||||
import { HealthRegistry } from "@wrnexus/core";
|
||||
import { createLivenessHandler, createReadinessHandler } from "@wrnexus/observability";
|
||||
|
||||
const health = new HealthRegistry();
|
||||
health.register("database", async () =>
|
||||
(await db.ping()) ? { status: "up" } : { status: "down" },
|
||||
);
|
||||
|
||||
export const live = createLivenessHandler();
|
||||
export const ready = createReadinessHandler(health);
|
||||
```
|
||||
|
||||
Liveness reports whether the process can answer requests. Readiness returns HTTP 503 when a
|
||||
registered dependency is down. Dependency messages and details are hidden unless
|
||||
`exposeDetails: true` is explicitly selected for a trusted endpoint.
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
{
|
||||
"name": "@wrnexus/observability",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Metrics, Web Vitals collection, request instrumentation, and exporter adapters for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts"
|
||||
"./client": "./src/client.ts",
|
||||
"./health": "./src/health.ts",
|
||||
"./integrations": "./src/integrations.ts",
|
||||
"./logging": "./src/logging.ts",
|
||||
"./metrics": "./src/metrics.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./trace": "./src/trace.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HealthRegistry } from "@wrnexus/core";
|
||||
|
||||
export interface HealthHandlerOptions {
|
||||
exposeDetails?: boolean;
|
||||
cacheControl?: string;
|
||||
}
|
||||
|
||||
export function createLivenessHandler(options: HealthHandlerOptions = {}) {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
return new Response("Method Not Allowed", { status: 405, headers: { allow: "GET, HEAD" } });
|
||||
}
|
||||
return Response.json(
|
||||
{ status: "up" },
|
||||
{ status: 200, headers: { "cache-control": options.cacheControl ?? "no-store" } },
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function createReadinessHandler(
|
||||
registry: HealthRegistry,
|
||||
options: HealthHandlerOptions = {},
|
||||
) {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
return new Response("Method Not Allowed", { status: 405, headers: { allow: "GET, HEAD" } });
|
||||
}
|
||||
const result = await registry.check();
|
||||
const body = options.exposeDetails ? result : { status: result.status };
|
||||
return Response.json(body, {
|
||||
status: result.status === "down" ? 503 : 200,
|
||||
headers: { "cache-control": options.cacheControl ?? "no-store" },
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export { MetricsRegistry } from "./metrics.ts";
|
||||
export type { MetricLabels, MetricPoint } from "./metrics.ts";
|
||||
export {
|
||||
createHttpMetricExporter,
|
||||
createOtlpMetricExporter,
|
||||
createWebVitalsHandler,
|
||||
defaultMetrics,
|
||||
metricsMiddleware,
|
||||
@@ -14,3 +15,30 @@ export type {
|
||||
} from "./server.ts";
|
||||
export { webVitalsClient } from "./client.ts";
|
||||
export type { WebVitalsClientOptions } from "./client.ts";
|
||||
export {
|
||||
createOtlpTraceExporter,
|
||||
formatTraceparent,
|
||||
parseTraceparent,
|
||||
traceMiddleware,
|
||||
} from "./trace.ts";
|
||||
export type { SpanExporter, SpanRecord, TraceContext, TraceMiddlewareOptions } from "./trace.ts";
|
||||
export { createLivenessHandler, createReadinessHandler } from "./health.ts";
|
||||
export type { HealthHandlerOptions } from "./health.ts";
|
||||
export { createStructuredLogger } from "./logging.ts";
|
||||
export type { LogLevel, LogRecord, StructuredLogger, StructuredLoggerOptions } from "./logging.ts";
|
||||
export {
|
||||
createJaegerExporter,
|
||||
createOperationTracer,
|
||||
createOtlpLogExporter,
|
||||
createPerformanceProfiler,
|
||||
createPrometheusPushExporter,
|
||||
createSentryCompatibleReporter,
|
||||
createZipkinExporter,
|
||||
renderPrometheus,
|
||||
} from "./integrations.ts";
|
||||
export type {
|
||||
ErrorReporter,
|
||||
FrameworkSpanKind,
|
||||
LogExporter,
|
||||
OperationTracer,
|
||||
} from "./integrations.ts";
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
} = {},
|
||||
): 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) {
|
||||
options.onExportError?.(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogRecord {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
service: string;
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
requestId?: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StructuredLogger {
|
||||
log(level: LogLevel, message: string, attributes?: Record<string, unknown>): void;
|
||||
debug(message: string, attributes?: Record<string, unknown>): void;
|
||||
info(message: string, attributes?: Record<string, unknown>): void;
|
||||
warn(message: string, attributes?: Record<string, unknown>): void;
|
||||
error(message: string, attributes?: Record<string, unknown>): void;
|
||||
child(attributes: Record<string, unknown>): StructuredLogger;
|
||||
}
|
||||
|
||||
export interface StructuredLoggerOptions {
|
||||
service?: string;
|
||||
level?: LogLevel;
|
||||
now?: () => Date;
|
||||
sink?: (record: LogRecord, line: string) => void;
|
||||
redact?: readonly string[];
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
|
||||
function scrub(value: Record<string, unknown>, keys: Set<string>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
keys.has(key.toLowerCase()) ? "[REDACTED]" : item,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createStructuredLogger(options: StructuredLoggerOptions = {}): StructuredLogger {
|
||||
const threshold = LEVELS[options.level ?? "info"];
|
||||
const now = options.now ?? (() => new Date());
|
||||
const sink = options.sink ?? ((_record, line) => console.log(line));
|
||||
const redacted = new Set(
|
||||
(options.redact ?? ["authorization", "cookie", "password", "secret", "token"]).map((key) =>
|
||||
key.toLowerCase(),
|
||||
),
|
||||
);
|
||||
const base = { ...(options.attributes ?? {}) };
|
||||
const logger = (attributes: Record<string, unknown>): StructuredLogger => {
|
||||
const log = (level: LogLevel, message: string, values: Record<string, unknown> = {}) => {
|
||||
if (LEVELS[level] < threshold) return;
|
||||
const combined = scrub({ ...attributes, ...values }, redacted);
|
||||
const record: LogRecord = {
|
||||
timestamp: now().toISOString(),
|
||||
level,
|
||||
message,
|
||||
service: options.service ?? "wrnexus",
|
||||
...(typeof combined.traceId === "string" ? { traceId: combined.traceId } : {}),
|
||||
...(typeof combined.spanId === "string" ? { spanId: combined.spanId } : {}),
|
||||
...(typeof combined.requestId === "string" ? { requestId: combined.requestId } : {}),
|
||||
attributes: combined,
|
||||
};
|
||||
sink(record, JSON.stringify(record));
|
||||
};
|
||||
return {
|
||||
log,
|
||||
debug: (message, values) => log("debug", message, values),
|
||||
info: (message, values) => log("info", message, values),
|
||||
warn: (message, values) => log("warn", message, values),
|
||||
error: (message, values) => log("error", message, values),
|
||||
child: (values) => logger({ ...attributes, ...values }),
|
||||
};
|
||||
};
|
||||
return logger(base);
|
||||
}
|
||||
@@ -129,4 +129,81 @@ export function createHttpMetricExporter(
|
||||
};
|
||||
}
|
||||
|
||||
export function createOtlpMetricExporter(
|
||||
endpoint: string,
|
||||
options: { headers?: HeadersInit; fetch?: typeof fetch; serviceName?: string } = {},
|
||||
): MetricExporter {
|
||||
const send = options.fetch ?? fetch;
|
||||
return {
|
||||
async export(points) {
|
||||
if (!points.length) return;
|
||||
const metrics = points.map((point) => {
|
||||
const attributes = Object.entries(point.labels).map(([key, value]) => ({
|
||||
key,
|
||||
value:
|
||||
typeof value === "boolean"
|
||||
? { boolValue: value }
|
||||
: typeof value === "number"
|
||||
? { doubleValue: value }
|
||||
: { stringValue: value },
|
||||
}));
|
||||
const timeUnixNano = String(BigInt(Math.trunc(point.timestamp)) * 1_000_000n);
|
||||
if (point.type === "histogram") {
|
||||
return {
|
||||
name: point.name,
|
||||
histogram: {
|
||||
aggregationTemporality: 2,
|
||||
dataPoints: [
|
||||
{
|
||||
attributes,
|
||||
timeUnixNano,
|
||||
count: String(point.count ?? 0),
|
||||
sum: point.sum ?? 0,
|
||||
min: point.min,
|
||||
max: point.max,
|
||||
bucketCounts: [String(point.count ?? 0)],
|
||||
explicitBounds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
const kind = point.type === "counter" ? "sum" : "gauge";
|
||||
return {
|
||||
name: point.name,
|
||||
[kind]: {
|
||||
...(kind === "sum" ? { aggregationTemporality: 2, isMonotonic: true } : {}),
|
||||
dataPoints: [{ attributes, timeUnixNano, asDouble: point.value }],
|
||||
},
|
||||
};
|
||||
});
|
||||
const response = await send(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resourceMetrics: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{ key: "service.name", value: { stringValue: options.serviceName ?? "wrnexus" } },
|
||||
],
|
||||
},
|
||||
scopeMetrics: [
|
||||
{
|
||||
scope: { name: "@wrnexus/observability", version: "0.8.0" },
|
||||
metrics,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`OTLP metric export failed with ${response.status}.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultMetrics = new MetricsRegistry();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createTracer, type Context, type Middleware } from "@wrnexus/core";
|
||||
|
||||
export interface TraceContext {
|
||||
version: "00";
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
sampled: boolean;
|
||||
}
|
||||
|
||||
export interface SpanRecord {
|
||||
name: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
sampled: boolean;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
status: "ok" | "error";
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
error?: { name: string; message: string };
|
||||
}
|
||||
|
||||
export interface SpanExporter {
|
||||
export(spans: readonly SpanRecord[]): Promise<void> | void;
|
||||
}
|
||||
|
||||
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
||||
|
||||
export function parseTraceparent(value: string | null | undefined): TraceContext | null {
|
||||
if (!value) return null;
|
||||
const match = TRACEPARENT.exec(value.trim().toLowerCase());
|
||||
if (!match || /^0+$/.test(match[1]!) || /^0+$/.test(match[2]!)) return null;
|
||||
return {
|
||||
version: "00",
|
||||
traceId: match[1]!,
|
||||
spanId: match[2]!,
|
||||
sampled: (Number.parseInt(match[3]!, 16) & 1) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTraceparent(context: TraceContext): string {
|
||||
return `00-${context.traceId}-${context.spanId}-${context.sampled ? "01" : "00"}`;
|
||||
}
|
||||
|
||||
function randomHex(bytes: number, random: (target: Uint8Array) => Uint8Array): string {
|
||||
let value = "";
|
||||
while (!value || /^0+$/.test(value)) {
|
||||
value = Array.from(random(new Uint8Array(bytes)), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface TraceMiddlewareOptions {
|
||||
serviceName?: string;
|
||||
sampleRate?: number;
|
||||
exporter?: SpanExporter;
|
||||
onSpan?: (span: SpanRecord) => void | Promise<void>;
|
||||
now?: () => number;
|
||||
random?: (target: Uint8Array) => Uint8Array;
|
||||
routeName?: (ctx: Context) => string;
|
||||
serverTiming?: boolean;
|
||||
onExportError?: (error: unknown, span: SpanRecord) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function traceMiddleware(options: TraceMiddlewareOptions = {}): Middleware {
|
||||
const sampleRate = options.sampleRate ?? 1;
|
||||
if (!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) {
|
||||
throw new Error("Observability trace sampleRate must be between 0 and 1.");
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const random = options.random ?? ((target: Uint8Array) => crypto.getRandomValues(target));
|
||||
return async (ctx, next) => {
|
||||
const parent = parseTraceparent(ctx.req.headers.get("traceparent"));
|
||||
const sampled = parent?.sampled ?? Math.random() < sampleRate;
|
||||
const traceId = parent?.traceId ?? randomHex(16, random);
|
||||
const spanId = randomHex(8, random);
|
||||
const trace: TraceContext = { version: "00", traceId, spanId, sampled };
|
||||
const started = now();
|
||||
ctx.locals.traceId = traceId;
|
||||
ctx.locals.spanId = spanId;
|
||||
ctx.locals.traceparent = formatTraceparent(trace);
|
||||
ctx.locals.requestId ??= traceId;
|
||||
ctx.tracer ??= createTracer(now);
|
||||
const frameworkSpan = ctx.tracer.startSpan("http.request", {
|
||||
traceId,
|
||||
spanId,
|
||||
method: ctx.req.method,
|
||||
path: ctx.url.pathname,
|
||||
});
|
||||
let response: Response | undefined;
|
||||
let failure: unknown;
|
||||
let failed = false;
|
||||
try {
|
||||
response = await next();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
failed = true;
|
||||
} finally {
|
||||
const ended = now();
|
||||
const span: SpanRecord = {
|
||||
name: options.routeName?.(ctx) ?? `${ctx.req.method} ${ctx.url.pathname}`,
|
||||
traceId,
|
||||
spanId,
|
||||
...(parent ? { parentSpanId: parent.spanId } : {}),
|
||||
sampled,
|
||||
startTime: started,
|
||||
endTime: ended,
|
||||
durationMs: ended - started,
|
||||
status: failed || (response?.status ?? 500) >= 500 ? "error" : "ok",
|
||||
attributes: {
|
||||
"service.name": options.serviceName ?? "wrnexus",
|
||||
"http.request.method": ctx.req.method,
|
||||
"url.path": ctx.url.pathname,
|
||||
"http.response.status_code": response?.status ?? 500,
|
||||
},
|
||||
...(failed
|
||||
? {
|
||||
error: {
|
||||
name: failure instanceof Error ? failure.name : "Error",
|
||||
message: failure instanceof Error ? failure.message : String(failure),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
frameworkSpan.end(span.status, failure);
|
||||
if (sampled) {
|
||||
try {
|
||||
await options.onSpan?.(span);
|
||||
await options.exporter?.export([span]);
|
||||
} catch (error) {
|
||||
await options.onExportError?.(error, span);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failed) throw failure;
|
||||
const headers = new Headers(response!.headers);
|
||||
headers.set("traceparent", formatTraceparent(trace));
|
||||
headers.set("x-request-id", String(ctx.locals.requestId));
|
||||
if (options.serverTiming !== false) {
|
||||
headers.append("server-timing", `trace;dur=${Math.max(0, now() - started).toFixed(2)}`);
|
||||
}
|
||||
return new Response(response!.body, {
|
||||
status: response!.status,
|
||||
statusText: response!.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function otlpValue(value: string | number | boolean) {
|
||||
if (typeof value === "boolean") return { boolValue: value };
|
||||
if (typeof value === "number")
|
||||
return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
||||
return { stringValue: value };
|
||||
}
|
||||
|
||||
export function createOtlpTraceExporter(
|
||||
endpoint: string,
|
||||
options: { headers?: HeadersInit; fetch?: typeof fetch; serviceName?: string } = {},
|
||||
): SpanExporter {
|
||||
const send = options.fetch ?? fetch;
|
||||
return {
|
||||
async export(spans) {
|
||||
if (!spans.length) return;
|
||||
const body = {
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{ key: "service.name", value: { stringValue: options.serviceName ?? "wrnexus" } },
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: { name: "@wrnexus/observability", version: "0.8.0" },
|
||||
spans: spans.map((span) => ({
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
|
||||
name: span.name,
|
||||
kind: 2,
|
||||
startTimeUnixNano: String(BigInt(Math.trunc(span.startTime)) * 1_000_000n),
|
||||
endTimeUnixNano: String(BigInt(Math.trunc(span.endTime)) * 1_000_000n),
|
||||
attributes: Object.entries(span.attributes).map(([key, value]) => ({
|
||||
key,
|
||||
value: otlpValue(value),
|
||||
})),
|
||||
status: { code: span.status === "error" ? 2 : 1 },
|
||||
...(span.error
|
||||
? {
|
||||
events: [
|
||||
{
|
||||
timeUnixNano: String(BigInt(Math.trunc(span.endTime)) * 1_000_000n),
|
||||
name: "exception",
|
||||
attributes: [
|
||||
{ key: "exception.type", value: { stringValue: span.error.name } },
|
||||
{
|
||||
key: "exception.message",
|
||||
value: { stringValue: span.error.message },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const response = await send(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`OTLP trace export failed with ${response.status}.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { MetricsRegistry } from "../src/metrics.ts";
|
||||
import {
|
||||
createOperationTracer,
|
||||
createOtlpLogExporter,
|
||||
createPerformanceProfiler,
|
||||
createPrometheusPushExporter,
|
||||
createSentryCompatibleReporter,
|
||||
createZipkinExporter,
|
||||
renderPrometheus,
|
||||
} from "../src/integrations.ts";
|
||||
|
||||
describe("observability integrations", () => {
|
||||
test("records every framework operation kind and failures", async () => {
|
||||
const spans: any[] = [];
|
||||
const tracer = createOperationTracer({
|
||||
exporter: {
|
||||
export: (records) => {
|
||||
spans.push(...records);
|
||||
},
|
||||
},
|
||||
now: (() => {
|
||||
let time = 0;
|
||||
return () => (time += 5);
|
||||
})(),
|
||||
});
|
||||
for (const kind of [
|
||||
"database",
|
||||
"cache",
|
||||
"queue",
|
||||
"realtime",
|
||||
"server-action",
|
||||
"application",
|
||||
] as const)
|
||||
expect(await tracer.span(kind, `${kind}.work`, async () => kind)).toBe(kind);
|
||||
await expect(
|
||||
tracer.span("application", "failure", async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
expect(spans.map((span) => span.attributes["wrnexus.span.kind"])).toContain("database");
|
||||
expect(spans.at(-1).status).toBe("error");
|
||||
});
|
||||
|
||||
test("exports Prometheus, Zipkin/Jaeger, OTLP logs and Sentry-compatible errors", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const send = (async (url: URL | RequestInfo, init?: RequestInit) => {
|
||||
requests.push({ url: String(url), init });
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const registry = new MetricsRegistry(() => 10);
|
||||
registry.increment("http.requests", 1, { route: "/" });
|
||||
expect(renderPrometheus(registry.snapshot())).toContain('http_requests{route="/"} 1');
|
||||
await createPrometheusPushExporter("https://prom.test", { fetch: send }).export(
|
||||
registry.snapshot(),
|
||||
);
|
||||
const span = {
|
||||
name: "db",
|
||||
traceId: "1".repeat(32),
|
||||
spanId: "2".repeat(16),
|
||||
sampled: true,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
durationMs: 1,
|
||||
status: "ok" as const,
|
||||
attributes: {},
|
||||
};
|
||||
await createZipkinExporter("https://zipkin.test", { fetch: send }).export([span]);
|
||||
await createOtlpLogExporter("https://otlp.test", { fetch: send }).export([
|
||||
{
|
||||
timestamp: new Date(0).toISOString(),
|
||||
level: "info",
|
||||
message: "ready",
|
||||
service: "app",
|
||||
attributes: {},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
await createSentryCompatibleReporter("https://sentry.test", { fetch: send }).capture(
|
||||
new Error("bad"),
|
||||
),
|
||||
).toHaveLength(32);
|
||||
expect(requests).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("profiles application operations", async () => {
|
||||
const profiles: any[] = [];
|
||||
let time = 0;
|
||||
const profile = createPerformanceProfiler({
|
||||
now: () => (time += 4),
|
||||
onProfile: (value) => profiles.push(value),
|
||||
});
|
||||
expect(await profile("render", () => "ok")).toBe("ok");
|
||||
expect(profiles[0]).toMatchObject({ name: "render", durationMs: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createContext, HealthRegistry } from "@wrnexus/core";
|
||||
import {
|
||||
createLivenessHandler,
|
||||
createOtlpMetricExporter,
|
||||
createOtlpTraceExporter,
|
||||
createReadinessHandler,
|
||||
createStructuredLogger,
|
||||
formatTraceparent,
|
||||
MetricsRegistry,
|
||||
parseTraceparent,
|
||||
traceMiddleware,
|
||||
type SpanRecord,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("production observability operations", () => {
|
||||
test("parses and formats strict W3C trace context", () => {
|
||||
const value = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
|
||||
const parsed = parseTraceparent(value);
|
||||
expect(parsed).toEqual({
|
||||
version: "00",
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
sampled: true,
|
||||
});
|
||||
expect(formatTraceparent(parsed!)).toBe(value);
|
||||
expect(parseTraceparent("00-00000000000000000000000000000000-00f067aa0ba902b7-01")).toBeNull();
|
||||
expect(parseTraceparent("ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).toBeNull();
|
||||
});
|
||||
|
||||
test("propagates trace context, correlates locals, and records a framework span", async () => {
|
||||
const parent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
|
||||
const ctx = createContext(
|
||||
new Request("https://example.test/orders", { headers: { traceparent: parent } }),
|
||||
new URL("https://example.test/orders"),
|
||||
);
|
||||
const spans: SpanRecord[] = [];
|
||||
const times = [100, 125];
|
||||
let randomValue = 1;
|
||||
const middleware = traceMiddleware({
|
||||
serviceName: "orders",
|
||||
now: () => times.shift() ?? 125,
|
||||
random: (target) => target.fill(randomValue++),
|
||||
onSpan: (span) => {
|
||||
spans.push(span);
|
||||
},
|
||||
});
|
||||
const response = await middleware(ctx, () => new Response("ok", { status: 201 }));
|
||||
|
||||
expect(ctx.locals.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
expect(ctx.locals.requestId).toBe(ctx.locals.traceId);
|
||||
expect(response.headers.get("traceparent")).toMatch(
|
||||
/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/,
|
||||
);
|
||||
expect(response.headers.get("x-request-id")).toBe(String(ctx.locals.traceId));
|
||||
expect(spans[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
parentSpanId: "00f067aa0ba902b7",
|
||||
durationMs: 25,
|
||||
status: "ok",
|
||||
}),
|
||||
);
|
||||
expect(ctx.tracer?.records()[0]).toEqual(expect.objectContaining({ status: "ok" }));
|
||||
});
|
||||
|
||||
test("does not break responses when a telemetry exporter fails", async () => {
|
||||
const failures: unknown[] = [];
|
||||
const ctx = createContext(
|
||||
new Request("https://example.test/"),
|
||||
new URL("https://example.test/"),
|
||||
);
|
||||
const middleware = traceMiddleware({
|
||||
random: (target) => target.fill(7),
|
||||
exporter: { export: () => Promise.reject(new Error("collector unavailable")) },
|
||||
onExportError: (error) => {
|
||||
failures.push(error);
|
||||
},
|
||||
});
|
||||
const response = await middleware(ctx, () => new Response("ok"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("exports OTLP JSON traces and metrics", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const send = (async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||
requests.push(JSON.parse(String(init?.body)));
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const span: SpanRecord = {
|
||||
name: "GET /",
|
||||
traceId: "1".repeat(32),
|
||||
spanId: "2".repeat(16),
|
||||
sampled: true,
|
||||
startTime: 100,
|
||||
endTime: 125,
|
||||
durationMs: 25,
|
||||
status: "ok",
|
||||
attributes: { "http.response.status_code": 200 },
|
||||
};
|
||||
await createOtlpTraceExporter("https://collector.test/v1/traces", { fetch: send }).export([
|
||||
span,
|
||||
]);
|
||||
const metrics = new MetricsRegistry(() => 200);
|
||||
metrics.increment("requests", 1, { route: "/" });
|
||||
await createOtlpMetricExporter("https://collector.test/v1/metrics", { fetch: send }).export(
|
||||
metrics.snapshot(),
|
||||
);
|
||||
expect(requests[0]).toHaveProperty(
|
||||
"resourceSpans.0.scopeSpans.0.spans.0.traceId",
|
||||
span.traceId,
|
||||
);
|
||||
expect(requests[1]).toHaveProperty(
|
||||
"resourceMetrics.0.scopeMetrics.0.metrics.0.name",
|
||||
"requests",
|
||||
);
|
||||
});
|
||||
|
||||
test("serves liveness and dependency readiness without leaking details by default", async () => {
|
||||
const registry = new HealthRegistry();
|
||||
registry.register("database", () => ({ status: "down", message: "connection refused" }));
|
||||
const live = await createLivenessHandler()(new Request("https://example.test/live"));
|
||||
const ready = await createReadinessHandler(registry)(new Request("https://example.test/ready"));
|
||||
const detailed = await createReadinessHandler(registry, { exposeDetails: true })(
|
||||
new Request("https://example.test/ready"),
|
||||
);
|
||||
expect(live.status).toBe(200);
|
||||
expect(ready.status).toBe(503);
|
||||
expect(await ready.json()).toEqual({ status: "down" });
|
||||
expect(await detailed.json()).toHaveProperty("checks.database.message", "connection refused");
|
||||
});
|
||||
|
||||
test("creates correlated structured child logs with secret redaction", () => {
|
||||
const records: unknown[] = [];
|
||||
const logger = createStructuredLogger({
|
||||
service: "api",
|
||||
now: () => new Date("2026-08-02T00:00:00.000Z"),
|
||||
sink: (record) => records.push(record),
|
||||
}).child({ traceId: "trace", requestId: "request" });
|
||||
logger.info("signed in", { userId: "user-1", token: "secret-value" });
|
||||
expect(records[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
service: "api",
|
||||
traceId: "trace",
|
||||
requestId: "request",
|
||||
attributes: expect.objectContaining({ token: "[REDACTED]", userId: "user-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user