Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/spotty-pandas-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"sandbox": minor
---

Add opt-out usage telemetry and AI-agent attribution. The CLI now reports
anonymous usage events (subcommand, CLI version, platform, and the AI agent
driving the invocation, detected via `detect-agent`) to Vercel's telemetry
bridge, and tags API requests with the detected agent in the user-agent
header. Manage collection with `sandbox telemetry status|enable|disable`,
`VERCEL_SANDBOX_TELEMETRY_DISABLED=1`, or inspect events without sending via
`VERCEL_TELEMETRY_DEBUG=1`. Running as `vercel sandbox` respects the Vercel
CLI's own telemetry preference.
8 changes: 8 additions & 0 deletions .changeset/tall-lions-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@vercel/sandbox": minor
---

Tag API requests with the AI agent driving the process, when one is
detected via `detect-agent`, as an `agent/<name>` phrase in the user-agent
header. No agent detected means no change to the header. The SDK sends no
telemetry events; this is request metadata only.
1 change: 1 addition & 0 deletions packages/sandbox/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Commands:
snapshot <name> Take a snapshot of the filesystem of a sandbox
snapshots Manage sandbox snapshots
sessions Manage sandbox sessions
telemetry Manage telemetry collection status
login Log in to the Sandbox CLI
logout Log out of the Sandbox CLI

Expand Down
2 changes: 2 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"@types/ms": "^2.1.0",
"@types/node": "^22.15.12",
"@types/ws": "^8.18.1",
"@vercel/cli-config": "0.2.4",
"@vercel/detect-agent": "1.2.5",
"@vercel/oidc": "^3.2.0",
"@vercel/sandbox": "workspace:*",
"chalk": "^5.6.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/sandbox/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { snapshot } from "./commands/snapshot";
import { snapshots } from "./commands/snapshots";
import { sessions } from "./commands/sessions";
import { config } from "./commands/config";
import { telemetryCommand } from "./commands/telemetry";

export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => {
const appName = opts?.appName ?? "sandbox";
Expand All @@ -38,6 +39,7 @@ export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => {
snapshot,
snapshots,
sessions,
telemetry: telemetryCommand,
...(!opts?.withoutAuth && {
login,
logout,
Expand Down
70 changes: 70 additions & 0 deletions packages/sandbox/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const configRoot = mkdtempSync(join(tmpdir(), "sandbox-client-test-"));

vi.mock("xdg-app-paths", () => ({
default: (name: string) => ({
config: () => join(configRoot, name),
cache: () => join(configRoot, name, "cache"),
}),
}));

import { sandboxClient } from "./client";
import { writeTelemetryConfig } from "./telemetry";

describe("fetchWithUserAgent", () => {
const fetchMock = vi.fn(
async (..._args: Parameters<typeof globalThis.fetch>) =>
new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
}),
);

beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("AI_AGENT", "test-agent");
});

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
fetchMock.mockClear();
rmSync(configRoot, { recursive: true, force: true });
});

