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

@@ -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() {
+3 -3
View File
@@ -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.
+19 -15
View File
@@ -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