From 1f5b4a794d6d7d090bdd8953a80eb5446107e951 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Thu, 30 Jul 2026 23:05:36 -0400 Subject: [PATCH] Extract the local trace reader into a shared harness module The spool parsing, span assembly, and trace listing that lived inside the `eve traces` CLI command move to #harness/local-trace-reader so other consumers (the dev TUI, tests) can read the local spool without going through the CLI. The command keeps its behavior and re-exports the reader API; traces also expose their session ids and window index. Signed-off-by: Chad Hietala --- .changeset/shared-trace-reader.md | 5 + .../cli/commands/trace.integration.test.ts | 9 +- packages/eve/src/cli/commands/trace.ts | 307 ++--------------- ...l-instrumentation-runtime.scenario.test.ts | 2 +- .../eve/src/harness/local-trace-reader.ts | 323 ++++++++++++++++++ 5 files changed, 353 insertions(+), 293 deletions(-) create mode 100644 .changeset/shared-trace-reader.md create mode 100644 packages/eve/src/harness/local-trace-reader.ts diff --git a/.changeset/shared-trace-reader.md b/.changeset/shared-trace-reader.md new file mode 100644 index 000000000..33c4a22dc --- /dev/null +++ b/.changeset/shared-trace-reader.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +The local trace spool reader behind `eve traces` moved into a shared internal module; command behavior is unchanged. diff --git a/packages/eve/src/cli/commands/trace.integration.test.ts b/packages/eve/src/cli/commands/trace.integration.test.ts index 5e80f9f5f..33f3b3ce3 100644 --- a/packages/eve/src/cli/commands/trace.integration.test.ts +++ b/packages/eve/src/cli/commands/trace.integration.test.ts @@ -4,12 +4,9 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - listLocalTraces, - resolveLocalTraces, - runTraceListCommand, - runTraceShowCommand, -} from "./trace.js"; +import { listLocalTraces } from "#harness/local-trace-reader.js"; + +import { resolveLocalTraces, runTraceListCommand, runTraceShowCommand } from "./trace.js"; const TRACE_ONE = "1".repeat(32); const TRACE_TWO = "2".repeat(32); diff --git a/packages/eve/src/cli/commands/trace.ts b/packages/eve/src/cli/commands/trace.ts index a9bd71729..eb0d0b033 100644 --- a/packages/eve/src/cli/commands/trace.ts +++ b/packages/eve/src/cli/commands/trace.ts @@ -1,75 +1,28 @@ -import { readdir, readFile, stat } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename } from "node:path"; import { formatElapsed } from "#cli/format-elapsed.js"; import { createCliTheme, renderCliSection, sanitizeForTerminal } from "#cli/ui/output.js"; +import type { LocalTrace, LocalTraceSpan } from "#harness/local-trace-reader.js"; +import { + compareLocalTraceSpans, + describeLocalTraceSpan, + listLocalTraces, +} from "#harness/local-trace-reader.js"; -const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/u; -const SPAN_FILE_PATTERN = /^([0-9a-f]{16})\.otlp\.json$/u; -const TRACE_DIRECTORY_SEGMENTS = [".eve", "traces", "v1"] as const; -const TRACE_DISPLAY_DIRECTORY = TRACE_DIRECTORY_SEGMENTS.join("/"); -const MAX_SEGMENT_BYTES = 8 * 1024 * 1024; -const MAX_UINT64 = 18_446_744_073_709_551_615n; +const TRACE_DISPLAY_DIRECTORY = ".eve/traces/v1"; interface CliTraceLogger { error(message: string): void; log(message: string): void; } -export interface LocalTraceSpan { - readonly attributes: Readonly>; - readonly endTimeNs: bigint; - readonly name: string; - readonly parentSpanId?: string; - readonly scope?: string; - readonly spanId: string; - readonly startTimeNs: bigint; - readonly statusCode: number; - readonly traceId: string; -} +export type { LocalTrace, LocalTraceSpan }; interface SpanExtent { readonly endTimeNs: bigint; readonly startTimeNs: bigint; } -export interface LocalTrace { - readonly agentName?: string; - readonly endTimeNs: bigint; - readonly sessionId?: string; - readonly sessionIds: readonly string[]; - readonly spans: readonly LocalTraceSpan[]; - readonly startTimeNs: bigint; - readonly traceId: string; - readonly window?: number; -} - -/** Reads valid local traces, newest first, while ignoring malformed segments. */ -export async function listLocalTraces(appRoot: string): Promise { - const root = join(appRoot, ...TRACE_DIRECTORY_SEGMENTS); - let entries; - try { - entries = await readdir(root, { withFileTypes: true }); - } catch (error) { - if (isMissing(error)) return []; - throw error; - } - - const traces: LocalTrace[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || !TRACE_ID_PATTERN.test(entry.name)) continue; - const trace = await readTrace(root, entry.name).catch(() => undefined); - if (trace !== undefined) traces.push(trace); - } - return traces.sort((left, right) => - left.startTimeNs === right.startTimeNs - ? left.traceId.localeCompare(right.traceId) - : left.startTimeNs > right.startTimeNs - ? -1 - : 1, - ); -} - /** * Resolves an exact trace/session id or an unambiguous prefix. One session id * can match several traces, since a long session windows into more than one. @@ -210,132 +163,6 @@ export async function runTraceShowCommand( ); } -async function readTrace(root: string, traceId: string): Promise { - const segmentsRoot = join(root, traceId, "segments"); - let entries; - try { - entries = await readdir(segmentsRoot, { withFileTypes: true }); - } catch (error) { - if (isMissing(error)) return undefined; - throw error; - } - const spans = new Map(); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!entry.isFile() || !SPAN_FILE_PATTERN.test(entry.name)) continue; - let content: string; - try { - const path = join(segmentsRoot, entry.name); - if ((await stat(path)).size > MAX_SEGMENT_BYTES) continue; - content = await readFile(path, "utf8"); - } catch { - continue; - } - for (const span of parseSegment(content, traceId)) { - if (!spans.has(span.spanId)) spans.set(span.spanId, span); - } - } - if (spans.size === 0) return undefined; - const ordered = [...spans.values()].sort(compareSpans); - const attributes = ordered.map((span) => span.attributes); - const sessionIds = distinctAttributes(attributes, "agent.session.id"); - return { - agentName: firstAttribute(attributes, "agent.name"), - endTimeNs: ordered.reduce( - (value, span) => (span.endTimeNs > value ? span.endTimeNs : value), - 0n, - ), - sessionId: sessionIds[0], - sessionIds, - spans: ordered, - startTimeNs: ordered.reduce( - (value, span) => (span.startTimeNs < value ? span.startTimeNs : value), - ordered[0]!.startTimeNs, - ), - traceId, - window: firstNumberAttribute(attributes, "agent.session.window"), - }; -} - -function parseSegment(content: string, expectedTraceId: string): LocalTraceSpan[] { - try { - const request = JSON.parse(content) as unknown; - if (!isRecord(request) || !Array.isArray(request.resourceSpans)) return []; - const spans: LocalTraceSpan[] = []; - for (const resource of request.resourceSpans) { - if (!isRecord(resource) || !Array.isArray(resource.scopeSpans)) continue; - for (const scoped of resource.scopeSpans) { - if (!isRecord(scoped) || !Array.isArray(scoped.spans)) continue; - const scope = - isRecord(scoped.scope) && typeof scoped.scope.name === "string" - ? scoped.scope.name - : undefined; - for (const rawSpan of scoped.spans) { - const span = parseSpan(rawSpan, expectedTraceId, scope); - if (span !== undefined) spans.push(span); - } - } - } - return spans; - } catch { - return []; - } -} - -function parseSpan( - raw: unknown, - expectedTraceId: string, - scope?: string, -): LocalTraceSpan | undefined { - if (!isRecord(raw)) return undefined; - if ( - raw.traceId !== expectedTraceId || - typeof raw.spanId !== "string" || - !/^[0-9a-f]{16}$/u.test(raw.spanId) || - typeof raw.name !== "string" - ) { - return undefined; - } - const startTimeNs = parseNanos(raw.startTimeUnixNano); - const endTimeNs = parseNanos(raw.endTimeUnixNano); - if (startTimeNs === undefined || endTimeNs === undefined || endTimeNs < startTimeNs) - return undefined; - return { - attributes: parseAttributes(raw.attributes), - endTimeNs, - name: raw.name, - parentSpanId: - typeof raw.parentSpanId === "string" && /^[0-9a-f]{16}$/u.test(raw.parentSpanId) - ? raw.parentSpanId - : undefined, - scope, - spanId: raw.spanId, - startTimeNs, - statusCode: parseStatusCode(raw.status), - traceId: expectedTraceId, - }; -} - -function parseAttributes(value: unknown): Record { - if (!Array.isArray(value)) return {}; - const attributes: Record = {}; - for (const entry of value) { - if (!isRecord(entry) || typeof entry.key !== "string" || !isRecord(entry.value)) continue; - const parsed = parseAnyValue(entry.value); - if (parsed !== undefined) attributes[entry.key] = parsed; - } - return attributes; -} - -function parseAnyValue(value: Record): unknown { - for (const key of ["stringValue", "boolValue", "intValue", "doubleValue"] as const) { - if (key in value) return value[key]; - } - if (isRecord(value.arrayValue) && Array.isArray(value.arrayValue.values)) { - return value.arrayValue.values.filter(isRecord).map((entry) => parseAnyValue(entry)); - } - return undefined; -} - function renderSpanTree(spans: readonly LocalTraceSpan[]): string { const byId = new Map(spans.map((span) => [span.spanId, span])); const children = new Map(); @@ -349,8 +176,8 @@ function renderSpanTree(spans: readonly LocalTraceSpan[]): string { children.set(span.parentSpanId, siblings); } } - roots.sort(compareSpans); - for (const siblings of children.values()) siblings.sort(compareSpans); + roots.sort(compareLocalTraceSpans); + for (const siblings of children.values()) siblings.sort(compareLocalTraceSpans); const extents = subtreeExtents(spans, children); const lines: string[] = []; @@ -370,12 +197,20 @@ function renderSpanTree(spans: readonly LocalTraceSpan[]): string { }); }; for (const root of roots) render(root, "", ""); - for (const span of [...spans].sort(compareSpans)) { + for (const span of [...spans].sort(compareLocalTraceSpans)) { if (!visited.has(span.spanId)) render(span, "", ""); } return lines.join("\n"); } +/** + * Extent of each span's own range unioned with its descendants', keyed by span id. + * + * eve records a span whose lifetime crosses a durable worker boundary — a + * session window or a turn — as a zero-duration marker, because a span object + * cannot cross that boundary to be ended later. The tree falls back to this + * extent for those rows so it shows where the time went. + */ function subtreeExtents( spans: readonly LocalTraceSpan[], children: ReadonlyMap, @@ -405,32 +240,7 @@ function subtreeExtents( } function spanLabel(span: LocalTraceSpan, extent?: SpanExtent): string { - const details: string[] = []; - const turnId = stringAttribute(span, "agent.turn.id"); - const stepIndex = valueAttribute(span, "agent.step.index"); - const attempt = valueAttribute(span, "agent.step.attempt"); - const actionKind = stringAttribute(span, "agent.action.kind"); - const actionName = stringAttribute(span, "agent.action.name"); - const model = - stringAttribute(span, "agent.model.id") ?? stringAttribute(span, "gen_ai.request.model"); - if (span.name === "agent.turn" && turnId !== undefined) { - details.push(sanitizeForTerminal(turnId)); - } - if (span.name === "agent.step" && stepIndex !== undefined) { - details.push( - `step ${sanitizeForTerminal(String(stepIndex))}${ - attempt === undefined ? "" : `, attempt ${sanitizeForTerminal(String(attempt))}` - }`, - ); - } - if (span.name === "agent.action" && actionName !== undefined) { - details.push( - `${sanitizeForTerminal(actionKind ?? "action")}: ${sanitizeForTerminal(actionName)}`, - ); - } - if (model !== undefined && span.name.includes("do")) { - details.push(`model ${sanitizeForTerminal(model)}`); - } + const details = describeLocalTraceSpan(span).map(sanitizeForTerminal); const detail = details.length === 0 ? "" : ` [${details.join(", ")}]`; const error = span.statusCode === 2 ? " ERROR" : ""; const recorded = durationMs(span.startTimeNs, span.endTimeNs); @@ -441,73 +251,6 @@ function spanLabel(span: LocalTraceSpan, extent?: SpanExtent): string { return `${sanitizeForTerminal(span.name)}${detail} ${formatElapsed(elapsed)}${error}`; } -function compareSpans(left: LocalTraceSpan, right: LocalTraceSpan): number { - if (left.startTimeNs !== right.startTimeNs) return left.startTimeNs < right.startTimeNs ? -1 : 1; - if (left.endTimeNs !== right.endTimeNs) return left.endTimeNs < right.endTimeNs ? -1 : 1; - return left.name.localeCompare(right.name) || left.spanId.localeCompare(right.spanId); -} - -function firstNumberAttribute( - attributes: readonly Readonly>[], - key: string, -): number | undefined { - for (const values of attributes) { - const value = values[key]; - if (typeof value === "number") return value; - if (typeof value === "bigint") return Number(value); - } - return undefined; -} - -function distinctAttributes( - attributes: readonly Readonly>[], - key: string, -): readonly string[] { - const values = new Set(); - for (const entry of attributes) { - const value = entry[key]; - if (typeof value === "string" && value.length > 0) values.add(value); - } - return [...values]; -} - -function firstAttribute( - attributes: readonly Readonly>[], - key: string, -): string | undefined { - for (const values of attributes) { - const value = values[key]; - if (typeof value === "string" && value.length > 0) return value; - } - return undefined; -} - -function stringAttribute(span: LocalTraceSpan, key: string): string | undefined { - const value = span.attributes[key]; - return typeof value === "string" ? value : undefined; -} - -function valueAttribute(span: LocalTraceSpan, key: string): string | number | undefined { - const value = span.attributes[key]; - return typeof value === "string" || typeof value === "number" ? value : undefined; -} - -function parseNanos(value: unknown): bigint | undefined { - if (typeof value !== "string" && typeof value !== "number") return undefined; - try { - const parsed = BigInt(value); - return parsed >= 0n && parsed <= MAX_UINT64 ? parsed : undefined; - } catch { - return undefined; - } -} - -function parseStatusCode(value: unknown): number { - if (!isRecord(value)) return 0; - if (typeof value.code === "number") return value.code; - return value.code === "STATUS_CODE_ERROR" ? 2 : 0; -} - function durationMs(start: bigint, end: bigint): number { return Number(end - start) / 1_000_000; } @@ -516,14 +259,6 @@ function toDate(nanoseconds: bigint): Date { return new Date(Number(nanoseconds / 1_000_000n)); } -function isMissing(error: unknown): boolean { - return (error as NodeJS.ErrnoException).code === "ENOENT"; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function ambiguousTraceError(traces: readonly LocalTrace[], reference: string): Error { return new Error( [ diff --git a/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts b/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts index 329fe000b..56795d413 100644 --- a/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts +++ b/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts @@ -7,8 +7,8 @@ import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; import { afterEach, describe, expect, it } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; -import { listLocalTraces } from "#cli/commands/trace.js"; import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js"; +import { listLocalTraces } from "#harness/local-trace-reader.js"; import type { InstrumentationAttemptScope } from "#harness/instrumentation-lifecycle.js"; import { installLocalInstrumentationRuntime } from "#harness/local-instrumentation-runtime.js"; import { LocalTraceSpanProcessor } from "#harness/local-trace-span-processor.js"; diff --git a/packages/eve/src/harness/local-trace-reader.ts b/packages/eve/src/harness/local-trace-reader.ts new file mode 100644 index 000000000..aa8e8de3f --- /dev/null +++ b/packages/eve/src/harness/local-trace-reader.ts @@ -0,0 +1,323 @@ +/** + * Reads the local trace spool written by {@link LocalTraceSpanProcessor}. + * + * The spool is the contract between the dev-time writer and every viewer + * (`eve traces`, the TUI trace viewer): one directory per trace under + * `.eve/traces/v1//segments/`, one immutable OTLP/JSON file per + * span. Parsing is defensive end to end — malformed or oversized segments + * are skipped so a partial write never breaks a view. + */ + +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; + +import { resolveLocalTraceSchemaDirectory } from "#harness/local-trace-span-processor.js"; + +const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/u; +const SPAN_FILE_PATTERN = /^([0-9a-f]{16})\.otlp\.json$/u; +const MAX_SEGMENT_BYTES = 8 * 1024 * 1024; +const MAX_UINT64 = 18_446_744_073_709_551_615n; + +export interface LocalTraceSpan { + readonly attributes: Readonly>; + readonly endTimeNs: bigint; + readonly name: string; + readonly parentSpanId?: string; + readonly scope?: string; + readonly spanId: string; + readonly startTimeNs: bigint; + readonly statusCode: number; + readonly traceId: string; +} + +export interface LocalTrace { + readonly agentName?: string; + readonly endTimeNs: bigint; + /** Session that opened the trace, which is the one the list shows. */ + readonly sessionId?: string; + /** + * Every session recorded in the trace, opener first. + * + * A subagent child records into the window its parent had open, so one + * trace can hold several sessions and any of their ids resolves to it. + */ + readonly sessionIds: readonly string[]; + readonly spans: readonly LocalTraceSpan[]; + readonly startTimeNs: bigint; + readonly traceId: string; + /** Zero-based session window this trace holds, when the spans record one. */ + readonly window?: number; +} + +/** Reads valid local traces, newest first, while ignoring malformed segments. */ +export async function listLocalTraces(appRoot: string): Promise { + const root = resolveLocalTraceSchemaDirectory(appRoot); + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } + + const traces: LocalTrace[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || !TRACE_ID_PATTERN.test(entry.name)) continue; + const trace = await readLocalTrace(root, entry.name).catch(() => undefined); + if (trace !== undefined) traces.push(trace); + } + return traces.sort((left, right) => + left.startTimeNs === right.startTimeNs + ? left.traceId.localeCompare(right.traceId) + : left.startTimeNs > right.startTimeNs + ? -1 + : 1, + ); +} + +/** + * Reads one trace from its segments directory, or `undefined` when the trace + * has no valid spans. Spans come back ordered by start time. + */ +export async function readLocalTrace( + root: string, + traceId: string, +): Promise { + const segmentsRoot = join(root, traceId, "segments"); + let entries; + try { + entries = await readdir(segmentsRoot, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + const spans = new Map(); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isFile() || !SPAN_FILE_PATTERN.test(entry.name)) continue; + let content: string; + try { + const path = join(segmentsRoot, entry.name); + if ((await stat(path)).size > MAX_SEGMENT_BYTES) continue; + content = await readFile(path, "utf8"); + } catch { + continue; + } + for (const span of parseLocalTraceSegment(content, traceId)) { + if (!spans.has(span.spanId)) spans.set(span.spanId, span); + } + } + if (spans.size === 0) return undefined; + return assembleLocalTrace(traceId, [...spans.values()]); +} + +/** Sorts spans into timeline order: start, then end, then a stable tiebreak. */ +export function compareLocalTraceSpans(left: LocalTraceSpan, right: LocalTraceSpan): number { + if (left.startTimeNs !== right.startTimeNs) return left.startTimeNs < right.startTimeNs ? -1 : 1; + if (left.endTimeNs !== right.endTimeNs) return left.endTimeNs < right.endTimeNs ? -1 : 1; + return left.name.localeCompare(right.name) || left.spanId.localeCompare(right.spanId); +} + +/** Builds a trace from already-parsed spans, deriving summary fields. */ +export function assembleLocalTrace(traceId: string, spans: readonly LocalTraceSpan[]): LocalTrace { + const ordered = [...spans].sort(compareLocalTraceSpans); + const attributes = ordered.map((span) => span.attributes); + const sessionIds = distinctAttributes(attributes, "agent.session.id"); + return { + agentName: firstAttribute(attributes, "agent.name"), + endTimeNs: ordered.reduce( + (value, span) => (span.endTimeNs > value ? span.endTimeNs : value), + 0n, + ), + sessionId: sessionIds[0], + sessionIds, + spans: ordered, + startTimeNs: ordered.reduce( + (value, span) => (span.startTimeNs < value ? span.startTimeNs : value), + ordered[0]!.startTimeNs, + ), + traceId, + window: firstNumberAttribute(attributes, "agent.session.window"), + }; +} + +/** + * Extracts short human-facing details from a span's structural attributes + * (turn id, step index/attempt, action kind/name, model id) for one-line + * span labels. Raw values — callers sanitize for their output surface. + */ +export function describeLocalTraceSpan(span: LocalTraceSpan): string[] { + const details: string[] = []; + const turnId = stringSpanAttribute(span, "agent.turn.id"); + const stepIndex = valueSpanAttribute(span, "agent.step.index"); + const attempt = valueSpanAttribute(span, "agent.step.attempt"); + const actionKind = stringSpanAttribute(span, "agent.action.kind"); + const actionName = stringSpanAttribute(span, "agent.action.name"); + const model = + stringSpanAttribute(span, "agent.model.id") ?? + stringSpanAttribute(span, "gen_ai.request.model"); + if (span.name === "agent.turn" && turnId !== undefined) { + details.push(turnId); + } + if (span.name === "agent.step" && stepIndex !== undefined) { + details.push( + `step ${String(stepIndex)}${attempt === undefined ? "" : `, attempt ${String(attempt)}`}`, + ); + } + if (span.name === "agent.action" && actionName !== undefined) { + details.push(`${actionKind ?? "action"}: ${actionName}`); + } + if (model !== undefined && span.name.includes("do")) { + details.push(`model ${model}`); + } + return details; +} + +/** Parses one OTLP/JSON segment file into spans belonging to `expectedTraceId`. */ +export function parseLocalTraceSegment(content: string, expectedTraceId: string): LocalTraceSpan[] { + try { + const request = JSON.parse(content) as unknown; + if (!isRecord(request) || !Array.isArray(request.resourceSpans)) return []; + const spans: LocalTraceSpan[] = []; + for (const resource of request.resourceSpans) { + if (!isRecord(resource) || !Array.isArray(resource.scopeSpans)) continue; + for (const scoped of resource.scopeSpans) { + if (!isRecord(scoped) || !Array.isArray(scoped.spans)) continue; + const scope = + isRecord(scoped.scope) && typeof scoped.scope.name === "string" + ? scoped.scope.name + : undefined; + for (const rawSpan of scoped.spans) { + const span = parseLocalTraceSpan(rawSpan, expectedTraceId, scope); + if (span !== undefined) spans.push(span); + } + } + } + return spans; + } catch { + return []; + } +} + +function parseLocalTraceSpan( + raw: unknown, + expectedTraceId: string, + scope?: string, +): LocalTraceSpan | undefined { + if (!isRecord(raw)) return undefined; + if ( + raw.traceId !== expectedTraceId || + typeof raw.spanId !== "string" || + !/^[0-9a-f]{16}$/u.test(raw.spanId) || + typeof raw.name !== "string" + ) { + return undefined; + } + const startTimeNs = parseNanos(raw.startTimeUnixNano); + const endTimeNs = parseNanos(raw.endTimeUnixNano); + if (startTimeNs === undefined || endTimeNs === undefined || endTimeNs < startTimeNs) + return undefined; + return { + attributes: parseAttributes(raw.attributes), + endTimeNs, + name: raw.name, + parentSpanId: + typeof raw.parentSpanId === "string" && /^[0-9a-f]{16}$/u.test(raw.parentSpanId) + ? raw.parentSpanId + : undefined, + scope, + spanId: raw.spanId, + startTimeNs, + statusCode: parseStatusCode(raw.status), + traceId: expectedTraceId, + }; +} + +function parseAttributes(value: unknown): Record { + if (!Array.isArray(value)) return {}; + const attributes: Record = {}; + for (const entry of value) { + if (!isRecord(entry) || typeof entry.key !== "string" || !isRecord(entry.value)) continue; + const parsed = parseAnyValue(entry.value); + if (parsed !== undefined) attributes[entry.key] = parsed; + } + return attributes; +} + +function parseAnyValue(value: Record): unknown { + for (const key of ["stringValue", "boolValue", "intValue", "doubleValue"] as const) { + if (key in value) return value[key]; + } + if (isRecord(value.arrayValue) && Array.isArray(value.arrayValue.values)) { + return value.arrayValue.values.filter(isRecord).map((entry) => parseAnyValue(entry)); + } + return undefined; +} + +function parseNanos(value: unknown): bigint | undefined { + if (typeof value !== "string" && typeof value !== "number") return undefined; + try { + const parsed = BigInt(value); + return parsed >= 0n && parsed <= MAX_UINT64 ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseStatusCode(value: unknown): number { + if (!isRecord(value)) return 0; + if (typeof value.code === "number") return value.code; + return value.code === "STATUS_CODE_ERROR" ? 2 : 0; +} + +function firstAttribute( + attributes: readonly Readonly>[], + key: string, +): string | undefined { + for (const values of attributes) { + const value = values[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return undefined; +} + +function firstNumberAttribute( + attributes: readonly Readonly>[], + key: string, +): number | undefined { + for (const values of attributes) { + const value = values[key]; + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + } + return undefined; +} + +function distinctAttributes( + attributes: readonly Readonly>[], + key: string, +): readonly string[] { + const values = new Set(); + for (const entry of attributes) { + const value = entry[key]; + if (typeof value === "string" && value.length > 0) values.add(value); + } + return [...values]; +} + +function stringSpanAttribute(span: LocalTraceSpan, key: string): string | undefined { + const value = span.attributes[key]; + return typeof value === "string" ? value : undefined; +} + +function valueSpanAttribute(span: LocalTraceSpan, key: string): string | number | undefined { + const value = span.attributes[key]; + return typeof value === "string" || typeof value === "number" ? value : undefined; +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}