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:
Nicky LeachandPaperclip authored and GitHub committed 2026-08-06 22:31:15 -07:00
1 parent 03cfad7ceb
commit 9ace548fd2
15 files changed
+539 -123

No files matched your search

@@ -460,8 +460,9 @@ async function syncInFileMappings(input: {
// Ensure every target directory exists before the bulk upload writes its temp.
const mkdirCommand = [...parentDirs].map((dir) => `mkdir -p ${shellQuote(dir)}`).join(" && ");
// `ensureDirectory` span: `mkdir -p` — ensure a directory exists before a write.
await withProviderSpan({
name: "mkdir",
name: "ensureDirectory",
run: () => assertSandboxCommandOk(sandbox, mkdirCommand, timeoutSeconds, "syncIn mkdir"),
});
guardRoundTrips += 1;
@@ -470,8 +471,10 @@ async function syncInFileMappings(input: {
// can replace a target parent with a symlink to `/etc` so the string check
// passes but the upload + `mv -f` resolve through it. Canonicalize every parent
// dir (now materialized) and fail closed if any escapes, BEFORE any bytes land.
// `checkSymlinkEscape` span: re-check a path resolves inside the workspace root
// before use.
await withProviderSpan({
name: "guard",
name: "checkSymlinkEscape",
run: () =>
assertSandboxPathsConfined({
sandbox,
@@ -488,6 +491,7 @@ async function syncInFileMappings(input: {
// retry never accumulates stale `.paperclip-upload-*` scratch.
try {
// One batched bulk upload (single /files/bulk-upload) for all file mappings.
// `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`.
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
@@ -534,8 +538,10 @@ async function syncInFileMappings(input: {
`exec 8>&-;`,
);
}
// `promote` span: atomically move the staged temp onto its target via a
// pinned dir handle.
await withProviderSpan({
name: "rename",
name: "promote",
run: () =>
assertSandboxCommandOk(
sandbox,
@@ -564,6 +570,7 @@ async function syncInDirectoryMapping(input: {
const archivePath = path.join(tmp, "sync-in.tar");
// The pack step is host-local: it builds the tarball and makes no sandbox
// round trip. The `pack` span records its wall time.
// `pack` span: build a tarball on the host — no sandbox round trip.
await withProviderSpan({
name: "pack",
wallMsAttr: SPAN_ATTR.packWallMs,
@@ -585,8 +592,9 @@ async function syncInDirectoryMapping(input: {
// components, then confirm it (and any existing parent) canonicalizes inside
// the remote dir — `tar -C` would otherwise follow a sandbox-planted symlink
// and extract our archive outside the workspace root.
// `ensureDirectory` span: `mkdir -p` — ensure a directory exists before a write.
await withProviderSpan({
name: "mkdir",
name: "ensureDirectory",
run: () =>
assertSandboxCommandOk(
sandbox,
@@ -596,8 +604,10 @@ async function syncInDirectoryMapping(input: {
),
});
guardRoundTrips += 1;
// `checkSymlinkEscape` span: re-check a path resolves inside the workspace
// root before use.
await withProviderSpan({
name: "guard",
name: "checkSymlinkEscape",
run: () =>
assertSandboxPathsConfined({
sandbox,
@@ -608,6 +618,7 @@ async function syncInDirectoryMapping(input: {
}),
});
guardRoundTrips += 1;
// `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`.
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
@@ -641,8 +652,10 @@ async function syncInDirectoryMapping(input: {
`exec 9>&-;`,
`rm -f ${shellQuote(remoteTar)};`,
].join("\n");
// `extractTarball` span: one round trip — re-check the path, `tar -xf`, and
// remove the scratch tarball.
await withProviderSpan({
name: "extract",
name: "extractTarball",
run: () =>
assertSandboxCommandOk(
sandbox,
@@ -687,8 +700,10 @@ async function runPostUploadCommands(input: {
let cwd = remoteDir;
if (command.cwd != null) {
assertConfinedSandboxPath(remoteDir, command.cwd, "post-upload command cwd");
// `checkSymlinkEscape` span: re-check a path resolves inside the workspace
// root before use.
await withProviderSpan({
name: "guard",
name: "checkSymlinkEscape",
run: () =>
assertSandboxPathsConfined({
sandbox,
@@ -704,8 +719,9 @@ async function runPostUploadCommands(input: {
// C4: first non-zero exit or timeout throws and aborts the remaining commands.
const commandTimeoutSeconds =
command.timeoutMs != null ? toTimeoutSeconds(command.timeoutMs) : timeoutSeconds;
// `postUploadCommand` span: run one caller-supplied post-upload command.
const result = await withProviderSpan({
name: "provision",
name: "postUploadCommand",
run: () =>
sandbox.process.executeCommand(command.command, cwd, undefined, commandTimeoutSeconds),
});
@@ -1202,7 +1202,7 @@ describe("Daytona sandbox provider plugin", () => {
// The provision command runs before the run opens its trace root, so the
// host marks it `bypassSession`. The provider must not open the session for
// it, or the `session.setup` span loses its run parent.
// it, or the `session.open` span loses its run parent.
await plugin.definition.onEnvironmentExecute?.(
sessionExecParams({ bypassSession: true }),
);
@@ -1406,7 +1406,7 @@ describe("Daytona sandbox provider plugin", () => {
expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId);
});
it("emits a session.setup span on create and a session.teardown span on delete", async () => {
it("emits a session.open span on create and a session.close span on delete", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
@@ -1414,7 +1414,7 @@ describe("Daytona sandbox provider plugin", () => {
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
const setup = spans.find((span) => span.name === "session.setup");
const setup = spans.find((span) => span.name === "session.open");
expect(setup).toBeDefined();
expect(setup!.ended).toBe(true);
expect(setup!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
@@ -1426,7 +1426,7 @@ describe("Daytona sandbox provider plugin", () => {
providerLeaseId: "sandbox-123",
config: { timeoutMs: 300000, reuseLease: false, useSessions: true },
});
const teardown = spans.find((span) => span.name === "session.teardown");
const teardown = spans.find((span) => span.name === "session.close");
expect(teardown).toBeDefined();
expect(teardown!.ended).toBe(true);
expect(teardown!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
@@ -1435,7 +1435,7 @@ describe("Daytona sandbox provider plugin", () => {
}
});
it("marks the session.setup span failed when the session create throws", async () => {
it("marks the session.open span failed when the session create throws", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.process.createSession.mockRejectedValueOnce(new Error("create boom"));
@@ -1446,7 +1446,7 @@ describe("Daytona sandbox provider plugin", () => {
await expect(
plugin.definition.onEnvironmentExecute?.(sessionExecParams()),
).rejects.toThrow(/create boom/);
const setup = spans.find((span) => span.name === "session.setup");
const setup = spans.find((span) => span.name === "session.open");
expect(setup).toBeDefined();
expect(setup!.ended).toBe(true);
expect(setup!.status?.code).toBe(2);
@@ -3028,7 +3028,7 @@ describe("daytona native file-sync hooks", () => {
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(2);
});
it("opens mkdir, guard, transfer, rename spans in call order for a file-mapping sync", async () => {
it("opens ensureDirectory, checkSymlinkEscape, transfer, promote spans in call order for a file-mapping sync", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
@@ -3055,21 +3055,26 @@ describe("daytona native file-sync hooks", () => {
restore();
}
expect(spans.map((span) => span.name)).toEqual(["mkdir", "guard", "transfer", "rename"]);
expect(spans.map((span) => span.name)).toEqual([
"ensureDirectory",
"checkSymlinkEscape",
"transfer",
"promote",
]);
for (const span of spans) {
expect(span.ended).toBe(true);
expect(span.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
// A per-round-trip span carries no `*.wall_ms` attribute; the native span
// width carries its time. Only `pack` and `transfer` keep a wall_ms value.
if (span.name !== "transfer") {
expect(span.attributes["paperclip.sandbox.startup.mkdir.wall_ms"]).toBeUndefined();
expect(span.attributes["paperclip.sandbox.startup.rename.wall_ms"]).toBeUndefined();
expect(span.attributes["paperclip.sandbox.startup.guard.wall_ms"]).toBeUndefined();
expect(span.attributes["paperclip.sandbox.startup.ensureDirectory.wall_ms"]).toBeUndefined();
expect(span.attributes["paperclip.sandbox.startup.promote.wall_ms"]).toBeUndefined();
expect(span.attributes["paperclip.sandbox.startup.checkSymlinkEscape.wall_ms"]).toBeUndefined();
}
}
});
it("opens pack, mkdir, guard, transfer, extract spans in call order for a directory-mapping sync", async () => {
it("opens pack, ensureDirectory, checkSymlinkEscape, transfer, extractTarball spans in call order for a directory-mapping sync", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "assets");
await fs.mkdir(sourceDir, { recursive: true });
@@ -3099,7 +3104,13 @@ describe("daytona native file-sync hooks", () => {
restore();
}
expect(spans.map((span) => span.name)).toEqual(["pack", "mkdir", "guard", "transfer", "extract"]);
expect(spans.map((span) => span.name)).toEqual([
"pack",
"ensureDirectory",
"checkSymlinkEscape",
"transfer",
"extractTarball",
]);
for (const span of spans) {
expect(span.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
}
@@ -3141,7 +3152,7 @@ describe("daytona native file-sync hooks", () => {
expect(pack!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
});
it("opens a guard span and a provision span in call order for a post-upload command with a working directory", async () => {
it("opens a checkSymlinkEscape span and a postUploadCommand span in call order for a post-upload command with a working directory", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
@@ -3169,17 +3180,18 @@ describe("daytona native file-sync hooks", () => {
restore();
}
// The full order: the file mapping opens mkdir, guard, transfer, rename; the
// post-upload command then opens its own cwd guard and the provision span.
// The full order: the file mapping opens ensureDirectory, checkSymlinkEscape,
// transfer, promote; the post-upload command then opens its own cwd
// checkSymlinkEscape and the postUploadCommand span.
expect(spans.map((span) => span.name)).toEqual([
"mkdir",
"guard",
"ensureDirectory",
"checkSymlinkEscape",
"transfer",
"rename",
"guard",
"provision",
"promote",
"checkSymlinkEscape",
"postUploadCommand",
]);
const provision = spans.find((span) => span.name === "provision");
const provision = spans.find((span) => span.name === "postUploadCommand");
expect(provision!.ended).toBe(true);
expect(provision!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
});
@@ -1336,11 +1336,13 @@ async function getOrCreateSession(sandbox: Sandbox, scope: SandboxScope): Promis
// no second command can slip in between the store read and the create start.
return sandboxHandleSessionStore.runSingle(scope, async () => {
const sessionId = `paperclip-${randomUUID()}`;
// Wrap the session create in a short `session.setup` provider span. The span
// Wrap the session create in a short `session.open` provider span. The span
// carries no session id and no command text, only the provider family. The
// host maps the name to `sandbox.provider.session.setup`.
// host maps the name to `sandbox.daytona.session.open`.
// `session.open` span: create the one persistent Daytona session for a lease,
// on the first in-run command — `sandbox.process.createSession`.
await withProviderSpan({
name: "session.setup",
name: "session.open",
run: () => sandbox.process.createSession(sessionId),
});
sandboxHandleSessionStore.set(scope, sessionId);
@@ -1360,10 +1362,12 @@ async function teardownSession(sandbox: Sandbox, scope: SandboxScope): Promise<v
const sessionId = sandboxHandleSessionStore.get(scope);
if (!sessionId) return;
try {
// Wrap the session delete in a short `session.teardown` provider span. The
// host maps the name to `sandbox.provider.session.teardown`.
// Wrap the session delete in a short `session.close` provider span. The
// host maps the name to `sandbox.daytona.session.close`.
// `session.close` span: delete that persistent session on lease release —
// `sandbox.process.deleteSession`.
await withProviderSpan({
name: "session.teardown",
name: "session.close",
run: () => sandbox.process.deleteSession(sessionId),
});
} catch (error) {
@@ -2255,9 +2259,9 @@ const plugin = definePlugin({
// on, and it does NOT open the session. The host sets this flag on a
// pre-run command (the workspace provision command) that runs before the
// run opens its trace root. Opening the session there would emit a
// `session.setup` span with no run parent, and the span backend would drop
// `session.open` span with no run parent, and the span backend would drop
// it. With the bypass the session opens on the first in-run command, whose
// setup span parents to the run trace.
// open span parents to the run trace.
let result: PluginEnvironmentExecuteResult;
if (config.useSessions && !params.bypassSession) {
const sessionId = await getOrCreateSession(sandbox, scope);