mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
1 parent
f69c6a8a35
commit
d4892d4761
3 files changed
+85
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user