Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/browse-env-classification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"browse": patch
---

Tag anonymous CLI telemetry with execution-environment properties (`is_container`, `is_tty`, `runtime_provider`) so events can be segmented by where the CLI runs (container / sandbox / interactive) at the source, instead of fragile behavioral fingerprinting. `runtime_provider` is derived from `std-env` plus an env-var allowlist for agent sandboxes (e2b, modal, daytona, codespaces, gitpod, replit).
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"ignore": "^7.0.5",
"node-html-markdown": "^1.3.0",
"semver": "^7.7.4",
"std-env": "^4.1.0",
"tsx": "^4.20.6",
"ws": "^8.18.3",
"zod": "^4.2.1"
Expand Down
67 changes: 67 additions & 0 deletions packages/cli/src/lib/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { existsSync } from "node:fs";

import { hasTTY, provider } from "std-env";

export interface EnvironmentInfo {
/** Running inside a Docker/Podman-style container (microVM sandboxes are caught by runtime_provider instead). */
is_container: boolean;
/** Attached to an interactive terminal — false for CI, sandboxes, agents, and piped runs. */
is_tty: boolean;
/** Normalized execution environment: a CI/host provider (via std-env) or a sandbox (via env-var allowlist), else "unknown". */
runtime_provider: string;
}

// Sandboxes/dev-environments std-env does not (reliably) name, identified by the
// env var they self-set. Verified empirically (E2B/Modal/Daytona live probes) and
// against provider docs (Codespaces/Gitpod/Replit). DAYTONA_SANDBOX_ID is observed
// at runtime but undocumented, so treat it as best-effort.
const SANDBOX_ENV_VARS: ReadonlyArray<readonly [string, string]> = [
["E2B_SANDBOX", "e2b"],
["MODAL_SANDBOX_ID", "modal"],
["MODAL_TASK_ID", "modal"],
["DAYTONA_SANDBOX_ID", "daytona"],
["CODESPACES", "codespaces"],
["GITPOD_WORKSPACE_ID", "gitpod"],
["REPL_ID", "replit"],
];

function detectContainer(): boolean {
// Docker and Podman expose a marker file; Firecracker/gVisor microVM sandboxes
// (e2b, modal) do not — those resolve via runtime_provider + is_tty instead.
try {
return existsSync("/.dockerenv") || existsSync("/run/.containerenv");
} catch {
return false;
}
}

function detectProvider(env: NodeJS.ProcessEnv): string {
if (provider) {
return provider.toLowerCase();
}
for (const [key, name] of SANDBOX_ENV_VARS) {
if (env[key]) {
return name;
}
}
return "unknown";
}

let cached: EnvironmentInfo | undefined;

/**
* Classify the execution environment for telemetry segmentation. The result is
* static for the life of the process, so it is computed once and memoized.
*/
export function classifyEnvironment(
env: NodeJS.ProcessEnv = process.env,
): EnvironmentInfo {
if (!cached) {
cached = {
is_container: detectContainer(),
is_tty: hasTTY,
runtime_provider: detectProvider(env),
};
}
return cached;
}
2 changes: 2 additions & 0 deletions packages/cli/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Command } from "@oclif/core";

import { detectAgent } from "./agent.js";
import { classifyEnvironment } from "./environment.js";
import { resolveInstallId } from "./identity.js";
import { getRunTelemetry, resetRunTelemetry } from "./run-telemetry.js";
import type { CommandFailureTelemetry } from "./errors.js";
Expand Down Expand Up @@ -171,6 +172,7 @@ function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry {
node_version: process.version,
platform: process.platform,
arch: process.arch,
...(telemetryEnabled ? classifyEnvironment(env) : {}),
$process_person_profile: false,
};

Expand Down
53 changes: 53 additions & 0 deletions packages/cli/tests/cli-telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,59 @@ describe("CLI telemetry", () => {
await telemetryServer.close();
}
});

it("tags every event with execution-environment properties", async () => {
const telemetryServer = await startTelemetryServer();

try {
const installIdFile = await tempInstallIdFile("browse-telemetry-env-");
const result = await runCli(["status"], {
env: telemetryEnv(telemetryServer, installIdFile),
});

expect(result.exitCode).toBe(0);

const payloads = telemetryPayloads(telemetryServer);
for (const payload of payloads) {
expect(typeof payload.properties.is_container).toBe("boolean");
expect(typeof payload.properties.runtime_provider).toBe("string");
// The test CLI runs with piped stdio, so there is no interactive TTY.
expect(payload.properties.is_tty).toBe(false);
}
} finally {
await telemetryServer.close();
}
});

it("classifies a known sandbox via its env var (runtime_provider)", async () => {
const telemetryServer = await startTelemetryServer();

try {
const installIdFile = await tempInstallIdFile(
"browse-telemetry-env-e2b-",
);
const result = await runCli(["status"], {
env: {
...telemetryEnv(telemetryServer, installIdFile),
// Neutralize CI/host providers std-env would otherwise report first.
GITHUB_ACTIONS: "",
GITLAB_CI: "",
VERCEL: "",
E2B_SANDBOX: "true",
},
});

expect(result.exitCode).toBe(0);

const invoked = findPayload(
telemetryPayloads(telemetryServer),
"cli.command_invoked",
);
expect(invoked.properties.runtime_provider).toBe("e2b");
} finally {
await telemetryServer.close();
}
});
});

async function startTelemetryServer(): Promise<FakeBrowserbaseServer> {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading