-
Notifications
You must be signed in to change notification settings - Fork 74
feat(dev): OTLP trace storage for local dev #2043
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
dffe4ee
feat(dev): OTLP trace storage for local dev
tejaskash fad9c3b
fix(dev): match trace filters against every participating service
tejaskash 4035b7b
fix(dev): address review on OTLP trace storage
tejaskash 5074354
fix(dev): flatten kvlist-valued OTLP attributes instead of dropping them
tejaskash File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | ||
| import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { TraceStore } from "./store"; | ||
| import type { OtlpPayload } from "./types"; | ||
|
|
||
| const TRACE_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; | ||
| const TRACE_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; | ||
|
|
||
| function payload( | ||
| traceId: string, | ||
| options: { serviceName?: string; startNano?: string; name?: string } = {}, | ||
| ): OtlpPayload { | ||
| return { | ||
| resourceSpans: [ | ||
| { | ||
| resource: { | ||
| attributes: [ | ||
| { key: "service.name", value: { stringValue: options.serviceName ?? "agent-1" } }, | ||
| ], | ||
| }, | ||
| scopeSpans: [ | ||
| { | ||
| scope: { name: "test" }, | ||
| spans: [ | ||
| { | ||
| traceId, | ||
| spanId: "0123456789abcdef", | ||
| name: options.name ?? "invoke_agent strands", | ||
| kind: 1, | ||
| startTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, | ||
| endTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| let directory: string; | ||
| let store: TraceStore; | ||
|
|
||
| beforeEach(async () => { | ||
| directory = await mkdtemp(join(tmpdir(), "trace-store-")); | ||
| store = new TraceStore(directory); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await rm(directory, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe("TraceStore", () => { | ||
| test("append then list returns the trace with metadata", async () => { | ||
| await store.append(payload(TRACE_A)); | ||
| const traces = await store.list(); | ||
| expect(traces).toHaveLength(1); | ||
| expect(traces[0]!.traceId).toBe(TRACE_A); | ||
| expect(traces[0]!.spanCount).toBe("1"); | ||
| expect(traces[0]!.resourceSpans).toBeDefined(); | ||
| }); | ||
|
|
||
| test("appends to the same trace accumulate spans", async () => { | ||
| await store.append(payload(TRACE_A)); | ||
| await store.append(payload(TRACE_A, { name: "tool_use" })); | ||
| const traces = await store.list(); | ||
| expect(traces).toHaveLength(1); | ||
| expect(traces[0]!.spanCount).toBe("2"); | ||
| }); | ||
|
|
||
| test("payloads without a trace id are dropped", async () => { | ||
| await store.append({ resourceSpans: [] }); | ||
| expect(await store.list()).toEqual([]); | ||
| }); | ||
|
|
||
| test("a batch carrying several traces lands in each trace's own file", async () => { | ||
| const batch = payload(TRACE_A); | ||
| batch.resourceSpans![0]!.scopeSpans![0]!.spans!.push({ | ||
| ...batch.resourceSpans![0]!.scopeSpans![0]!.spans![0]!, | ||
| traceId: TRACE_B, | ||
| name: "tool_use", | ||
| }); | ||
| await store.append(batch); | ||
|
|
||
| const traces = await store.list(); | ||
| expect(traces.map((trace) => trace.traceId).sort()).toEqual([TRACE_A, TRACE_B]); | ||
| expect(traces.every((trace) => trace.spanCount === "1")).toBe(true); | ||
| expect(await store.get(TRACE_B)).toBeDefined(); | ||
| }); | ||
|
|
||
| test("list filters by service name, matching every participant of a distributed trace", async () => { | ||
| await store.append(payload(TRACE_A, { serviceName: "agent-1" })); | ||
| // agent-2 contributes spans to the SAME trace (distributed) and owns its own trace. | ||
| await store.append(payload(TRACE_A, { serviceName: "agent-2", name: "tool_use" })); | ||
| await store.append(payload(TRACE_B, { serviceName: "agent-2" })); | ||
|
|
||
| expect((await store.list({ serviceName: "agent-2" })).map((t) => t.traceId).sort()).toEqual([ | ||
| TRACE_A, | ||
| TRACE_B, | ||
| ]); | ||
| expect((await store.list({ serviceName: "agent-1" })).map((t) => t.traceId)).toEqual([TRACE_A]); | ||
| expect(await store.list({ serviceName: "agent-3" })).toEqual([]); | ||
| }); | ||
|
|
||
| test("list filters by time window, sorts newest first, and caps to limit", async () => { | ||
| const oldNano = `${BigInt(Date.now() - 24 * 60 * 60 * 1000) * 1_000_000n}`; | ||
| await store.append(payload(TRACE_A, { startNano: oldNano })); | ||
| await store.append(payload(TRACE_B)); | ||
|
|
||
| expect((await store.list()).map((trace) => trace.traceId)).toEqual([TRACE_B]); | ||
|
|
||
| const all = await store.list({ startTime: 0 }); | ||
| expect(all.map((trace) => trace.traceId)).toEqual([TRACE_B, TRACE_A]); | ||
|
|
||
| // limit keeps the newest N after sorting. | ||
| expect((await store.list({ startTime: 0, limit: 1 })).map((trace) => trace.traceId)).toEqual([ | ||
| TRACE_B, | ||
| ]); | ||
| }); | ||
|
|
||
| test("get merges spans across appends and is undefined for unknown ids", async () => { | ||
| await store.append(payload(TRACE_A)); | ||
| await store.append(payload(TRACE_A, { name: "tool_use" })); | ||
|
|
||
| const detail = await store.get(TRACE_A); | ||
| const spans = (detail!.resourceSpans as { scopeSpans: { spans: { name: string }[] }[] }[]) | ||
| .flatMap((resourceSpan) => resourceSpan.scopeSpans) | ||
| .flatMap((scopeSpan) => scopeSpan.spans); | ||
| expect(spans.map((span) => span.name).sort()).toEqual(["invoke_agent strands", "tool_use"]); | ||
|
|
||
| expect(await store.get(TRACE_B)).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("skips malformed lines and files without failing", async () => { | ||
| await store.append(payload(TRACE_A)); | ||
| await writeFile(join(directory, `${TRACE_A}.otlp.jsonl`), "{not json}\n", { | ||
| flag: "a", | ||
| }); | ||
| await writeFile(join(directory, "garbage.otlp.jsonl"), "also not json\n"); | ||
|
|
||
| const traces = await store.list(); | ||
| expect(traces).toHaveLength(1); | ||
| expect(traces[0]!.spanCount).toBe("1"); | ||
| }); | ||
|
|
||
| test("list on a directory that does not exist returns empty", async () => { | ||
| const empty = new TraceStore(join(directory, "missing")); | ||
| expect(await empty.list()).toEqual([]); | ||
| expect(await empty.get(TRACE_A)).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("non-ENOENT fs errors bubble up rather than reading as empty", async () => { | ||
| // readdir on a path that is a file, not a directory -> ENOTDIR must throw. | ||
| const asFile = join(directory, "file"); | ||
| await writeFile(asFile, "x"); | ||
| expect(new TraceStore(asFile).list()).rejects.toThrow(); | ||
|
|
||
| // readFile on a trace path that is a directory -> EISDIR must throw. | ||
| await mkdir(join(directory, `${TRACE_A}.otlp.jsonl`)); | ||
| expect(store.list()).rejects.toThrow(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { buildTraceDetail, extractTraceMeta, partitionByTraceId } from "./transforms"; | ||
| import type { OtlpPayload, OtlpResourceLog, OtlpResourceSpan } from "./types"; | ||
|
|
||
| const OTLP_EXT = ".otlp.jsonl"; | ||
| const DEFAULT_LIST_WINDOW_MS = 12 * 60 * 60 * 1000; | ||
|
|
||
| export interface TraceSummary { | ||
| traceId: string; | ||
| timestamp: string; | ||
| sessionId?: string; | ||
| spanCount: string; | ||
| resourceSpans?: unknown[]; | ||
| resourceLogs?: unknown[]; | ||
| } | ||
|
|
||
| export interface TraceDetail { | ||
| resourceSpans?: unknown[]; | ||
| resourceLogs?: unknown[]; | ||
| } | ||
|
|
||
| export interface ListTracesOptions { | ||
| serviceName?: string; | ||
| startTime?: number; | ||
| endTime?: number; | ||
| /** Keep only the newest N traces — the inspector re-polls this on every invocation. */ | ||
| limit?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Append-only local trace storage: one JSON Lines file per trace (named by its | ||
| * trace id), each line a per-trace slice of an OTLP export payload. No in-memory | ||
| * state — reads go to disk on demand, which is fine because the inspector only | ||
| * fetches traces on user actions. Malformed files and lines are skipped, never fatal. | ||
| */ | ||
| export class TraceStore { | ||
|
tejaskash marked this conversation as resolved.
|
||
| constructor(private readonly directory: string) {} | ||
|
|
||
| /** | ||
| * Persist one OTLP export payload, partitioned by trace id so a batch that | ||
| * carries several traces lands in each trace's own file. Spans and log | ||
| * records without a trace id are dropped. | ||
| */ | ||
| public async append(payload: OtlpPayload): Promise<void> { | ||
|
notgitika marked this conversation as resolved.
|
||
| const partitions = partitionByTraceId(payload); | ||
| if (partitions.size === 0) return; | ||
|
|
||
| await mkdir(this.directory, { recursive: true }); | ||
| await Promise.all( | ||
| [...partitions].map(([traceId, partition]) => | ||
| appendFile( | ||
| join(this.directory, `${sanitize(traceId)}${OTLP_EXT}`), | ||
| JSON.stringify(partition) + "\n", | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| /** List traces newest-first, filtered by service name and time range (default: last 12 hours). */ | ||
| public async list(options: ListTracesOptions = {}): Promise<TraceSummary[]> { | ||
| const now = Date.now(); | ||
| const start = options.startTime ?? now - DEFAULT_LIST_WINDOW_MS; | ||
| const end = options.endTime ?? now; | ||
|
|
||
| const summaries: TraceSummary[] = []; | ||
| for (const file of await this.traceFiles()) { | ||
| const trace = await this.readTraceFile(file); | ||
| if (!trace) continue; | ||
|
|
||
| const meta = extractTraceMeta(trace.resourceSpans, trace.resourceLogs); | ||
| // No id means every line failed to parse (empty/corrupt file), not a real trace. | ||
| if (!meta.traceId) continue; | ||
|
tejaskash marked this conversation as resolved.
|
||
| if (meta.lastSeen < start || meta.firstSeen > end) continue; | ||
| if (options.serviceName && !meta.serviceNames.includes(options.serviceName)) continue; | ||
|
|
||
| const detail = buildTraceDetail(trace.resourceSpans, trace.resourceLogs); | ||
| summaries.push({ | ||
| traceId: meta.traceId, | ||
| timestamp: new Date(meta.lastSeen).toISOString(), | ||
| sessionId: meta.sessionId, | ||
| // Count the spans the UI actually renders (post noise-filter), not raw records. | ||
| spanCount: String(countRenderedSpans(detail.resourceSpans)), | ||
| ...detail, | ||
| }); | ||
| } | ||
|
|
||
| summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); | ||
| return options.limit === undefined ? summaries : summaries.slice(0, options.limit); | ||
| } | ||
|
|
||
| /** All spans and logs for one trace, or undefined when the trace is unknown. */ | ||
| public async get(traceId: string): Promise<TraceDetail | undefined> { | ||
| const trace = await this.readTraceFile(`${sanitize(traceId)}${OTLP_EXT}`); | ||
| if (!trace) return undefined; | ||
| return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); | ||
| } | ||
|
|
||
| private async traceFiles(): Promise<string[]> { | ||
| try { | ||
| return (await readdir(this.directory)).filter((file) => file.endsWith(OTLP_EXT)); | ||
| } catch (error) { | ||
| if (isNotFound(error)) return []; // No traces persisted yet — the dir is created on first append. | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| private async readTraceFile( | ||
| fileName: string, | ||
| ): Promise<{ resourceSpans: OtlpResourceSpan[]; resourceLogs: OtlpResourceLog[] } | undefined> { | ||
| let content: string; | ||
| try { | ||
| content = await readFile(join(this.directory, fileName), "utf8"); | ||
| } catch (error) { | ||
| // Unknown trace (get) or a file removed between listing and read; any other | ||
| // fault (permissions, bad path) is real and must not read as "no trace". | ||
| if (isNotFound(error)) return undefined; | ||
| throw error; | ||
| } | ||
|
|
||
| const resourceSpans: OtlpResourceSpan[] = []; | ||
| const resourceLogs: OtlpResourceLog[] = []; | ||
| for (const line of content.split("\n")) { | ||
| if (!line.trim()) continue; | ||
| try { | ||
| const payload = JSON.parse(line) as OtlpPayload; | ||
| if (payload.resourceSpans) resourceSpans.push(...payload.resourceSpans); | ||
| if (payload.resourceLogs) resourceLogs.push(...payload.resourceLogs); | ||
| } catch { | ||
| // Skip malformed lines — a partially written line must not break reads. | ||
|
tejaskash marked this conversation as resolved.
|
||
| } | ||
| } | ||
| return { resourceSpans, resourceLogs }; | ||
| } | ||
| } | ||
|
|
||
| function sanitize(value: string): string { | ||
| return value.replace(/[^a-zA-Z0-9_-]/g, "_"); | ||
| } | ||
|
|
||
| /** Number of spans in a built trace detail — what the inspector's waterfall shows. */ | ||
| function countRenderedSpans(resourceSpans: TraceDetail["resourceSpans"]): number { | ||
| let count = 0; | ||
| for (const resourceSpan of (resourceSpans ?? []) as OtlpResourceSpan[]) { | ||
| for (const scopeSpan of resourceSpan.scopeSpans ?? []) count += scopeSpan.spans?.length ?? 0; | ||
| } | ||
| return count; | ||
| } | ||
|
|
||
| /** A missing directory or file — the only fs error reads should treat as "empty". */ | ||
| function isNotFound(error: unknown): boolean { | ||
| return (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.