mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat(observability): rename sandbox provider spans and add run-time wrapper spans (#10999)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip uses adapter and sandbox code to start agents and run sandbox work > - The current sandbox spans use mixed names and do not group related run-time work > - Mixed names make traces harder to read and compare across providers > - This pull request renames provider spans, adds run-time wrapper spans, and keeps the host allowlist closed > - The benefit is clearer traces with the same sandbox behavior and trust boundary ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves OpenTelemetry span names and grouping for sandbox startup, execution, callback relay, and agent session work. **Subsystem affected** Cross-cutting (multiple of the above): adapter utilities, sandbox providers, shared telemetry documentation, and server instrumentation. **Current behavior** Sandbox provider spans use mixed names. Related run-time operations expose inner `sandbox.exec` spans without a named wrapper span. The host mapper uses a closed allowlist for provider span names. **Proposed behavior** Use descriptive provider-scoped span names. Add wrapper spans for agent session input, agent session output polling, and callback relay. Keep the host mapper allowlist closed and map unknown names to `other`. **Reason and benefit** Clear names make traces easier to read and reduce ambiguity during sandbox operation analysis. Wrapper spans show the full operation while preserving the inner execution spans. **Breaking changes** None. This change updates telemetry span names and grouping only. It does not change sandbox behavior, endpoint behavior, or the host trust boundary. **Additional context** Related prior work: [#10758](https://github.com/paperclipai/paperclip/pull/10758). ## What Changed - Rename Daytona provider sync and session spans with descriptive provider-scoped names. - Add three run-time wrapper spans for agent session input, output polling, and callback relay. - Add a shared span runner that preserves no-op behavior without a real tracer. - Keep the host mapper allowlist closed and map unknown names to `other`. - Update telemetry documentation and span-name tests. ## Verification - Focused adapter-utils span tests pass for startup timing, callback relay, and sandbox execution. - Focused Daytona plugin span tests pass for renamed leaf spans and session open or close spans. - Focused server tests pass for host mapping and instrumentation. - The stacked diff contains one commit on top of `feat/daytona-persistent-session-model`. ## Risks - Span names change for existing telemetry consumers. - The wrapper spans add trace structure but do not change sandbox execution. - The host mapper keeps the existing closed allowlist and `other` bucket. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 (Codex agent); exact deployment revision and context window are not exposed in this run; tool use and code execution enabled. ## 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 either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived 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] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
03cfad7ceb
commit
9ace548fd2
15 files changed
+539
-123
No files matched your search
@@ -709,7 +709,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
// Build the fake plugin fixture for the run-parent release tests below. A
|
||||
// plugin sandbox provider can open a persistent session on the first command
|
||||
// and delete it on lease release; the delete emits a provider
|
||||
// `session.teardown` span. The host mints that span's parent from the active
|
||||
// `session.close` span. The host mints that span's parent from the active
|
||||
// step context at the release RPC. So the release must run under the run
|
||||
// parent, or the span loses its traceparent and the backend drops it.
|
||||
async function seedFakePluginSandbox() {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe.skipIf(!otelSdk)("recordProviderPluginSpan native duration", () => {
|
||||
const startTimeMs = Date.now() - 4500;
|
||||
const endTimeMs = startTimeMs + 4500;
|
||||
recordProviderPluginSpan({
|
||||
name: "sandbox.provider.mkdir",
|
||||
name: "sandbox.daytona.ensureDirectory",
|
||||
parent: {
|
||||
traceId: "0af7651916cd43dd8448eb211c80319c",
|
||||
spanId: "b7ad6b7169203331",
|
||||
@@ -191,7 +191,7 @@ describe.skipIf(!otelSdk)("recordProviderPluginSpan native duration", () => {
|
||||
const finished = exporter.getFinishedSpans();
|
||||
expect(finished).toHaveLength(1);
|
||||
const span = finished[0]!;
|
||||
expect(span.name).toBe("sandbox.provider.mkdir");
|
||||
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.
|
||||
@@ -211,7 +211,7 @@ describe.skipIf(!otelSdk)("recordProviderPluginSpan native duration", () => {
|
||||
try {
|
||||
const { recordProviderPluginSpan } = await import("../instrumentation.js");
|
||||
recordProviderPluginSpan({
|
||||
name: "sandbox.provider.pack",
|
||||
name: "sandbox.daytona.pack",
|
||||
parent: {
|
||||
traceId: "0af7651916cd43dd8448eb211c80319c",
|
||||
spanId: "b7ad6b7169203331",
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("plugin provider span host handler", () => {
|
||||
parent: { traceId: string; spanId: string; traceFlags: number };
|
||||
attributes: Record<string, unknown>;
|
||||
};
|
||||
expect(call.name).toBe("sandbox.provider.pack");
|
||||
expect(call.name).toBe("sandbox.daytona.pack");
|
||||
expect(call.parent).toEqual({
|
||||
traceId: "0af7651916cd43dd8448eb211c80319c",
|
||||
spanId: "b7ad6b7169203331",
|
||||
@@ -114,20 +114,26 @@ describe("plugin provider span host handler", () => {
|
||||
expect(call.status).not.toHaveProperty("message");
|
||||
});
|
||||
|
||||
it("clamps an unknown span name to sandbox.provider.other", async () => {
|
||||
it("clamps an unknown span name to sandbox.daytona.other", async () => {
|
||||
const services = servicesFor();
|
||||
await services.tracer.record(
|
||||
{ name: "rm -rf / --no-preserve-root" },
|
||||
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
||||
);
|
||||
expect((mockRecordSpan.mock.calls[0]![0] as { name: string }).name).toBe(
|
||||
"sandbox.provider.other",
|
||||
"sandbox.daytona.other",
|
||||
);
|
||||
});
|
||||
|
||||
it("admits each per-round-trip span name to sandbox.provider.<name>", async () => {
|
||||
it("admits each per-round-trip span name to sandbox.daytona.<name>", async () => {
|
||||
const services = servicesFor();
|
||||
for (const name of ["mkdir", "guard", "rename", "extract", "provision"]) {
|
||||
for (const name of [
|
||||
"ensureDirectory",
|
||||
"checkSymlinkEscape",
|
||||
"promote",
|
||||
"extractTarball",
|
||||
"postUploadCommand",
|
||||
]) {
|
||||
await services.tracer.record(
|
||||
{ name },
|
||||
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
||||
@@ -135,17 +141,17 @@ describe("plugin provider span host handler", () => {
|
||||
}
|
||||
const recorded = mockRecordSpan.mock.calls.map((c) => (c[0] as { name: string }).name);
|
||||
expect(recorded).toEqual([
|
||||
"sandbox.provider.mkdir",
|
||||
"sandbox.provider.guard",
|
||||
"sandbox.provider.rename",
|
||||
"sandbox.provider.extract",
|
||||
"sandbox.provider.provision",
|
||||
"sandbox.daytona.ensureDirectory",
|
||||
"sandbox.daytona.checkSymlinkEscape",
|
||||
"sandbox.daytona.promote",
|
||||
"sandbox.daytona.extractTarball",
|
||||
"sandbox.daytona.postUploadCommand",
|
||||
]);
|
||||
});
|
||||
|
||||
it("admits the session setup and teardown span names", async () => {
|
||||
it("admits the session open and close span names", async () => {
|
||||
const services = servicesFor();
|
||||
for (const name of ["session.setup", "session.teardown"]) {
|
||||
for (const name of ["session.open", "session.close"]) {
|
||||
await services.tracer.record(
|
||||
{ name },
|
||||
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
||||
@@ -153,8 +159,8 @@ describe("plugin provider span host handler", () => {
|
||||
}
|
||||
const recorded = mockRecordSpan.mock.calls.map((c) => (c[0] as { name: string }).name);
|
||||
expect(recorded).toEqual([
|
||||
"sandbox.provider.session.setup",
|
||||
"sandbox.provider.session.teardown",
|
||||
"sandbox.daytona.session.open",
|
||||
"sandbox.daytona.session.close",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -163,7 +169,7 @@ describe("plugin provider span host handler", () => {
|
||||
const startTimeMs = Date.now() - 4500;
|
||||
const endTimeMs = startTimeMs + 4500;
|
||||
await services.tracer.record(
|
||||
{ name: "mkdir", startTimeMs, endTimeMs },
|
||||
{ name: "ensureDirectory", startTimeMs, endTimeMs },
|
||||
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
||||
);
|
||||
const call = mockRecordSpan.mock.calls[0]![0] as {
|
||||
@@ -181,7 +187,7 @@ describe("plugin provider span host handler", () => {
|
||||
const startTimeMs = Date.now() + 5000;
|
||||
const endTimeMs = startTimeMs + 1000;
|
||||
await services.tracer.record(
|
||||
{ name: "mkdir", startTimeMs, endTimeMs },
|
||||
{ name: "ensureDirectory", startTimeMs, endTimeMs },
|
||||
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
||||
);
|
||||
const call = mockRecordSpan.mock.calls[0]![0] as {
|
||||
@@ -207,7 +213,7 @@ describe("plugin provider span host handler", () => {
|
||||
for (const pair of invalidPairs) {
|
||||
mockRecordSpan.mockReset();
|
||||
await services.tracer.record(
|
||||
{ name: "mkdir", startTimeMs: pair.startTimeMs, endTimeMs: pair.endTimeMs } as never,
|
||||
{ name: "ensureDirectory", startTimeMs: pair.startTimeMs, endTimeMs: pair.endTimeMs } as never,
|
||||
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
|
||||
);
|
||||
// The span still records (the synchronous path), but without a timestamp.
|
||||
|
||||
@@ -507,29 +507,33 @@ const SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
|
||||
|
||||
const SPAN_ATTRS = SANDBOX_STARTUP_SPAN_ATTRS;
|
||||
|
||||
/** The closed set of provider span names a plugin may emit. `pack` and
|
||||
* `transfer` are the host-local build and the byte upload. `mkdir`, `guard`,
|
||||
* `rename`, `extract`, and `provision` are the per-round-trip command spans in
|
||||
* the inbound sync path. `session.setup` and `session.teardown` are the short
|
||||
* spans that wrap a persistent-session create and delete. */
|
||||
/** The closed set of provider span leaf names a plugin may emit. `pack` and
|
||||
* `transfer` are the host-local build and the byte upload. `ensureDirectory`,
|
||||
* `checkSymlinkEscape`, `promote`, `extractTarball`, and `postUploadCommand`
|
||||
* are the per-round-trip command spans in the inbound sync path. `session.open`
|
||||
* and `session.close` are the short spans that wrap a persistent-session create
|
||||
* and delete. */
|
||||
const KNOWN_PROVIDER_SPAN_NAMES: ReadonlySet<string> = new Set([
|
||||
"pack",
|
||||
"transfer",
|
||||
"mkdir",
|
||||
"guard",
|
||||
"rename",
|
||||
"extract",
|
||||
"provision",
|
||||
"session.setup",
|
||||
"session.teardown",
|
||||
"ensureDirectory",
|
||||
"checkSymlinkEscape",
|
||||
"promote",
|
||||
"extractTarball",
|
||||
"postUploadCommand",
|
||||
"session.open",
|
||||
"session.close",
|
||||
]);
|
||||
|
||||
/** Clamp the span name to a closed, namespaced set. A known name maps to
|
||||
* `sandbox.provider.<name>`; any other value maps to `sandbox.provider.other`,
|
||||
* so a span name never carries free-form data. */
|
||||
* `sandbox.daytona.<name>`; any other value maps to `sandbox.daytona.other`, so
|
||||
* a span name never carries free-form data. Only the daytona provider emits
|
||||
* these spans today, so the segment is the literal `daytona`. When a second
|
||||
* provider emits provider spans, derive the segment from the normalized
|
||||
* `provider` family attribute on the span instead of this literal. */
|
||||
function clampProviderSpanName(raw: unknown): string {
|
||||
const name = typeof raw === "string" && KNOWN_PROVIDER_SPAN_NAMES.has(raw) ? raw : "other";
|
||||
return `sandbox.provider.${name}`;
|
||||
return `sandbox.daytona.${name}`;
|
||||
}
|
||||
|
||||
/** The closed allowlist of attribute keys a provider span may carry. The host
|
||||
|
||||
Reference in new issue
Block a user