feat(telemetry): fingerprint sandbox runtime and agent vendor

Add two new properties to every CLI telemetry event so we can tell
managed-sandbox traffic (Codex Cloud, Claude Code Web, etc.) apart from
real developer laptops without geolocation guesswork:

- sandbox_runtime: 'gvisor' | 'firecracker' | 'docker' | 'kvm' | 'wsl' | null
  gVisor detected via kernel string ('4.19.0-gvisor' or legacy Sentry
  '4.4.0') + /proc/version. Firecracker via /dev/vsock + DMI sys_vendor.
  Docker reuses the existing /.dockerenv + cgroup probe.

- agent_runtime: claude_code | codex | cursor | copilot_agent | jules
  | replit | devin | aider | gemini_cli | hermes | openclaw | null
  Detected by the EXISTENCE of well-known vendor env vars only — values
  are never read. Hermes rule keys on HERMES_QUIET=1 (set unconditionally
  at hermes-agent/cli.py:50). openclaw rule keys on OPENCLAW_STATE_DIR
  or OPENCLAW_CONFIG_PATH (set explicitly in the spawned child env at
  openclaw/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts).

Drive-by cleanups required by fallow because system.ts and client.ts
fall into the audit scope of this PR:
- Extract detectWSL into platform.ts to break the system.ts ↔ agent_runtime.ts cycle.
- Refactor detectCI / getCIName into a single CI_PROVIDERS table.
- Dedupe flush / flushSync via a shared drainQueueToPayload helper.

Privacy posture unchanged: HYPERFRAMES_NO_TELEMETRY=1 still opts out;
disclosure in docs/packages/cli.mdx updated to enumerate the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-22 19:10:30 -04:00
committed by James Russo
co-authored by Claude Opus 4.7
parent e9c515dedd
commit 0c6012a2ec
6 changed files with 512 additions and 62 deletions
+29 -30
View File
@@ -17,7 +17,7 @@ const FLUSH_TIMEOUT_MS = 5_000;
// ---------------------------------------------------------------------------
interface EventProperties {
[key: string]: string | number | boolean | undefined;
[key: string]: string | number | boolean | null | undefined;
}
let eventQueue: Array<{
@@ -81,30 +81,41 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi
ci_name: sys.ci_name ?? undefined,
is_wsl: sys.is_wsl,
is_tty: sys.is_tty,
sandbox_runtime: sys.sandbox_runtime ?? undefined,
agent_runtime: sys.agent_runtime ?? undefined,
},
timestamp: new Date().toISOString(),
});
}
/**
* Drain the in-memory queue into a PostHog `/batch/` payload string.
* Returns null when there's nothing to send. Resets the queue as a side effect
* so callers can fire-and-forget the resulting payload.
*
* $ip:null tells PostHog not to record the request IP for any of these events.
* Server-side "Discard client IP data" is also enabled in project settings.
*/
function drainQueueToPayload(): string | null {
if (eventQueue.length === 0) return null;
const config = readConfig();
const batch = eventQueue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
return JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
}
/**
* Flush all queued events to PostHog via async HTTP POST.
* Called before normal process exit via `beforeExit`.
*/
export async function flush(): Promise<void> {
if (eventQueue.length === 0) {
return;
}
const config = readConfig();
const batch = eventQueue.map((e) => ({
event: e.event,
// $ip: null tells PostHog to not record the request IP for this event.
// Server-side "Discard client IP data" is also enabled in project settings.
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
const payload = drainQueueToPayload();
if (payload == null) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
@@ -113,7 +124,7 @@ export async function flush(): Promise<void> {
await fetch(`${POSTHOG_HOST}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json", Connection: "close" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
body: payload,
signal: controller.signal,
});
} catch {
@@ -129,20 +140,8 @@ export async function flush(): Promise<void> {
* so the parent process exits immediately without waiting.
*/
export function flushSync(): void {
if (eventQueue.length === 0) {
return;
}
const config = readConfig();
const batch = eventQueue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: config.anonymousId,
timestamp: e.timestamp,
}));
eventQueue = [];
const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
const payload = drainQueueToPayload();
if (payload == null) return;
try {
const { spawn } = require("node:child_process") as typeof import("node:child_process");