diff --git a/.changeset/browse-env-classification.md b/.changeset/browse-env-classification.md new file mode 100644 index 000000000..39319013c --- /dev/null +++ b/.changeset/browse-env-classification.md @@ -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). diff --git a/packages/cli/package.json b/packages/cli/package.json index 32f572273..be278043b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/lib/environment.ts b/packages/cli/src/lib/environment.ts new file mode 100644 index 000000000..4642dbb6e --- /dev/null +++ b/packages/cli/src/lib/environment.ts @@ -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 = [ + ["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; +} diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index fd6212569..2098f97fa 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -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"; @@ -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, }; diff --git a/packages/cli/tests/cli-telemetry.test.ts b/packages/cli/tests/cli-telemetry.test.ts index 1287fd1d5..35631865c 100644 --- a/packages/cli/tests/cli-telemetry.test.ts +++ b/packages/cli/tests/cli-telemetry.test.ts @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad9fd6318..066a0c2a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: semver: specifier: ^7.7.4 version: 7.8.0 + std-env: + specifier: ^4.1.0 + version: 4.1.0 tsx: specifier: ^4.20.6 version: 4.22.4