it("sends exactly one agent phrase when the SDK already stamped one", async () => {
await sandboxClient
.list({ token: "fake", teamId: "team_fake", projectId: "prj_fake" })
.catch(() => undefined);

expect(fetchMock).toHaveBeenCalled();
const init = fetchMock.mock.calls[0]?.[1];
const userAgent = new Headers(init?.headers).get("user-agent") ?? "";

expect(userAgent).toMatch(/^vercel\/sandbox-cli\//);
expect(userAgent).toContain("vercel/sandbox/");
expect(userAgent.match(/ agent\//g)).toHaveLength(1);
});

it("strips the SDK's agent phrase on a config-file opt-out", async () => {
// `sandbox telemetry disable` writes the config file but sets no env
// vars, so the SDK (which gates on env only) still stamps its phrase.
// The wrapper must enforce the opt-out by stripping it from the header.
writeTelemetryConfig(false);

await sandboxClient
.list({ token: "fake", teamId: "team_fake", projectId: "prj_fake" })
.catch(() => undefined);

const init = fetchMock.mock.calls[0]?.[1];
const userAgent = new Headers(init?.headers).get("user-agent") ?? "";

expect(userAgent).toMatch(/^vercel\/sandbox-cli\//);
expect(userAgent).toContain("vercel/sandbox/");
expect(userAgent).not.toContain(" agent/");
});
});
79 changes: 65 additions & 14 deletions packages/sandbox/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Sandbox, APIError, Snapshot } from "@vercel/sandbox";
import { version } from "./pkg";
import { withFreshAuthRetry } from "./util/fresh-auth-retry";
import { formatApiError } from "./util/format-error";
import { telemetry } from "./telemetry";
import { detectAgentName } from "./telemetry/agent";

/**
* A {@link Sandbox} wrapper that adds user-agent headers and error handling.
Expand All @@ -11,21 +13,31 @@ export const sandboxClient: Pick<
"get" | "list" | "create" | "fork"
> = {
get: (params) =>
withErrorHandling(() =>
Sandbox.get({ fetch: fetchWithUserAgent, resume: false, ...params }),
),
withErrorHandling(async () => {
updateScope(params);
const sandbox = await Sandbox.get({ fetch: fetchWithUserAgent, resume: false, ...params });
trackSession(sandbox, "attached");
return sandbox;
}),
create: (params) =>
withErrorHandling(() =>
Sandbox.create({ fetch: fetchWithUserAgent, ...params }),
),
withErrorHandling(async () => {
updateScope(params);
const sandbox = await Sandbox.create({ fetch: fetchWithUserAgent, ...params });
trackSession(sandbox, "created");
return sandbox;
}),
fork: (params) =>
withErrorHandling(() =>
Sandbox.fork({ fetch: fetchWithUserAgent, ...params }),
),
withErrorHandling(async () => {
updateScope(params);
const sandbox = await Sandbox.fork({ fetch: fetchWithUserAgent, ...params });
trackSession(sandbox, "created");
return sandbox;
}),
list: (params) =>
withErrorHandling(() =>
Sandbox.list({ fetch: fetchWithUserAgent, ...params } as typeof params),
),
withErrorHandling(() => {
updateScope(params);
return Sandbox.list({ fetch: fetchWithUserAgent, ...params } as typeof params);
}),
};

export const snapshotClient: Pick<
Expand All @@ -41,7 +53,28 @@ export const snapshotClient: Pick<
withErrorHandling(() => Snapshot.tree({ fetch: fetchWithUserAgent, ...params })),
};

const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => {
function scopeField(params: unknown, field: string): string | undefined {
if (params && typeof params === "object" && field in params) {
const value = (params as Record<string, unknown>)[field];
if (typeof value === "string") return value;
}
return undefined;
}

function updateScope(params: unknown): void {
telemetry.updateTeamId(scopeField(params, "teamId"));
telemetry.updateProjectId(scopeField(params, "projectId"));
}

function trackSession(sandbox: Sandbox, origin: "created" | "attached"): void {
try {
telemetry.trackSandboxSession(sandbox.currentSession().sessionId, origin);
} catch {
// No active session on this instance; nothing to record.
}
}

const fetchWithUserAgent: typeof globalThis.fetch = async (input, init) => {
const headers = new Headers(
init?.headers ??
(input && typeof input === "object" && "headers" in input
Expand All @@ -50,7 +83,25 @@ const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => {
);
let agent = `vercel/sandbox-cli/${version}`;

const existingAgent = headers.get("user-agent");
let existingAgent = headers.get("user-agent");

if (telemetry.enabled) {
// Attribute API traffic to the AI agent driving this invocation, if any,
// so the server side can record it once ingestion support lands. The SDK
// stamps its own phrase, so skip ours when one is already present.
if (!existingAgent?.includes(" agent/")) {
const aiAgent = await detectAgentName();
if (aiAgent) {
agent += ` agent/${aiAgent}`;
}
}
} else if (existingAgent) {
// The SDK gates its stamp on env vars only, so a config-file opt-out
// (`sandbox telemetry disable`) must be enforced here: strip any agent
// phrase from the header rather than trusting upstream gates.
existingAgent = existingAgent.replace(/ agent\/\S+/g, "");
}

if (existingAgent) {
agent += ` ${existingAgent}`;
}
Expand Down
63 changes: 63 additions & 0 deletions packages/sandbox/src/commands/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as cmd from "cmd-ts";
import chalk from "chalk";
import {
readTelemetryConfig,
writeTelemetryConfig,
telemetry,
} from "../telemetry";

function printStatus(): void {
const enabled = telemetry.enabled;
const status = enabled ? chalk.green("Enabled") : chalk.red("Disabled");
process.stderr.write(`Telemetry status: ${status}\n\n`);
if (enabled) {
process.stderr.write(
"The Vercel Sandbox CLI collects anonymous usage data to improve the product.\n" +
`Opt out with ${chalk.cyan("sandbox telemetry disable")} or by setting ${chalk.cyan("VERCEL_SANDBOX_TELEMETRY_DISABLED=1")}.\n` +
`Inspect what is collected by setting ${chalk.cyan("VERCEL_TELEMETRY_DEBUG=1")}; events are printed and not sent.\n`,
);
} else {
process.stderr.write(
`Re-enable with ${chalk.cyan("sandbox telemetry enable")}.\n`,
);
}
}

const statusCommand = cmd.command({
name: "status",
description: "Show whether telemetry collection is enabled",
args: {},
async handler() {
printStatus();
},
});

const enableCommand = cmd.command({
name: "enable",
description: "Enable telemetry collection",
args: {},
async handler() {
writeTelemetryConfig(true);
printStatus();
},
});

const disableCommand = cmd.command({
name: "disable",
description: "Disable telemetry collection",
args: {},
async handler() {
writeTelemetryConfig(false);
printStatus();
},
});

export const telemetryCommand = cmd.subcommands({
name: "telemetry",
description: "Manage telemetry collection status",
cmds: {
status: statusCommand,
enable: enableCommand,
disable: disableCommand,
},
});
12 changes: 11 additions & 1 deletion packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { run as runCmd } from "cmd-ts";
import { app } from "./app";
import { telemetry } from "./telemetry";

export function createApp(opts: { withoutAuth: boolean; appName: string }) {
const instance = app(opts);
return {
async run(args: string[]) {
await runCmd(instance, args);
await telemetry.trackInvocation({ appName: opts.appName, argv: args });
try {
await runCmd(instance, args);
telemetry.trackExitCode(0);
} catch (error) {
telemetry.trackExitCode(1);
throw error;
} finally {
await telemetry.flush();
}
},
};
}
12 changes: 10 additions & 2 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dotenv from "dotenv-flow";
import { printTopLevelError } from "./util/format-error";
import { steerShCommand } from "./util/steer-sh";
import { vercelFormatter } from "cmd-ts/batteries/vercel-formatter";
import { telemetry } from "./telemetry";

dotenv.config({
silent: true,
Expand All @@ -12,10 +13,17 @@ dotenv.config({
async function main() {
setDefaultHelpFormatter(vercelFormatter);

const argv = process.argv.slice(2);
await telemetry.trackInvocation({ appName: "sandbox", argv });

try {
steerShCommand(process.argv.slice(2));
await run(app(), process.argv.slice(2));
steerShCommand(argv);
await run(app(), argv);
telemetry.trackExitCode(0);
await telemetry.flush();
} catch (e) {
telemetry.trackExitCode(1);
await telemetry.flush();
await printTopLevelError(e);
process.exit(1);
}
Expand Down
18 changes: 18 additions & 0 deletions packages/sandbox/src/telemetry/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { determineAgent } from "@vercel/detect-agent";

let agentNamePromise: Promise<string | undefined> | undefined;

/**
* Detects the AI agent driving this process, if any. Memoized because the
* result is used both for telemetry events and for the user-agent header
* on every API request.
*/
export function detectAgentName(): Promise<string | undefined> {
if (!agentNamePromise) {
agentNamePromise = determineAgent().then(
(result) => (result.isAgent ? result.agent.name : undefined),
() => undefined,
);
}
return agentNamePromise;
}
Loading
Loading