mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip manages work performed by AI agents. > - Native execution crosses the plugin worker, Runner, sidecar, and durable event boundaries. > - Existing outer spans leave repeated preparation and admission work unattributed. > - Optimizations need a common baseline with accurate ownership and event meanings. > - This change extends the existing opt-in tracing contract with bounded timing receipts. > - It preserves execution policy and keeps provider internals opaque where the API provides no timing proof. ## Linked Issues or Issue Description **What existing behavior does this improve?** Attribution of native task preparation, retained/cold Runner handoff, and completion-to-text work. **Current behavior** Worker spans use a flat provider contract. Workspace calls lack a consistent dispatch envelope. Submitted-to-started timing is named as a provider queue even though it includes other work. The transport does not expose a shared breakdown of its waits. **Proposed improvement** Add invocation-scoped nesting, bounded numeric remote receipts, current-run transport timing, durable receipt intervals, and distinct completion/text milestones. Reuse the existing exporter. No latency optimization is included. Related public work: #17842 consolidates existing startup tracing tests; it does not add this runtime coverage. ## What Changed - Extend the optional SDK tracer with nested async scopes and completed timing receipts. Keep the host responsible for parent identity, capability checks, attributes, clocks, depth, and count limits. - Add dispatch and bounded host-read timings for environment and workspace providers. - Resolve transport parents on each call so retained sessions do not retain an earlier run's trace. - Split cold/resume/attach/prepare/registration/command/started-event waits. Add opt-in numeric ACPX Runner/sidecar status and prompt-start receipts. - Distinguish first completion request, validated tool receipt, final result commit, first nonempty text, and event persistence. Rename the submitted-to-started interval to match its meaning. - Link completed earlier remote lifecycles to their host-owned observer rather than placing them under a later polling request. Preserve sampling, timestamp limits and budgets. - Document the versioned coverage map, causal clock limits, and baseline acceptance requirements. ## Verification - Current head: GitHub CI is fully green. Standard Greptile and Apex are both 5/5, with no unresolved review threads. Full-suite validation was performed in CI; follow-up local validation was limited to affected packages and tests. - Review follow-up: early rejection, missing provider capability and startup-parent regressions were reproduced and fixed. The affected worker/SDK/timing set passes 128 tests; 28 timing/context tests, SDK typecheck and direct server TypeScript checking pass. The server typecheck wrapper reached an unrelated Runner rebuild and stopped because Cargo was absent from PATH; full Runner/image validation remains in CI. - CI failure accounting: the earlier Turbo cache/force conflict and Linux OpenCode shell assertion were reproduced and diagnosed. A direct proc probe ruled out permissions; a portable shell comparison identified Bash skipping BASH_ENV with stripped SHLVL and socket stdin. Upstream #18230 now supplies the CI and ignored-stdin fixture fixes; this PR adopts them without duplicate CI changes. The strict Linux OOM assertion passed before this source-equivalent merge. - Retry follow-up: the three insertion/replay/failure timing cases and server typecheck pass. The existing database regression now asserts a single marker across identical result replay; the current-head CI integration gate passes. - Upstream CI alignment: all three real-Turbo 2.11.10 integration cases pass. The repository policy gate rejected committing a generated lockfile; the final PR contains no lockfile edit and uses the maintained inline stale-lockfile recovery. - Linked-lifecycle follow-up: 44 focused host/export tests pass (three optional SDK-dependent cases skip); SDK and server typechecks pass. The API contract test verifies an explicit root context, host-owned observer link and unsampled suppression. - SDK typecheck and focused worker/invocation tests: 30 passed, including clock changes, bounded legacy IPC, and manual-span creation-scope parentage. - Focused reviewed host/native/transaction-boundary checks: 44 passed. The 86-test worker-manager suite passes after updating its exact assertions for the host-owned timing budget found by CI. Earlier preparation/first-text checks also passed. - Affected server and Runner TypeScript checks pass after building local workspace declarations. - Merge follow-up: the profile/candidate startup subset passes 94 tests, the four Grok source-identity checks pass, and Runner TypeScript checking passes. A subsequent execution-factory merge passes 21 focused timing/trace tests and server TypeScript checking. - Pinned Rust build and the ACPX Rust subset pass. The hostile receipt projection test passes. Source profile compatibility checks: 127 passed, one existing skip. Grok identity checks: four passed. - Broad transport/ACPX suite: 410 passed after building all fake provider binaries. Earlier attempts failed on missing Runner/fake-provider outputs; building the required fixtures resolved those failures. - The first CI run correctly rejected stale source attestations. After integrating current master, Grok advances 13→14 and Copilot 84→85; master’s historical fixtures and Pi 35 stay byte-identical. Provider dependencies, models and permission defaults are unchanged. Identity generation/checks now pass. - The added real-Postgres result lock regression could not initialize its local embedded cluster. The insertion/replay/failure unit regressions pass. The current-head CI integration gate passes. - Comprehensive repository checks and image builds are delegated to CI, as requested for this instrumentation pass. No deployment, model run, collector-ingestion proof, or latency improvement is claimed. ## Risks The new SDK methods are optional. Legacy span calls and endpoint-off behavior remain supported. The host accepts only closed timing names and bounded attributes. Remote durations are anchored at local receipt; transport/scheduling uncertainty remains explicit. Trace trees have count/depth limits. The first rollout must inspect actual collector exports before treating the baseline as accepted. Fresh live qualification for the new source identities remains a rollout requirement. Revert these commits to remove the new measurements; no schema migration is required. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and local code/tool execution. The exact runtime model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the enhancement template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal issue details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Standard Greptile and Apex are both 5/5, with no unresolved findings - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
451 lines
19 KiB
TypeScript
451 lines
19 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createRequire } from "node:module";
|
|
|
|
/**
|
|
* Tests for the opt-in OpenTelemetry bootstrap. The @opentelemetry/* packages
|
|
* are optional peer dependencies and are NOT installed in CI, which is itself
|
|
* part of the contract under test: with the endpoint set but packages absent,
|
|
* the module must warn and settle instead of crashing the server.
|
|
*
|
|
* The module reads OTEL_* env vars at import time, so each test resets the
|
|
* module registry and imports a fresh copy.
|
|
*/
|
|
|
|
const ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT";
|
|
const PROTOCOL_ENV = "OTEL_EXPORTER_OTLP_PROTOCOL";
|
|
|
|
const originalEndpoint = process.env[ENDPOINT_ENV];
|
|
const originalProtocol = process.env[PROTOCOL_ENV];
|
|
|
|
async function importFreshInstrumentation() {
|
|
vi.resetModules();
|
|
return await import("../instrumentation.js");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
delete process.env[ENDPOINT_ENV];
|
|
delete process.env[PROTOCOL_ENV];
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalEndpoint === undefined) delete process.env[ENDPOINT_ENV];
|
|
else process.env[ENDPOINT_ENV] = originalEndpoint;
|
|
if (originalProtocol === undefined) delete process.env[PROTOCOL_ENV];
|
|
else process.env[PROTOCOL_ENV] = originalProtocol;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("resolveProtocol", () => {
|
|
it.each([
|
|
[undefined, "grpc", "@opentelemetry/exporter-trace-otlp-grpc"],
|
|
["", "grpc", "@opentelemetry/exporter-trace-otlp-grpc"],
|
|
["grpc", "grpc", "@opentelemetry/exporter-trace-otlp-grpc"],
|
|
["http/protobuf", "http/protobuf", "@opentelemetry/exporter-trace-otlp-proto"],
|
|
["http/json", "http/json", "@opentelemetry/exporter-trace-otlp-http"],
|
|
["HTTP/JSON", "http/json", "@opentelemetry/exporter-trace-otlp-http"],
|
|
// Surrounding space is trimmed before the switch, not only letter case.
|
|
[" HTTP/PROTOBUF ", "http/protobuf", "@opentelemetry/exporter-trace-otlp-proto"],
|
|
])("maps OTEL_EXPORTER_OTLP_PROTOCOL=%s to %s", async (raw, protocol, packageName) => {
|
|
if (raw === undefined) delete process.env[PROTOCOL_ENV];
|
|
else process.env[PROTOCOL_ENV] = raw;
|
|
|
|
const { resolveProtocol } = await importFreshInstrumentation();
|
|
|
|
expect(resolveProtocol()).toEqual({ protocol, packageName });
|
|
});
|
|
|
|
it("warns and falls back to grpc on an unrecognized protocol", async () => {
|
|
process.env[PROTOCOL_ENV] = "carrier-pigeon";
|
|
// Spy before the import so the assertion holds even if a future change
|
|
// makes the warning fire at module load time instead of on the call.
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const { resolveProtocol } = await importFreshInstrumentation();
|
|
|
|
// The fallback must also select the grpc exporter package, not just a
|
|
// grpc-labeled protocol.
|
|
expect(resolveProtocol()).toEqual({
|
|
protocol: "grpc",
|
|
packageName: "@opentelemetry/exporter-trace-otlp-grpc",
|
|
});
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("carrier-pigeon"));
|
|
// One unknown input logs exactly one warning, not one per lookup.
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("instrumentationReady", () => {
|
|
it("resolves immediately when OTEL_EXPORTER_OTLP_ENDPOINT is unset", async () => {
|
|
const { instrumentationReady } = await importFreshInstrumentation();
|
|
|
|
await expect(instrumentationReady).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("settles with a diagnostic instead of throwing when the endpoint is set but packages are missing", async () => {
|
|
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const { instrumentationReady } = await importFreshInstrumentation();
|
|
|
|
// Bootstrap must absorb the failed dynamic imports — the server keeps
|
|
// booting without tracing rather than crashing on an opt-in feature.
|
|
await expect(instrumentationReady).resolves.toBeUndefined();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("@opentelemetry/* packages are not installed"),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("checkExactPeerVersions", () => {
|
|
it("passes when the installed version matches the declared version exactly", async () => {
|
|
const { checkExactPeerVersions } = await importFreshInstrumentation();
|
|
const require = createRequire(import.meta.url);
|
|
const vitestVersion = (require("vitest/package.json") as { version: string }).version;
|
|
|
|
const result = checkExactPeerVersions(["vitest"], { vitest: vitestVersion });
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
});
|
|
|
|
it("reports a package as missing when it cannot be resolved", async () => {
|
|
const { checkExactPeerVersions } = await importFreshInstrumentation();
|
|
|
|
const result = checkExactPeerVersions(["@paperclipai/does-not-exist-anywhere"], {
|
|
"@paperclipai/does-not-exist-anywhere": "1.0.0",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.diagnostic).toContain("@opentelemetry/* packages are not installed");
|
|
expect(result.diagnostic).toContain("@paperclipai/does-not-exist-anywhere");
|
|
}
|
|
});
|
|
|
|
it("reports a package as mismatched when the installed version differs from the declared version", async () => {
|
|
const { checkExactPeerVersions } = await importFreshInstrumentation();
|
|
const require = createRequire(import.meta.url);
|
|
const vitestVersion = (require("vitest/package.json") as { version: string }).version;
|
|
|
|
const result = checkExactPeerVersions(["vitest"], {
|
|
vitest: "0.0.0-not-the-installed-version",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.diagnostic).toContain("a package is installed at an unsupported version");
|
|
expect(result.diagnostic).toContain(`vitest@${vitestVersion}`);
|
|
expect(result.diagnostic).toContain("expected 0.0.0-not-the-installed-version");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("bootstrapOtel exact-version gate", () => {
|
|
it("does not mention the two exporters OTEL_EXPORTER_OTLP_PROTOCOL did not select", async () => {
|
|
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
|
process.env[PROTOCOL_ENV] = "http/json";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const { instrumentationReady } = await importFreshInstrumentation();
|
|
await instrumentationReady;
|
|
|
|
// The OTel packages are absent in this test environment, so the gate
|
|
// reports every checked package as missing. It must have checked the
|
|
// selected exporter (http/json → exporter-trace-otlp-http) and skipped
|
|
// the two the protocol did not select.
|
|
const diagnosticCall = warn.mock.calls.find((call) =>
|
|
String(call[0]).includes("@opentelemetry/* packages are not installed"),
|
|
);
|
|
expect(diagnosticCall).toBeDefined();
|
|
const message = String(diagnosticCall![0]);
|
|
expect(message).toContain("@opentelemetry/exporter-trace-otlp-http");
|
|
expect(message).not.toContain("@opentelemetry/exporter-trace-otlp-grpc");
|
|
expect(message).not.toContain("@opentelemetry/exporter-trace-otlp-proto");
|
|
});
|
|
|
|
it("emits exactly one diagnostic when the endpoint is set and packages are absent", async () => {
|
|
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const { instrumentationReady } = await importFreshInstrumentation();
|
|
await instrumentationReady;
|
|
|
|
const diagnosticCalls = warn.mock.calls.filter((call) =>
|
|
String(call[0]).includes("@opentelemetry/* packages are not installed"),
|
|
);
|
|
expect(diagnosticCalls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("bootstrapOtel post-gate load failure", () => {
|
|
it("logs a load-failure diagnostic, not the missing-package message, when a package passes the gate but its dynamic import fails", async () => {
|
|
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
// Fake the resolution steps the exact-version gate uses, so every checked
|
|
// package reports as installed at its declared version. The dynamic
|
|
// `import(...)` calls in bootstrapOtel are untouched, so they still fail
|
|
// for real — the packages are not actually installed in this test run.
|
|
// That forces the one path the catch block now must describe: a package
|
|
// that passed the gate but failed to load.
|
|
const FAKE_ROOT = "/fake-otel-pkg";
|
|
const require = createRequire(import.meta.url);
|
|
const peerDependencies = (
|
|
require("../../package.json") as { peerDependencies?: Record<string, string> }
|
|
).peerDependencies ?? {};
|
|
|
|
vi.resetModules();
|
|
vi.doMock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
return {
|
|
...actual,
|
|
existsSync: (path: unknown) =>
|
|
String(path).startsWith(FAKE_ROOT) ? true : actual.existsSync(path as never),
|
|
readFileSync: (path: unknown, options?: unknown) => {
|
|
const asString = String(path);
|
|
if (asString.startsWith(FAKE_ROOT) && asString.endsWith("package.json")) {
|
|
const name = decodeURIComponent(
|
|
asString.slice(FAKE_ROOT.length + 1, -"/package.json".length),
|
|
);
|
|
return JSON.stringify({ name, version: peerDependencies[name] });
|
|
}
|
|
return actual.readFileSync(path as never, options as never);
|
|
},
|
|
};
|
|
});
|
|
vi.doMock("node:module", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:module")>();
|
|
return {
|
|
...actual,
|
|
createRequire: (...args: Parameters<typeof actual.createRequire>) => {
|
|
const real = actual.createRequire(...args);
|
|
const fake = ((id: string) => real(id)) as typeof real;
|
|
fake.resolve = ((id: string, options?: unknown) =>
|
|
id in peerDependencies
|
|
? `${FAKE_ROOT}/${encodeURIComponent(id)}/index.js`
|
|
: real.resolve(id, options as never)) as typeof real.resolve;
|
|
return fake;
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
const { instrumentationReady } = await import("../instrumentation.js");
|
|
await instrumentationReady;
|
|
} finally {
|
|
vi.doUnmock("node:fs");
|
|
vi.doUnmock("node:module");
|
|
}
|
|
|
|
for (const call of warn.mock.calls) {
|
|
expect(String(call[0])).not.toContain("packages are not installed");
|
|
expect(String(call[0])).not.toContain("Install @opentelemetry/sdk-node");
|
|
}
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("passed the version check"),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getStartupTracer", () => {
|
|
it("returns a usable tracer-shaped object when OTEL_EXPORTER_OTLP_ENDPOINT is unset", async () => {
|
|
const { getStartupTracer } = await importFreshInstrumentation();
|
|
|
|
const tracer = getStartupTracer();
|
|
|
|
// The accessor never returns null. The result exposes the span surface the
|
|
// startup seam calls, so the caller needs no null check.
|
|
expect(tracer).not.toBeNull();
|
|
expect(typeof tracer.startSpan).toBe("function");
|
|
// A no-op tracer must carry attribute, status, and end calls without
|
|
// throwing: the startup seam sets attributes and status before it ends.
|
|
expect(() => {
|
|
const span = tracer.startSpan("workspace.resolve");
|
|
span.setAttribute("k", "v");
|
|
span.setStatus({ code: 0 });
|
|
span.end();
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("loads no OTel SDK package when the endpoint is unset", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const { getStartupTracer, instrumentationReady } = await importFreshInstrumentation();
|
|
|
|
// The endpoint is unset, so the bootstrap never runs and no SDK package
|
|
// import is attempted; readiness resolves at once.
|
|
await expect(instrumentationReady).resolves.toBeUndefined();
|
|
|
|
// The accessor still returns a usable tracer even though no @opentelemetry
|
|
// SDK package is installed. That proves it never imported the SDK: a hard
|
|
// dependency on the SDK would throw here instead.
|
|
const tracer = getStartupTracer();
|
|
expect(typeof tracer.startSpan).toBe("function");
|
|
expect(() => tracer.startSpan("stage.sync").end()).not.toThrow();
|
|
|
|
// The bootstrap "packages are not installed" diagnostic must not fire on
|
|
// this path. That message comes only from the endpoint-set bootstrap.
|
|
for (const call of warn.mock.calls) {
|
|
expect(String(call[0])).not.toContain("@opentelemetry/* packages are not installed");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getStartupTraceContext", () => {
|
|
it("returns a tracer and a parent-context helper that never throw when the endpoint is unset", async () => {
|
|
const { getStartupTraceContext } = await importFreshInstrumentation();
|
|
|
|
const traceContext = getStartupTraceContext();
|
|
|
|
// The tracer handle carries the same span surface as getStartupTracer.
|
|
expect(() => {
|
|
const span = traceContext.tracer.startSpan("runtime.exec");
|
|
span.setAttribute("k", "v");
|
|
span.setStatus({ code: 0 });
|
|
span.end();
|
|
}).not.toThrow();
|
|
// The helper never throws. It returns a parent token when the api
|
|
// resolves, or `undefined` from the no-op fallback when it does not.
|
|
expect(() => traceContext.contextWithSpan({})).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("shutdownInstrumentation", () => {
|
|
it("is a no-op when tracing is off and idempotent across calls", async () => {
|
|
const { shutdownInstrumentation } = await importFreshInstrumentation();
|
|
|
|
const first = shutdownInstrumentation();
|
|
const second = shutdownInstrumentation();
|
|
|
|
// Memoized: concurrent callers share one shutdown promise.
|
|
expect(first).toBe(second);
|
|
await expect(first).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("resolves after a failed bootstrap instead of hanging", async () => {
|
|
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const { shutdownInstrumentation } = await importFreshInstrumentation();
|
|
|
|
await expect(shutdownInstrumentation()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// The `@opentelemetry/*` packages are optional. When they are absent,
|
|
// `recordProviderPluginSpan` is a no-op by contract, so a native-duration test
|
|
// cannot run. Resolve the SDK first and skip the test when it is not installed.
|
|
const otelSdk = (() => {
|
|
try {
|
|
const require = createRequire(import.meta.url);
|
|
return {
|
|
api: require("@opentelemetry/api") as typeof import("@opentelemetry/api"),
|
|
sdk: require("@opentelemetry/sdk-trace-base") as typeof import("@opentelemetry/sdk-trace-base"),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const hrTimeToMs = (time: [number, number]): number => time[0] * 1000 + time[1] / 1e6;
|
|
|
|
describe.skipIf(!otelSdk)("recordProviderPluginSpan native duration", () => {
|
|
it("opens the span at the true start time and ends it at the true end time", async () => {
|
|
const { api, sdk } = otelSdk!;
|
|
const exporter = new sdk.InMemorySpanExporter();
|
|
const provider = new sdk.BasicTracerProvider({
|
|
spanProcessors: [new sdk.SimpleSpanProcessor(exporter)],
|
|
});
|
|
api.trace.setGlobalTracerProvider(provider);
|
|
try {
|
|
const { recordProviderPluginSpan } = await import("../instrumentation.js");
|
|
const startTimeMs = Date.now() - 4500;
|
|
const endTimeMs = startTimeMs + 4500;
|
|
recordProviderPluginSpan({
|
|
name: "sandbox.daytona.ensureDirectory",
|
|
parent: {
|
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
spanId: "b7ad6b7169203331",
|
|
traceFlags: 1,
|
|
},
|
|
attributes: { provider: "daytona" },
|
|
startTimeMs,
|
|
endTimeMs,
|
|
});
|
|
const finished = exporter.getFinishedSpans();
|
|
expect(finished).toHaveLength(1);
|
|
const span = finished[0]!;
|
|
expect(span.name).toBe("sandbox.daytona.ensureDirectory");
|
|
expect(Math.round(hrTimeToMs(span.startTime as [number, number]))).toBe(startTimeMs);
|
|
expect(Math.round(hrTimeToMs(span.endTime as [number, number]))).toBe(endTimeMs);
|
|
// The native width equals the true wall-clock difference, not near zero.
|
|
expect(Math.round(hrTimeToMs(span.duration as [number, number]))).toBe(4500);
|
|
} finally {
|
|
api.trace.disable();
|
|
}
|
|
});
|
|
|
|
it("exports an earlier lifecycle as a separate trace linked to its observer",async()=>{
|
|
const {api,sdk}=otelSdk!;
|
|
const exporter=new sdk.InMemorySpanExporter();
|
|
const provider=new sdk.BasicTracerProvider({spanProcessors:[new sdk.SimpleSpanProcessor(exporter)]});
|
|
api.trace.setGlobalTracerProvider(provider);
|
|
try {
|
|
const {recordProviderPluginSpan}=await import("../instrumentation.js");
|
|
const parent={traceId:"0af7651916cd43dd8448eb211c80319c",spanId:"b7ad6b7169203331",traceFlags:1};
|
|
const end=Date.now()-100;
|
|
recordProviderPluginSpan({name:"plugin.remote.lifecycle",parent,linked:true,attributes:{},startTimeMs:end-500,endTimeMs:end});
|
|
const span=exporter.getFinishedSpans()[0]!;
|
|
expect(span.parentSpanContext).toBeUndefined();
|
|
expect(span.spanContext().traceId).not.toBe(parent.traceId);
|
|
expect(span.links[0].context).toMatchObject(parent);
|
|
expect(Math.round(hrTimeToMs(span.duration as [number,number]))).toBe(500);
|
|
} finally {api.trace.disable();}
|
|
});
|
|
|
|
it("opens and ends the span now when the timestamp pair is absent", async () => {
|
|
const { api, sdk } = otelSdk!;
|
|
const exporter = new sdk.InMemorySpanExporter();
|
|
const provider = new sdk.BasicTracerProvider({
|
|
spanProcessors: [new sdk.SimpleSpanProcessor(exporter)],
|
|
});
|
|
api.trace.setGlobalTracerProvider(provider);
|
|
try {
|
|
const { recordProviderPluginSpan } = await import("../instrumentation.js");
|
|
recordProviderPluginSpan({
|
|
name: "sandbox.daytona.pack",
|
|
parent: {
|
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
spanId: "b7ad6b7169203331",
|
|
traceFlags: 1,
|
|
},
|
|
attributes: { provider: "daytona" },
|
|
});
|
|
const finished = exporter.getFinishedSpans();
|
|
expect(finished).toHaveLength(1);
|
|
// The synchronous open-and-end path yields a near-zero native width.
|
|
expect(hrTimeToMs(finished[0]!.duration as [number, number])).toBeLessThan(1000);
|
|
} finally {
|
|
api.trace.disable();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("gives a linked lifecycle an explicit root context and a host-owned observer link",async()=>{
|
|
const root={},active={};const end=vi.fn();
|
|
const startSpan=vi.fn(()=>({end,setStatus:vi.fn(),spanContext:()=>({traceId:"a".repeat(32),spanId:"b".repeat(16),traceFlags:1})}));
|
|
const setSpanContext=vi.fn(()=>active);
|
|
vi.doMock("node:module",()=>({createRequire:()=>()=>({ROOT_CONTEXT:root,context:{active:()=>active},trace:{getTracer:()=>({startSpan}),setSpanContext}})}));
|
|
try {
|
|
const {recordProviderPluginSpan}=await importFreshInstrumentation();
|
|
const parent={traceId:"0af7651916cd43dd8448eb211c80319c",spanId:"b7ad6b7169203331",traceFlags:1};
|
|
recordProviderPluginSpan({name:"plugin.remote.lifecycle",parent,linked:true,attributes:{},startTimeMs:100,endTimeMs:500});
|
|
expect(startSpan).toHaveBeenCalledWith("plugin.remote.lifecycle",{attributes:{},startTime:100,links:[{context:{...parent,isRemote:true}}]},root);
|
|
expect(setSpanContext).not.toHaveBeenCalled();expect(end).toHaveBeenCalledWith(500);
|
|
startSpan.mockClear();
|
|
recordProviderPluginSpan({name:"plugin.remote.lifecycle",parent:{...parent,traceFlags:0},linked:true,attributes:{},startTimeMs:100,endTimeMs:500});
|
|
expect(startSpan).not.toHaveBeenCalled();
|
|
} finally {vi.doUnmock("node:module");vi.resetModules();}
|
|
});
|