fix(observability): sample native runs independently of triggers (#16285)

Start native background run traces under the operator's root sampling policy and link them to valid trigger contexts. Preserve child parenting and silent unconfigured tracing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 09:49:08 -05:00
1 parent f69c6a8a35
commit d4892d4761
3 files changed
+85 -1

No files matched your search

+6
View File
@@ -204,6 +204,12 @@ ends before outer workspace persistence. The heartbeat-owned local run-log
every other native span carries a real OpenTelemetry parent context rather than
only a descriptive `parentName` field.
The native lifecycle root uses the operator's root sampling policy. It links
to a valid active trigger trace without inheriting its sampling decision.
An unsampled HTTP request therefore cannot suppress an otherwise sampled
background run. An operator who disables root sampling still gets no spans.
Without an OTLP endpoint, tracing remains silent and does not export data.
The run link in Honeycomb matches both `task.provider_session` for native runs and `task.run` for legacy adapters. It retains the exact hashed run-ID and service filters and groups by trace ID. The URL builder uses Honeycomb’s supported `in` operator for the two root names; see the [query specification](https://github.com/honeycombio/honeycomb-mcp#query-specification-for-run_query). This changes only the query link. The operator endpoint gate and span attribute allowlist remain unchanged.
The canonical lifecycle is:
@@ -0,0 +1,75 @@
import { context, trace, TraceFlags } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { AlwaysOffSampler, ParentBasedSampler, TracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace";
import { expect, it, vi } from "vitest";
import { createNativeRunTrace } from "./native-run-trace.js";
it.each([TraceFlags.NONE, TraceFlags.SAMPLED])("exports a complete native run independently of trigger flags %i", async traceFlags => {
const manager = new AsyncLocalStorageContextManager().enable();
const exporter = new InMemorySpanExporter();
const provider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] });
expect(context.setGlobalContextManager(manager)).toBe(true);
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
const trigger = {
traceId: "11111111111111111111111111111111", spanId: "2222222222222222", traceFlags, isRemote: true,
};
try {
await context.with(trace.setSpanContext(context.active(), trigger), async () => {
const native = createNativeRunTrace({ runId: "diagnostic-native-run" });
await native.measure("native.session.execute", async () => undefined);
await native.finish("ok");
expect(trace.getActiveSpan()?.spanContext()).toEqual(trigger);
});
await provider.forceFlush();
const spans = exporter.getFinishedSpans();
expect(spans.map(span => span.name).sort()).toEqual(["native.session.execute", "task.provider_session"]);
const root = spans.find(span => span.name === "task.provider_session")!;
const child = spans.find(span => span.name === "native.session.execute")!;
expect(root.parentSpanContext).toBeUndefined();
expect(root.links.map(link => link.context)).toEqual([trigger]);
expect(root.spanContext().traceId).not.toBe(trigger.traceId);
expect(child.parentSpanContext?.spanId).toBe(root.spanContext().spanId);
expect(child.spanContext().traceId).toBe(root.spanContext().traceId);
} finally {
trace.disable(); context.disable(); manager.disable(); await provider.shutdown();
}
});
it("honors an operator who disables root sampling despite a sampled trigger", async () => {
const manager = new AsyncLocalStorageContextManager().enable();
const exporter = new InMemorySpanExporter();
const provider = new TracerProvider({
sampler: new ParentBasedSampler({ root: new AlwaysOffSampler() }),
spanProcessors: [new SimpleSpanProcessor({ exporter })],
});
expect(context.setGlobalContextManager(manager)).toBe(true);
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
try {
await context.with(trace.setSpanContext(context.active(), {
traceId: "11111111111111111111111111111111", spanId: "2222222222222222", traceFlags: TraceFlags.SAMPLED, isRemote: true,
}), async () => {
const native = createNativeRunTrace({ runId: "disabled-native-run" });
await native.measure("native.session.execute", async () => undefined);
await native.finish("ok");
});
await provider.forceFlush();
expect(exporter.getFinishedSpans()).toEqual([]);
} finally {
trace.disable(); context.disable(); manager.disable(); await provider.shutdown();
}
});
it("stays silent without a tracer provider", async () => {
trace.disable(); context.disable();
const methods = ["log", "info", "warn", "error", "debug"] as const;
const spies = methods.map(method => vi.spyOn(console, method));
try {
const native = createNativeRunTrace({ runId: "unconfigured-native-run" });
const value = {};
await expect(native.measure("native.session.execute", async () => value)).resolves.toBe(value);
const error = new Error("original failure");
await expect(native.measure("native.session.execute", async () => { throw error; })).rejects.toBe(error);
await native.finish("ok");
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
} finally { for (const spy of spies) spy.mockRestore(); }
});
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash } from "node:crypto";
import { isSpanContextValid, ROOT_CONTEXT, trace } from "@opentelemetry/api";
import { runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import type { AdapterRuntimeEvent } from "../../adapters/index.js";
@@ -210,6 +211,7 @@ export function createNativeRunTrace(input: {
const runIdHash = hashedId(input.runId);
let rootSpan: SpanHandle;
try {
const trigger = trace.getActiveSpan()?.spanContext();
rootSpan = tracing.tracer.startSpan("task.provider_session", {
startTime: traceStartedAtMs,
attributes: {
@@ -218,7 +220,8 @@ export function createNativeRunTrace(input: {
"paperclip.task.run.trace_schema_version":
NATIVE_RUN_TRACE_SCHEMA_VERSION,
},
});
...(trigger && isSpanContextValid(trigger) ? { links: [{ context: trigger }] } : {}),
}, ROOT_CONTEXT);
} catch {
rootSpan = NOOP_SPAN;
}