import { describe, expect, it, vi } from "vitest"; import type { AdapterRuntimeEvent } from "../types.js"; import type { StartupSpan, StartupTracer } from "./startup-timing.js"; import { clampSpanLabel, emitSkippedStartupStep, getActiveStepContext, measureStartupStep, normalizeProviderFamily, runWithoutActiveStep, SANDBOX_STARTUP_SPAN_ATTR_PREFIX, SANDBOX_STARTUP_SPAN_ATTRS, setSandboxRootSpanAttributes, } from "./startup-timing.js"; const A = SANDBOX_STARTUP_SPAN_ATTRS; /** * A recording span for the mock tracer. It captures the attribute set, the * status, and the end count so a test can assert the emitted span shape. */ class MockSpan implements StartupSpan { readonly attributes: Record = {}; status: { code: number; message?: string } | undefined; endCount = 0; constructor( readonly name: string, initial: Record | undefined, ) { if (initial) Object.assign(this.attributes, initial); } setAttribute(key: string, value: string | number | boolean): void { this.attributes[key] = value; } setStatus(status: { code: number; message?: string }): void { this.status = status; } end(): void { this.endCount += 1; } } /** A recording tracer that keeps every span it opens for assertions. */ function makeMockTracer(): { tracer: StartupTracer; spans: MockSpan[] } { const spans: MockSpan[] = []; const tracer: StartupTracer = { startSpan(name, options) { const span = new MockSpan(name, options?.attributes); spans.push(span); return span; }, }; return { tracer, spans }; } describe("measureStartupStep", () => { it("emits one run.startup.step event with the step name and measured durationMs", async () => { let t = 0; const now = () => t; const events: AdapterRuntimeEvent[] = []; const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => { events.push(event); }); const result = await measureStartupStep({ onEvent }, now, "stage.sync", async () => { t = 150; // clock advances while the wrapped step runs return "ok"; }); expect(result).toBe("ok"); expect(onEvent).toHaveBeenCalledTimes(1); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ eventType: "run.startup.step", stream: "system", level: "info", payload: { step: "stage.sync", durationMs: 150 }, }); expect(events[0]!.message).toBe("startup step: stage.sync (150ms)"); }); it("emits only the high-level fields (step, durationMs, outcome) on the payload", async () => { let t = 0; const now = () => t; const events: AdapterRuntimeEvent[] = []; const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => { events.push(event); }); await measureStartupStep({ onEvent }, now, "acp.handshake", async () => { t = 7000; return "handle"; }); // The detailed per-step round-trip and provider-duration numbers ride the // OTel spans now, so the run-log payload keeps only the high-level fields. const payload = events[0]!.payload as Record; expect(Object.keys(payload).sort()).toEqual(["durationMs", "outcome", "step"]); expect(payload).not.toHaveProperty("roundTrips"); expect(payload).not.toHaveProperty("providerExecMs"); expect(payload).not.toHaveProperty("providerGetMs"); expect(payload).not.toHaveProperty("createRuntimeMs"); expect(payload).not.toHaveProperty("ensureSessionMs"); }); it("returns the wrapped fn result unchanged", async () => { const now = () => 0; const onEvent = vi.fn(async () => {}); const value = { nested: [1, 2, 3] }; const result = await measureStartupStep({ onEvent }, now, "workspace.resolve", async () => value); expect(result).toBe(value); }); it("still emits the timing event and re-throws when fn rejects", async () => { let t = 0; const now = () => t; const events: AdapterRuntimeEvent[] = []; const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => { events.push(event); }); const boom = new Error("step failed"); await expect( measureStartupStep({ onEvent }, now, "acp.handshake", async () => { t = 42; throw boom; }), ).rejects.toBe(boom); expect(onEvent).toHaveBeenCalledTimes(1); expect(events[0]).toMatchObject({ eventType: "run.startup.step", payload: { step: "acp.handshake", durationMs: 42 }, }); }); it("swallows onEvent errors without changing the wrapped fn result", async () => { let t = 0; const now = () => t; const onEvent = vi.fn(async () => { throw new Error("sink failed"); }); const result = await measureStartupStep({ onEvent }, now, "bridge.paperclip", async () => { t = 17; return "value"; }); expect(result).toBe("value"); expect(onEvent).toHaveBeenCalledTimes(1); }); it("swallows onEvent errors without replacing a wrapped fn error", async () => { let t = 0; const now = () => t; const onEvent = vi.fn(async () => { throw new Error("sink failed"); }); const boom = new Error("step failed"); await expect( measureStartupStep({ onEvent }, now, "bridge.process-session", async () => { t = 17; throw boom; }), ).rejects.toBe(boom); expect(onEvent).toHaveBeenCalledTimes(1); }); it("does not throw when ctx.onEvent is undefined", async () => { const now = () => 0; await expect( measureStartupStep({}, now, "bridge.paperclip", async () => "value"), ).resolves.toBe("value"); }); it("still surfaces the fn error when ctx.onEvent is undefined", async () => { const now = () => 0; const boom = new Error("undefined-sink failure"); await expect( measureStartupStep({}, now, "bridge.process-session", async () => { throw boom; }), ).rejects.toBe(boom); }); it("opens one span and ends it once for a normal step", async () => { const { tracer, spans } = makeMockTracer(); const onEvent = vi.fn(async () => {}); await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", { tracer, }); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe("stage.sync"); // The span name carries the step; no redundant `step` attribute rides it. expect(spans[0]!.attributes).not.toHaveProperty("step"); // The step wall time rides the closed, type-suffixed attribute key. expect(spans[0]!.attributes[A.stepWallMs]).toBe(0); expect(spans[0]!.endCount).toBe(1); }); it("ends the span and sets an error status when fn throws, then re-throws", async () => { const { tracer, spans } = makeMockTracer(); const onEvent = vi.fn(async () => {}); const boom = new Error("step failed"); await expect( measureStartupStep({ onEvent }, () => 0, "acp.handshake", async () => { throw boom; }, { tracer }), ).rejects.toBe(boom); expect(spans).toHaveLength(1); expect(spans[0]!.endCount).toBe(1); // SpanStatusCode.ERROR === 2 in @opentelemetry/api. expect(spans[0]!.status?.code).toBe(2); }); it("carries only the allowlisted attributes on the step span, never the removed round-trip detail", async () => { const events: AdapterRuntimeEvent[] = []; const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => { events.push(event); }); const { tracer, spans } = makeMockTracer(); await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", { tracer, provider: "daytona", }); // The step span carries only the closed allowlist. The per-execution // `sandbox.exec` spans carry the round-trip and provider-duration detail. expect(Object.keys(spans[0]!.attributes).sort()).toEqual( [A.provider, A.stepWallMs, A.outcome].sort(), ); const payload = events[0]!.payload as Record; expect(payload).not.toHaveProperty("roundTrips"); expect(payload).not.toHaveProperty("providerExecMs"); expect(payload).not.toHaveProperty("providerGetMs"); }); it("normalizes a plugin-backed provider key to plugin and keeps a built-in family as-is", async () => { const onEvent = vi.fn(async () => {}); const custom = makeMockTracer(); await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", { tracer: custom.tracer, provider: "acme-cloud-runner", }); expect(custom.spans[0]!.attributes[A.provider]).toBe("plugin"); const builtIn = makeMockTracer(); await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok", { tracer: builtIn.tracer, provider: "daytona", }); expect(builtIn.spans[0]!.attributes[A.provider]).toBe("daytona"); }); it("normalizeProviderFamily maps every non-built-in key to plugin", () => { for (const key of ["daytona", "kubernetes", "e2b", "cloudflare", "exe-dev", "modal", "novita"]) { expect(normalizeProviderFamily(key)).toBe(key); } for (const key of ["acme", "my-plugin", "", "DAYTONA", undefined]) { expect(normalizeProviderFamily(key)).toBe("plugin"); } }); it("emits exactly the allowlisted span-attribute key set and no command / path / ID / error-text key", async () => { const onEvent = vi.fn(async () => {}); const { tracer, spans } = makeMockTracer(); await measureStartupStep({ onEvent }, () => 0, "acp.handshake", async () => "ok", { tracer, provider: "daytona", }); expect(Object.keys(spans[0]!.attributes).sort()).toEqual( [A.provider, A.stepWallMs, A.outcome].sort(), ); // Every key uses the closed prefix, so no free-form command / path / id key // can ride the span. for (const key of Object.keys(spans[0]!.attributes)) { expect(key.startsWith(SANDBOX_STARTUP_SPAN_ATTR_PREFIX)).toBe(true); } // No forbidden segment (command / arg / env / output / path / raw id) rides // the span key set, even after the prefix. for (const key of Object.keys(spans[0]!.attributes)) { const suffix = key.slice(SANDBOX_STARTUP_SPAN_ATTR_PREFIX.length); expect(suffix).not.toMatch(/command|args|env|stdout|stderr|path|url|repo|ref|branch|error|message/); } }); it("uses a no-op tracer by default so a call without a tracer changes nothing", async () => { const events: AdapterRuntimeEvent[] = []; const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => { events.push(event); }); // No tracer supplied. The helper must still emit the event and return the // value without throwing. const result = await measureStartupStep({ onEvent }, () => 0, "stage.sync", async () => "ok"); expect(result).toBe("ok"); expect(events[0]!.payload).toMatchObject({ step: "stage.sync" }); }); it("sets a batch tag on the span from the batch option", async () => { const { tracer, spans } = makeMockTracer(); await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "bridge.paperclip", async () => "ok", { tracer, batch: "bridge", }); expect(spans[0]!.attributes[A.batch]).toBe("bridge"); }); it("maps handshake sub-times to fixed span keys and skips a non-finite one", async () => { const { tracer, spans } = makeMockTracer(); await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "acp.handshake", async () => "ok", { tracer, spanWallTimes: () => ({ createRuntime: 12, ensureSession: 6988 }), }); expect(spans[0]!.attributes[A.handshakeCreateRuntimeWallMs]).toBe(12); expect(spans[0]!.attributes[A.handshakeEnsureSessionWallMs]).toBe(6988); // A retry reports only its own ensure-session sub-time; the absent create- // runtime value sets no attribute. const retry = makeMockTracer(); await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "acp.handshake", async () => "ok", { tracer: retry.tracer, spanWallTimes: () => ({ ensureSession: 40 }), }); expect(retry.spans[0]!.attributes[A.handshakeEnsureSessionWallMs]).toBe(40); expect(retry.spans[0]!.attributes).not.toHaveProperty(A.handshakeCreateRuntimeWallMs); }); it("sets outcome = ok on a settled step and outcome = failed on a throwing step", async () => { const okEvents: AdapterRuntimeEvent[] = []; const ok = makeMockTracer(); await measureStartupStep({ onEvent: vi.fn(async (e: AdapterRuntimeEvent) => { okEvents.push(e); }) }, () => 0, "stage.sync", async () => "ok", { tracer: ok.tracer }); expect(ok.spans[0]!.attributes[A.outcome]).toBe("ok"); expect((okEvents[0]!.payload as Record).outcome).toBe("ok"); const failEvents: AdapterRuntimeEvent[] = []; const fail = makeMockTracer(); await expect( measureStartupStep({ onEvent: vi.fn(async (e: AdapterRuntimeEvent) => { failEvents.push(e); }) }, () => 0, "acp.handshake", async () => { throw new Error("boom"); }, { tracer: fail.tracer }), ).rejects.toThrow("boom"); expect(fail.spans[0]!.attributes[A.outcome]).toBe("failed"); expect((failEvents[0]!.payload as Record).outcome).toBe("failed"); }); it("sets each step-span attribute key with the closed prefix and a type suffix", async () => { const { tracer, spans } = makeMockTracer(); await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "stage.sync", async () => "ok", { tracer, provider: "daytona", }); const keys = Object.keys(spans[0]!.attributes); expect(keys.length).toBeGreaterThan(0); for (const key of keys) { expect(key.startsWith(SANDBOX_STARTUP_SPAN_ATTR_PREFIX)).toBe(true); } // The step wall time uses its type suffix. expect(keys).toContain(A.stepWallMs); expect(A.stepWallMs.endsWith(".wall_ms")).toBe(true); }); }); describe("setSandboxRootSpanAttributes", () => { it("records wall / work / diff and bounds the context, hashing ids and image", () => { const span = new MockSpan("sandbox.startup", undefined); setSandboxRootSpanAttributes(span, { wallMs: 800, workMs: 1000 }, { coldStart: true, provider: "acme-custom-runner", region: "us-east-1", imageId: "registry.internal/team/secret-codename:sha-1234", sandboxId: "sbx-secret-internal-id", leaseId: "lease-secret-internal-id", }); expect(span.attributes[A.rootWallMs]).toBe(800); expect(span.attributes[A.rootWorkMs]).toBe(1000); expect(span.attributes[A.rootDiffMs]).toBe(200); expect(span.attributes[A.coldStart]).toBe(true); // Provider clamps to the bounded family; region stays a known value. expect(span.attributes[A.provider]).toBe("plugin"); expect(span.attributes[A.region]).toBe("us-east-1"); // The image id and both ids ride only as non-reversible hashes. for (const key of [A.imageId, A.sandboxId, A.leaseId]) { expect(String(span.attributes[key])).toMatch(/^[0-9a-f]{12}$/); } for (const value of Object.values(span.attributes).map(String)) { expect(value).not.toContain("secret"); expect(value).not.toContain("codename"); expect(value).not.toContain("registry.internal"); } }); it("maps an unknown region to `unknown` and omits every absent context value", () => { const span = new MockSpan("sandbox.startup", undefined); setSandboxRootSpanAttributes(span, { wallMs: 5, workMs: 5 }, { region: "moon-base-1" }); expect(span.attributes[A.region]).toBe("unknown"); expect(span.attributes).not.toHaveProperty(A.coldStart); expect(span.attributes).not.toHaveProperty(A.provider); expect(span.attributes).not.toHaveProperty(A.imageId); expect(span.attributes).not.toHaveProperty(A.sandboxId); expect(span.attributes).not.toHaveProperty(A.leaseId); }); }); describe("emitSkippedStartupStep", () => { it("emits a span and event with outcome = skipped and a zero wall time", async () => { const { tracer, spans } = makeMockTracer(); const events: AdapterRuntimeEvent[] = []; const onEvent = vi.fn(async (event: AdapterRuntimeEvent) => { events.push(event); }); await emitSkippedStartupStep({ onEvent }, "acp.handshake", { tracer }); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe("acp.handshake"); expect(spans[0]!.attributes[A.stepWallMs]).toBe(0); expect(spans[0]!.attributes[A.outcome]).toBe("skipped"); expect(spans[0]!.endCount).toBe(1); expect(events).toHaveLength(1); expect(events[0]!.payload).toMatchObject({ step: "acp.handshake", durationMs: 0, outcome: "skipped", }); }); it("emits the event with no injected tracer and does not throw", async () => { const events: AdapterRuntimeEvent[] = []; await emitSkippedStartupStep( { onEvent: vi.fn(async (e: AdapterRuntimeEvent) => { events.push(e); }) }, "acp.handshake", ); expect(events[0]!.payload).toMatchObject({ step: "acp.handshake", outcome: "skipped" }); }); }); describe("getActiveStepContext", () => { it("returns null when no measured step runs", () => { expect(getActiveStepContext()).toBeNull(); }); it("exposes the active step context to inner code while fn runs, then clears it", async () => { const { tracer, spans } = makeMockTracer(); let seen: ReturnType = null; await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "stage.sync", async () => { // Inner code reads the active step context through the getter. seen = getActiveStepContext(); return "ok"; }, { tracer, // The server builds a child-context token whose active span is the step // span. Model it as `{ span }`, the same shape the recording tracer reads. contextWithSpan: (span) => ({ span }), }); expect(seen).not.toBeNull(); // The published span is the one open step span. expect(seen!.span).toBe(spans[0]); // The parent token points at the step span, so an inner exec span parents // to it. expect(seen!.parentContext).toEqual({ span: spans[0] }); // A regular step is on the critical path by default. expect(seen!.criticalPath).toBe(true); // The context clears once the step body settles. expect(getActiveStepContext()).toBeNull(); }); it("carries criticalPath = false when the step opts out (parallel steps)", async () => { let seen: ReturnType = null; await measureStartupStep({ onEvent: vi.fn(async () => {}) }, () => 0, "bridge.paperclip", async () => { seen = getActiveStepContext(); }, { criticalPath: false }); expect(seen!.criticalPath).toBe(false); }); it("keeps the ended step store in a timer scheduled inside the step body", async () => { // Node snapshots the active store on each async resource at creation time. // A timer scheduled inside a measured step body keeps that step store, even // after the step span ends. A later run-time exec then reads the ended step // and parents its `sandbox.exec` span to a dead startup step. This test locks // that leak, so the fix and its guard test stay honest. let storeInTimer: ReturnType = null; let fireTimer!: () => void; const timerRan = new Promise((resolve) => { fireTimer = resolve; }); await measureStartupStep( { onEvent: vi.fn(async () => {}) }, () => 0, "bridge.process-session", async () => { setTimeout(() => { storeInTimer = getActiveStepContext(); fireTimer(); }, 0); return "started"; }, { criticalPath: false }, ); // The step span already ended, so the main line reads no active step. expect(getActiveStepContext()).toBeNull(); await timerRan; // Yet the timer callback still reads the ended step context. This is the // store leak the bridge boundary fix removes. expect(storeInTimer).not.toBeNull(); expect(storeInTimer!.criticalPath).toBe(false); }); it("clears the active step for a continuation wrapped in runWithoutActiveStep", async () => { // The bridge boundary wraps its long-lived poll timer in `runWithoutActiveStep`. // A timer scheduled inside that empty store scope reads no active step, so a // later run-time exec opens an unparented span instead of one under the ended // startup step. let storeInTimer: ReturnType = null; let sawTimer = false; let fireTimer!: () => void; const timerRan = new Promise((resolve) => { fireTimer = resolve; }); await measureStartupStep( { onEvent: vi.fn(async () => {}) }, () => 0, "bridge.process-session", async () => { runWithoutActiveStep(() => { setTimeout(() => { storeInTimer = getActiveStepContext(); sawTimer = true; fireTimer(); }, 0); }); return "started"; }, { criticalPath: false }, ); await timerRan; expect(sawTimer).toBe(true); expect(storeInTimer).toBeNull(); }); it("returns the work result and restores the previous active step", async () => { // Outside any measured step the previous store is empty, so the helper both // returns the work value and leaves the store empty afterward. const value = runWithoutActiveStep(() => "value"); expect(value).toBe("value"); expect(getActiveStepContext()).toBeNull(); }); }); describe("clampSpanLabel", () => { it("returns a known command label unchanged and maps an unknown command to `other`", () => { expect(clampSpanLabel("command", "sh")).toBe("sh"); expect(clampSpanLabel("command", "git")).toBe("git"); // A full command line, a path, or a secret-like argument is not a known // basename, so it maps to the bounded fallback and never leaks. expect(clampSpanLabel("command", "bash -lc 'rm -rf /secret/path'")).toBe("other"); expect(clampSpanLabel("command", "/usr/local/bin/node")).toBe("other"); expect(clampSpanLabel("command", undefined)).toBe("other"); }); it("returns a known region unchanged and maps an unknown region to `unknown`", () => { expect(clampSpanLabel("region", "us-east-1")).toBe("us-east-1"); expect(clampSpanLabel("region", "moon-base-1")).toBe("unknown"); expect(clampSpanLabel("region", undefined)).toBe("unknown"); }); it("hashes an id or image label to a non-reversible short digest and never returns the raw value", () => { const raw = "lease-super-secret-internal-codename"; for (const label of ["image_id", "sandbox_id", "lease_id"]) { const clamped = clampSpanLabel(label, raw); expect(clamped).toBeTruthy(); expect(clamped).not.toBe(raw); expect(clamped).not.toContain("secret"); expect(clamped).not.toContain("codename"); // A stable 12-hex-character digest prefix. expect(clamped).toMatch(/^[0-9a-f]{12}$/); } // A missing id yields no attribute (fail open — never a raw value). expect(clampSpanLabel("sandbox_id", undefined)).toBeUndefined(); expect(clampSpanLabel("sandbox_id", "")).toBeUndefined(); }); it("drops an unknown label name so the caller sets no attribute for it", () => { expect(clampSpanLabel("nonsense", "anything")).toBeUndefined(); expect(clampSpanLabel("stdout", "secret output")).toBeUndefined(); }); });