diff --git a/AGENTS.md b/AGENTS.md index 8cc03b1..1e6611b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidance for agents working on `@larvit/log`. Keep changes aligned with the prio ## What this is -Structured logging with a tiny API and first-class OTLP (logs + traces) over `fetch`, with no OpenTelemetry SDK dependency. Works as a plain stdout/stderr logger when OTLP is not configured. +Structured logging with a tiny API and first-class OTLP (logs + traces) over `fetch`, with no OpenTelemetry SDK dependency. Works as a plain stdout/stderr logger when OTLP is not configured. `log.fetch()` auto-instruments outgoing HTTP (client spans + W3C `traceparent` propagation); the `traceparent` option joins upstream traces. ## Design priorities (in order) diff --git a/README.md b/README.md index 6781bb3..02bf166 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @larvit/log -Structured logging with a simple interface and support for OTLP. +Structured logging with a simple interface, OTLP export, and auto-instrumented HTTP tracing. ## Design priorities @@ -68,6 +68,59 @@ async function myRequestHandler(req, res) { ``` +### Trace outgoing HTTP calls + +`log.fetch()` is a drop-in for `fetch()` that automatically creates an OpenTelemetry **client span** +for the call (nested under the log's span) and injects a W3C `traceparent` header, so the downstream +service continues the same trace: + +```javascript +const reqLog = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", parentLog: appLog }); + +const res = await reqLog.fetch("https://api.example.com/users", { method: "POST" }); + +await reqLog.end(); // flushes the spans (the fetch itself never waits on the OTLP export) +``` + +The span records the OTel HTTP semantic-convention attributes (`http.request.method`, `url.full`, +`url.scheme`, `server.address`/`server.port`, `http.response.status_code`, and `error.type` on +failure — its value is the error's `code`, else its `name`, else `"fetch_error"`). 4xx/5xx responses, +and thrown network errors (which are re-thrown unchanged), mark the span as errored. Instrumentation +never changes the call's result. + +Notes: + +- **The span is the only output** — `log.fetch` writes no log line. Without `otlpHttpBaseURI` it just + injects the `traceparent` header (still useful: downstream services continue the trace). +- **Delivery:** the span exports in the background, so the call returns as soon as the response is + ready. `await log.end()` delivers every span started by this log — including a fire-and-forget + `log.fetch()` you never awaited — so a short-lived process can safely exit after `end()`. +- **Errors:** fire-and-forget delivers the *span*, not error handling — exactly like plain `fetch`, a + `log.fetch()` you don't await surfaces a failed request as an unhandled promise rejection. Await it + (or attach a `.catch`) whenever the call can fail. +- **Inputs:** only `string` and `URL` are traced. A `Request` object, or a relative URL with no base + (e.g. in Node), passes straight through to a plain, untraced `fetch` (the call still works). + +**Privacy:** the query string is **dropped** from `url.full` by default (it may carry tokens) and +userinfo is always stripped. Opt in with `captureQuery: true` (known-sensitive keys like `Signature` +are still redacted). Headers are captured only when allow-listed via `captureRequestHeaders` / +`captureResponseHeaders`; request/response bodies are never captured. These three are an +instance-wide policy (read at call time from the log); `clone()` to vary them. + +### Continue a trace from an incoming request + +To join a trace started upstream, pass the incoming `traceparent` header — this log adopts that trace +and nests under the caller's span. Read the current context back with `log.traceparent()` to propagate +it to a non-fetch client: + +```javascript +const reqLog = new Log({ traceparent: req.headers.traceparent }); +// ...later, calling some other client: +myClient.send({ headers: { traceparent: reqLog.traceparent() } }); +``` + +A malformed `traceparent` is ignored (a fresh trace starts), so passing an untrusted header is safe. + ### Configuration **Log level only** @@ -78,6 +131,17 @@ async function myRequestHandler(req, res) { const log = new Log({ // All options is optional + // log.fetch only: include the URL query string on the span (sensitive keys still redacted). + // Default false — the query is dropped, as it may contain tokens. + // Added in 2.3.0 + captureQuery: false, + + // log.fetch only: header-name allow-lists, recorded as http.request.header.* / + // http.response.header.*. Default: none captured. Instance-wide; clone() to vary per call. + // Added in 2.3.0 + captureRequestHeaders: ["x-request-id"], + captureResponseHeaders: ["x-request-id"], + // Context will be appended as metadata to all log entries // Default is an empty context context: { @@ -128,6 +192,11 @@ const log = new Log({ // Added in 1.3.0 printTraceInfo: false, + // Incoming W3C traceparent to adopt: this log joins that trace and nests under that span. + // Ignored if malformed or if parentLog is set. + // Added in 2.3.0 + traceparent: null, + // Use a specific span name. Any log using this log as a parent will be // grouped under this span name. // Defaults to be the same as the span id, that is internally generated for each span @@ -196,6 +265,20 @@ To publish manually instead: `npm run build-and-publish`. ## Changelog +### v2.3.0 + +- **Auto-instrumented HTTP client.** New `log.fetch(input, init?)` — a drop-in for `fetch` that + creates an OTel **client span** for the call (nested under the log's span), injects a W3C + `traceparent` header, and records the HTTP semantic-convention attributes. The query string is + dropped from `url.full` by default (opt in with `captureQuery`); headers are captured only via the + `captureRequestHeaders`/`captureResponseHeaders` allow-lists; bodies are never captured. 4xx/5xx and + thrown errors (re-thrown unchanged) mark the span errored. Spans export in the background and flush + on `end()`, so the fetch never waits on the OTLP round-trip. +- **W3C trace-context propagation.** New `traceparent` option to adopt an incoming trace (join it and + nest under its span; malformed values are ignored) and `log.traceparent()` to emit the current + context for non-fetch clients. New exported helpers `parseTraceparent`/`formatTraceparent`. +- Still dependency-free and runtime-agnostic — built on global `fetch`/`Headers`/`URL`. + ### v2.2.0 - OTLP can now export over **HTTP/protobuf**, not only HTTP/JSON. Opt in with diff --git a/index.ts b/index.ts index 5f08a63..e79fb17 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,12 @@ export type EntryFormatterConf = { }; export type LogConf = { + // log.fetch only: include the URL query string on the span (sensitive keys still redacted). Default false. + captureQuery?: boolean; + // log.fetch only: request header names to record as http.request.header.* (allow-list, none by default). + captureRequestHeaders?: string[]; + // log.fetch only: response header names to record as http.response.header.* (allow-list, none by default). + captureResponseHeaders?: string[]; context?: Metadata; entryFormatter?: (conf: EntryFormatterConf) => string; format?: "text" | "json"; @@ -18,6 +24,9 @@ export type LogConf = { spanName?: string; stderr?: (msg: string) => void; stdout?: (msg: string) => void; + // Incoming W3C traceparent to adopt: this log joins that trace and nests under that span. + // Ignored if malformed or if parentLog is set. Edge-only: not inherited by clones/children. + traceparent?: string; }; // conf after the constructor fills its defaults: the always-set fields are no longer optional. @@ -25,7 +34,9 @@ export type ResolvedLogConf = LogConf & Required Promise; span: OtlpSpan; + traceparent: () => string; /* eslint-disable perfectionist/sort-object-types */ error: LogShorthand; warn: LogShorthand; @@ -228,6 +239,38 @@ export function generateTraceId(): string { return bytesToHex(bytes); } +/** + * Builds a W3C `traceparent` header value (`version-traceId-spanId-flags`) for the given context. + * + * @returns {string} A traceparent header value, sampled by default + */ +export function formatTraceparent(traceId: string, spanId: string, sampled: boolean = true): string { + return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`; +} + +/** + * Parses a W3C `traceparent` header. Untrusted input: returns null (rather than throwing) for any + * malformed or all-zero value, so the caller cleanly starts a fresh trace instead of continuing. + * + * @returns {{ flags: string, spanId: string, traceId: string } | null} The parsed parts, or null + */ +export function parseTraceparent(header: string): { flags: string, spanId: string, traceId: string } | null { + const match = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(header.trim().toLowerCase()); + + if (!match) { + return null; + } + + const [, traceId, spanId, flags] = match; + + // All-zero ids are invalid per the spec; treat them as absent. + if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) { + return null; + } + + return { flags, spanId, traceId }; +} + // msTimestamp should be generated from Date.now() function getNsTimestamp(msTimestamp: number): string { const seconds = Math.floor(msTimestamp / 1000); @@ -286,7 +329,8 @@ function buildLogPayload(opts: { }; } -// Pure builder: state in, OTLP span payload out. Mutates the passed span with its resolved attributes/parent. +// Span finalizer: writes the resolved attributes/parent onto the span, then returns the OTLP payload. +// Not pure — it mutates `span` — but kept out of the class so it stays trivially testable. function buildSpanPayload(opts: { context: Metadata, parentSpan?: OtlpSpan, @@ -490,6 +534,32 @@ function encodeOtlpProtobuf(payload: OtlpLogPayload | OtlpSpanPayload): Uint8Arr return "resourceLogs" in payload ? encodeOtlpLogPayload(payload) : encodeOtlpSpanPayload(payload); } +// --- log.fetch helpers ----------------------------------------------------- + +// Query-param keys whose values are replaced with REDACTED when captureQuery is on. Mirrors the +// default deny-list of the official OTel HTTP instrumentations. Matched case-insensitively. +const SENSITIVE_QUERY_KEYS = new Set(["awsaccesskeyid", "signature", "sig", "x-goog-signature"]); + +// Builds the `url.full` span attribute. Userinfo is always dropped (origin omits it); the query is +// dropped unless captureQuery is set, in which case sensitive values are redacted. +function buildUrlFull(url: URL, captureQuery: boolean): string { + const base = url.origin + url.pathname; + + if (!captureQuery || !url.search) { + return base; + } + + const params = new URLSearchParams(url.search); + + for (const key of [...params.keys()]) { + if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) { + params.set(key, "REDACTED"); + } + } + + return `${base}?${params.toString()}`; +} + export class Log implements LogInt { context: Metadata; ended: boolean = false; @@ -551,6 +621,14 @@ export class Log implements LogInt { this.otlpBaseUrl = new URL(this.conf.otlpHttpBaseURI); } + // An in-process parentLog wins; otherwise adopt an incoming traceparent (cross-process parent); + // otherwise start a fresh trace. + let incoming: ReturnType = null; + + if (!this.conf.parentLog && this.conf.traceparent) { + incoming = parseTraceparent(this.conf.traceparent); + } + this.span = { attributes: [], droppedAttributesCount: 0, @@ -561,11 +639,11 @@ export class Log implements LogInt { kind: 1, links: [], name: this.conf.spanName || "unnamed-span", - parentSpanId: this.conf.parentLog?.span.spanId, + parentSpanId: this.conf.parentLog?.span.spanId ?? incoming?.spanId, spanId: generateSpanId(), startTimeUnixNano: getNsTimestamp(Date.now()), status: { code: 0 }, - traceId: this.conf.parentLog?.span.traceId || generateTraceId(), + traceId: this.conf.parentLog?.span.traceId || incoming?.traceId || generateTraceId(), }; } @@ -598,7 +676,7 @@ export class Log implements LogInt { // parentLog. parentLog and spanName are excluded: a clone is its own span, not a child of the // original. (A manual allow-list here previously dropped OTLP options added after clone existed.) for (const key of Object.keys(this.conf) as (keyof LogConf)[]) { - if (key !== "parentLog" && key !== "spanName" && conf[key] === undefined) { + if (key !== "parentLog" && key !== "spanName" && key !== "traceparent" && conf[key] === undefined) { conf[key] = this.conf[key] as never; } } @@ -620,6 +698,107 @@ export class Log implements LogInt { await this.otlpCreateSpan(this.span); } + // The current span's context as a W3C `traceparent` header, for propagating to non-fetch clients. + public traceparent(): string { + return formatTraceparent(this.span.traceId, this.span.spanId); + } + + // Drop-in `fetch` that auto-creates an OTel CLIENT span for the call (nested under this log's + // span), injects a `traceparent` header, and records the OTel http.* attributes. The span exports + // in the background — the call returns as soon as the response is ready and never waits on the + // OTLP round-trip — but it is registered with end() at call time, so `await log.end()` delivers it + // even when the fetch was not awaited. The span is the only output: no log line is written. + // Instrumentation never changes the call's outcome: only `string`/`URL` inputs are traced; + // anything else (a `Request`, or an unparseable/relative URL with no base) passes straight through + // to a plain, untraced fetch. + public fetch(input: string | URL, init?: RequestInit): Promise { + if (this.ended) { + throw new Error("Logging instance is already ended"); + } + + let url: URL; + + try { + url = new URL(String(input), (globalThis as { location?: { href?: string } }).location?.href); + } catch { + return globalThis.fetch(input, init); + } + + // Register the whole operation (request + span export) synchronously, so a fire-and-forget + // log.fetch() is still delivered by a later await log.end(). + let settle!: () => void; + + this.track(new Promise(resolve => { settle = resolve; })); + + return this.tracedFetch(url, init, settle); + } + + private async tracedFetch(url: URL, init: RequestInit | undefined, settle: () => void): Promise { + // Created with safe operations only; everything that can throw (e.g. `new Headers` on a bad + // header name) lives inside the try, so the finally always runs and settles the tracked + // promise — end() can never hang on this fetch. + const span = this.childSpan(url.host, 3); // CLIENT; name refined below + const context: Metadata = { ...this.context }; + + try { + const method = (init?.method ?? "GET").toUpperCase(); + + span.name = `${method} ${url.host}`; + Object.assign(context, { + "http.request.method": method, + "server.address": url.hostname, + "url.full": buildUrlFull(url, this.conf.captureQuery === true), + "url.scheme": url.protocol.replace(/:$/, ""), + ...url.port ? { "server.port": Number(url.port) } : {}, + }); + + const headers = new Headers(init?.headers); + + if (!headers.has("traceparent")) { + headers.set("traceparent", formatTraceparent(span.traceId, span.spanId)); + } + + for (const name of this.conf.captureRequestHeaders ?? []) { + const value = headers.get(name); + + if (value !== null) { + context[`http.request.header.${name.toLowerCase()}`] = value; + } + } + + const res = await globalThis.fetch(url, { ...init, headers }); + + context["http.response.status_code"] = res.status; + span.status.code = res.status >= 400 ? 2 : 0; // 4xx/5xx are errors for client spans + + for (const name of this.conf.captureResponseHeaders ?? []) { + const value = res.headers.get(name); + + if (value !== null) { + context[`http.response.header.${name.toLowerCase()}`] = value; + } + } + + return res; + } catch (err) { + const cause = err as { code?: string, name?: string }; + + span.status.code = 2; // ERROR + context["error.type"] = cause.code ?? cause.name ?? "fetch_error"; + + throw err; + } finally { + // Always settle the tracked promise so end() can never hang on this fetch — even if the + // export call itself were to throw synchronously (defense-in-depth; it normally returns a + // promise we resolve via .then once the span is delivered). + try { + void this.exportChildSpan(span, context).then(settle, settle); + } catch { + settle(); + } + } + } + public error(msg: string, metadata?: Metadata) { this.log("error", msg, metadata); } public warn(msg: string, metadata?: Metadata) { this.log("warn", msg, metadata); } public info(msg: string, metadata?: Metadata) { this.log("info", msg, metadata); } @@ -774,6 +953,35 @@ export class Log implements LogInt { } } + // A fresh child span under this log's span/trace, with its kind set at birth (no later mutation). + private childSpan(name: string, kind: OtlpSpan["kind"]): OtlpSpan { + const now = getNsTimestamp(Date.now()); + + return { + attributes: [], + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + endTimeUnixNano: now, + events: [], + kind, + links: [], + name, + parentSpanId: this.span.spanId, + spanId: generateSpanId(), + startTimeUnixNano: now, + status: { code: 0 }, + traceId: this.span.traceId, + }; + } + + // Stamps the end time and exports a child span, deriving its attributes/resource from `context`. + private exportChildSpan(span: OtlpSpan, context: Metadata): Promise { + span.endTimeUnixNano = getNsTimestamp(Date.now()); + + return this.otlpCall({ path: "/v1/traces", payload: buildSpanPayload({ context, span }) }); + } + private async otlpCreateSpan(span: OtlpSpan): Promise { const payload = buildSpanPayload({ context: this.context, diff --git a/package-lock.json b/package-lock.json index 3f5d743..2372610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@larvit/log", - "version": "2.1.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@larvit/log", - "version": "2.1.0", + "version": "2.3.0", "license": "MIT", "devDependencies": { "@larvit/eslint-config-typescript-esm": "2.0.0", diff --git a/package.json b/package.json index d9217db..e957f37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larvit/log", - "version": "2.1.0", + "version": "2.3.0", "type": "module", "license": "MIT", "engines": { diff --git a/test.ts b/test.ts index e086eee..cc217f2 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,4 @@ -import { generateSpanId, generateTraceId, Log, type LogConf, LogLevels, msgJsonFormatter } from "./index.js"; +import { formatTraceparent, generateSpanId, generateTraceId, Log, type LogConf, LogLevels, msgJsonFormatter, parseTraceparent } from "./index.js"; import test from "./tap.js"; // --- helpers --------------------------------------------------------------- @@ -35,14 +35,22 @@ function capture(conf?: ConstructorParameters[0]) { function okResponse(json: unknown = { partialSuccess: {} }) { // eslint-disable-next-line id-length -- "ok" mirrors the fetch Response shape the transport reads - return { json: () => Promise.resolve(json), ok: true, status: 200 }; + return { headers: new Headers(), json: () => Promise.resolve(json), ok: true, status: 200 }; +} + +// A fetch Response stand-in for log.fetch tests, with overridable status/headers. +function fetchResponse(opts: { headers?: Headers, status?: number } = {}) { + const status = opts.status ?? 200; + + // eslint-disable-next-line id-length -- "ok" mirrors the fetch Response shape + return { headers: opts.headers ?? new Headers(), json: () => Promise.resolve({}), ok: status < 400, status }; } // Replace the global fetch with a recording stub. Works in Node and the browser, so the OTLP // transport can be asserted without a real HTTP server (deterministic, no express dependency). // The harness restores globalThis.fetch after each test, so callers never restore it themselves. function stubFetch(responder?: (path: string, body: unknown) => ReturnType | undefined) { - const calls: { body: any, contentType: string, path: string, rawBody: any, url: string }[] = []; + const calls: { body: any, contentType: string, headers: any, path: string, rawBody: any, url: string }[] = []; globalThis.fetch = (async (url: string, init: { body: any, headers: Record }) => { const contentType = init.headers?.["Content-Type"]; @@ -50,7 +58,7 @@ function stubFetch(responder?: (path: string, body: unknown) => ReturnType ReturnType new Headers(call.headers ?? {}).get(name); + +// Locate the exported CLIENT span (kind 3) among the /v1/traces calls — the one log.fetch creates. +function clientSpan(calls: { body: any, path: string }[]) { + for (const call of calls.filter(call => call.path === "/v1/traces")) { + const span = call.body.resourceSpans[0].scopeSpans[0].spans[0]; + + if (span.kind === 3) { + return span; + } + } + + throw new Error("no client span was exported"); +} + // --- protobuf decode (test-only, zero-dep) --------------------------------- // Minimal reader that verifies the hand-rolled OTLP protobuf encoder: it decodes the wire bytes // back into the shape the JSON transport builds, so the same facts can be asserted. Field numbers @@ -647,3 +671,283 @@ test("OLTP multiple instances should work independently", async t => { t.end(); }); + +// --- trace context propagation (W3C traceparent) --------------------------- + +test("formatTraceparent/parseTraceparent round-trip", t => { + const traceId = generateTraceId(); + const spanId = generateSpanId(); + const header = formatTraceparent(traceId, spanId); + + t.ok(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/.test(header), "formatted header has the W3C shape and is sampled"); + + const parsed = parseTraceparent(header); + + t.strictEqual(parsed?.traceId, traceId, "round-tripped traceId"); + t.strictEqual(parsed?.spanId, spanId, "round-tripped spanId"); + t.end(); +}); + +test("parseTraceparent rejects malformed headers", t => { + t.strictEqual(parseTraceparent("garbage"), null, "non-hex garbage is rejected"); + t.strictEqual(parseTraceparent(`00-${"0".repeat(32)}-${"a".repeat(16)}-01`), null, "all-zero traceId is rejected"); + t.strictEqual(parseTraceparent(`00-${"a".repeat(32)}-${"0".repeat(16)}-01`), null, "all-zero spanId is rejected"); + t.strictEqual(parseTraceparent(`00-${"a".repeat(31)}-${"b".repeat(16)}-01`), null, "wrong-length traceId is rejected"); + t.end(); +}); + +test("Log adopts an incoming traceparent (distributed trace continuation)", t => { + const traceId = generateTraceId(); + const spanId = generateSpanId(); + const log = new Log({ traceparent: formatTraceparent(traceId, spanId) }); + + t.strictEqual(log.span.traceId, traceId, "log joins the incoming trace"); + t.strictEqual(log.span.parentSpanId, spanId, "the incoming span becomes the parent span"); + t.end(); +}); + +test("Log ignores a malformed traceparent and starts a fresh trace", t => { + let log!: Log; + + t.doesNotThrow(() => { log = new Log({ traceparent: "not-a-traceparent" }); }, "malformed traceparent does not throw (untrusted input)"); + t.ok(/^[0-9a-f]{32}$/.test(log.span.traceId), "a fresh traceId is generated"); + t.strictEqual(log.span.parentSpanId, undefined, "no parent span is set"); + t.end(); +}); + +test("parentLog wins over traceparent", t => { + const parent = new Log(); + const child = new Log({ parentLog: parent, traceparent: formatTraceparent(generateTraceId(), generateSpanId()) }); + + t.strictEqual(child.span.traceId, parent.span.traceId, "in-process parent trace takes precedence"); + t.strictEqual(child.span.parentSpanId, parent.span.spanId, "the parent span is the parent, not the header span"); + t.end(); +}); + +test("log.traceparent() emits the current span context", t => { + const log = new Log(); + + t.strictEqual(log.traceparent(), formatTraceparent(log.span.traceId, log.span.spanId), "emitted header carries this span's context"); + t.end(); +}); + +test("clone() does not re-adopt the traceparent (its own trace)", t => { + const base = new Log({ traceparent: formatTraceparent(generateTraceId(), generateSpanId()) }); + const clone = base.clone(); + + t.notStrictEqual(clone.span.traceId, base.span.traceId, "clone starts its own trace, not the adopted one"); + t.end(); +}); + +// --- log.fetch() auto-instrumentation -------------------------------------- + +test("log.fetch injects a traceparent and returns the response", async t => { + const { calls } = stubFetch(); + const log = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + const res = await log.fetch("https://api.test/users"); + + await log.end(); + + t.strictEqual(res.status, 200, "the underlying response is returned to the caller"); + + const parsed = parseTraceparent(callHeader(calls.find(call => call.path === "/users")!, "traceparent") ?? ""); + + t.ok(parsed, "outgoing request carries a valid traceparent"); + t.strictEqual(parsed!.traceId, log.span.traceId, "outgoing trace continues this log's trace"); + t.notStrictEqual(parsed!.spanId, log.span.spanId, "a fresh child span id is propagated, not the calling span's"); + t.end(); +}); + +test("log.fetch exports a CLIENT span with url.full query dropped by default", async t => { + const { calls } = stubFetch(); + const log = new Log({ context: { "service.name": "svc" }, otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + await log.fetch("https://api.test:8443/users?token=secret&q=hi", { method: "POST" }); + await log.end(); + + const span = clientSpan(calls); + const attr = (key: string) => span.attributes.find((attribute: any) => attribute.key === key)?.value.stringValue; + + t.strictEqual(span.kind, 3, "client span kind"); + t.strictEqual(span.name, "POST api.test:8443", "low-cardinality span name (method + host)"); + t.strictEqual(span.parentSpanId, log.span.spanId, "fetch span nests under the calling log span"); + t.strictEqual(attr("http.request.method"), "POST", "method attribute"); + t.strictEqual(attr("url.full"), "https://api.test:8443/users", "url.full has no query string and no userinfo by default"); + t.strictEqual(attr("url.scheme"), "https", "url.scheme attribute"); + t.strictEqual(attr("server.address"), "api.test", "server.address attribute"); + t.strictEqual(attr("server.port"), "8443", "server.port attribute"); + t.strictEqual(attr("http.response.status_code"), "200", "status_code attribute"); + t.end(); +}); + +test("log.fetch captureQuery keeps the query but redacts known-sensitive keys", async t => { + const { calls } = stubFetch(); + const log = new Log({ captureQuery: true, otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + await log.fetch("https://api.test/x?q=hi&Signature=abc"); + await log.end(); + + const urlFull = clientSpan(calls).attributes.find((attribute: any) => attribute.key === "url.full").value.stringValue; + + t.ok(urlFull.includes("q=hi"), "non-sensitive query param is kept"); + t.ok(urlFull.includes("Signature=REDACTED"), "sensitive query value is redacted"); + t.ok(!urlFull.includes("abc"), "the sensitive value is not leaked"); + t.end(); +}); + +test("log.fetch captures allow-listed request and response headers only", async t => { + const { calls } = stubFetch(path => { + if (path === "/h") { + return fetchResponse({ headers: new Headers({ "x-resp": "rv", "x-secret": "nope" }) }); + } + + return undefined; + }); + const log = new Log({ + captureRequestHeaders: ["x-req"], + captureResponseHeaders: ["x-resp"], + otlpHttpBaseURI: "http://127.0.0.1:4318", + stderr: () => {}, + }); + + await log.fetch("https://api.test/h", { headers: { "x-other": "ignored", "x-req": "qv" } }); + await log.end(); + + const span = clientSpan(calls); + const attr = (key: string) => span.attributes.find((attribute: any) => attribute.key === key)?.value.stringValue; + + t.strictEqual(attr("http.request.header.x-req"), "qv", "allow-listed request header captured"); + t.strictEqual(attr("http.request.header.x-other"), undefined, "non-listed request header not captured"); + t.strictEqual(attr("http.response.header.x-resp"), "rv", "allow-listed response header captured"); + t.strictEqual(attr("http.response.header.x-secret"), undefined, "non-listed response header not captured"); + t.end(); +}); + +test("log.fetch marks 4xx/5xx responses as error spans but still returns them", async t => { + const { calls } = stubFetch(path => { + if (path === "/missing") { + return fetchResponse({ status: 404 }); + } + + return undefined; + }); + const log = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + const res = await log.fetch("https://api.test/missing"); + + await log.end(); + + t.strictEqual(res.status, 404, "a 4xx response is still returned (it is not an exception)"); + t.strictEqual(clientSpan(calls).status.code, 2, "span status is ERROR for 4xx"); + t.end(); +}); + +test("log.fetch records error.type and re-throws on a network failure", async t => { + const { calls } = stubFetch(path => { + if (path === "/boom") { + throw Object.assign(new Error("down"), { code: "ECONNREFUSED" }); + } + + return undefined; + }); + const log = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + let threw = false; + + try { + await log.fetch("https://api.test/boom"); + } catch { + threw = true; + } + + await log.end(); + + t.ok(threw, "the underlying fetch error propagates to the caller"); + + const span = clientSpan(calls); + + t.strictEqual(span.status.code, 2, "span status is ERROR"); + t.strictEqual(span.attributes.find((attribute: any) => attribute.key === "error.type").value.stringValue, "ECONNREFUSED", "error.type captured from the error code"); + t.end(); +}); + +test("log.fetch keeps a caller-supplied traceparent", async t => { + const { calls } = stubFetch(); + const log = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + const supplied = formatTraceparent(generateTraceId(), generateSpanId()); + + await log.fetch("https://api.test/x", { headers: { traceparent: supplied } }); + await log.end(); + + t.strictEqual(callHeader(calls.find(call => call.path === "/x")!, "traceparent"), supplied, "the caller's traceparent is not overwritten"); + t.end(); +}); + +test("await log.end() drains a fire-and-forget log.fetch span", async t => { + const { calls } = stubFetch(); + const log = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + log.fetch("https://api.test/bg"); // deliberately NOT awaited + + await log.end(); + + // The span must be delivered by the time end() resolves, even though the fetch was not awaited — + // otherwise a short-lived process would exit before the span is exported. + t.strictEqual(clientSpan(calls).name, "GET api.test", "the client span was exported before end() resolved"); + t.end(); +}); + +test("log.fetch with an invalid header rejects but never hangs end()", async t => { + const { calls } = stubFetch(); + const log = new Log({ otlpHttpBaseURI: "http://127.0.0.1:4318", stderr: () => {} }); + + let threw = false; + + try { + // An invalid header name makes `new Headers()` throw during setup, before the request goes out. + await log.fetch("https://api.test/x", { headers: { "bad header name": "v" } }); + } catch { + threw = true; + } + + // Must resolve, not hang — the tracked promise has to settle even when setup throws. + // (The harness fails the test on a >10s hang.) + await log.end(); + + t.ok(threw, "the setup error propagates to the caller"); + t.strictEqual(clientSpan(calls).status.code, 2, "an error span is still exported for the failed call"); + t.end(); +}); + +test("log.fetch throws when the log is already ended", async t => { + const log = new Log({ stderr: () => {} }); + + await log.end(); + t.throws(() => log.fetch("https://api.test/x"), "fetch on an ended log throws, like the log methods"); + t.end(); +}); + +test("clone() inherits captureQuery and the header allow-lists", t => { + const base = new Log({ captureQuery: true, captureRequestHeaders: ["x-req"], captureResponseHeaders: ["x-resp"] }); + const clone = base.clone(); + + t.strictEqual(clone.conf.captureQuery, true, "captureQuery inherited"); + t.deepEqual(clone.conf.captureRequestHeaders, ["x-req"], "request header allow-list inherited"); + t.deepEqual(clone.conf.captureResponseHeaders, ["x-resp"], "response header allow-list inherited"); + t.end(); +}); + +test("log.fetch works without OTLP, still injecting a traceparent", async t => { + const { calls } = stubFetch(); + const log = new Log({ stderr: () => {} }); + + const res = await log.fetch("https://api.test/x"); + + await log.end(); + + t.strictEqual(res.status, 200, "fetch still returns the response"); + t.ok(calls.every(call => call.path !== "/v1/traces"), "no span is exported when OTLP is not configured"); + t.ok(callHeader(calls.find(call => call.path === "/x")!, "traceparent"), "traceparent is still injected for downstream continuation"); + t.end(); +});