diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md new file mode 100644 index 000000000..2db9eacc3 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md @@ -0,0 +1,45 @@ +# Trajectory: Rope telemetry through CLI SDK and hosted backend + +> **Status:** ✅ Completed +> **Confidence:** 86% +> **Started:** June 3, 2026 at 05:42 AM +> **Completed:** June 3, 2026 at 05:58 AM + +--- + +## Summary + +Routed telemetry context through the CLI, SDK, cloud client, Rust broker, and hosted relaycast engine. Added app/surface/harness common properties, minimal SDK method/workflow events, enabled cloud request identity headers only when telemetry is enabled, and made hosted relaycast prefer Agent Relay anonymous ids for PostHog distinct ids. Verified telemetry, cloud, SDK, CLI, broker, and relaycast engine checks. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Propagated Agent Relay telemetry context through child process env and cloud request headers + +- **Chose:** Propagated Agent Relay telemetry context through child process env and cloud request headers +- **Reasoning:** CLI owns telemetry preferences and anonymous id creation, so cloud requests only include identity, surface, client version, and harness headers when telemetry is enabled and a PostHog key is configured. + +### Hosted relaycast telemetry prefers Agent Relay anonymous id + +- **Chose:** Hosted relaycast telemetry prefers Agent Relay anonymous id +- **Reasoning:** When the hosted backend receives X-Agent-Relay-Anonymous-Id, using it as PostHog distinctId ties CLI, SDK, and server-side events together while preserving workspace_id as an event property. + +### Kept SDK telemetry minimal + +- **Chose:** Kept SDK telemetry minimal +- **Reasoning:** SDK events capture stable method/workflow names, success, duration, and error class only, avoiding user content, paths, payloads, tokens, URLs, and argument values. + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Propagated Agent Relay telemetry context through child process env and cloud request headers: Propagated Agent Relay telemetry context through child process env and cloud request headers +- Hosted relaycast telemetry prefers Agent Relay anonymous id: Hosted relaycast telemetry prefers Agent Relay anonymous id +- Kept SDK telemetry minimal: Kept SDK telemetry minimal diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json new file mode 100644 index 000000000..9a9c78b37 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json @@ -0,0 +1,77 @@ +{ + "id": "traj_6kef2z1redhb", + "version": 1, + "task": { + "title": "Rope telemetry through CLI SDK and hosted backend" + }, + "status": "completed", + "startedAt": "2026-06-03T09:42:04.363Z", + "completedAt": "2026-06-03T09:58:39.713Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-06-03T09:55:45.392Z" + } + ], + "chapters": [ + { + "id": "chap_5vw63ytf4i4y", + "title": "Work", + "agentName": "default", + "startedAt": "2026-06-03T09:55:45.392Z", + "endedAt": "2026-06-03T09:58:39.713Z", + "events": [ + { + "ts": 1780480545393, + "type": "decision", + "content": "Propagated Agent Relay telemetry context through child process env and cloud request headers: Propagated Agent Relay telemetry context through child process env and cloud request headers", + "raw": { + "question": "Propagated Agent Relay telemetry context through child process env and cloud request headers", + "chosen": "Propagated Agent Relay telemetry context through child process env and cloud request headers", + "alternatives": [], + "reasoning": "CLI owns telemetry preferences and anonymous id creation, so cloud requests only include identity, surface, client version, and harness headers when telemetry is enabled and a PostHog key is configured." + }, + "significance": "high" + }, + { + "ts": 1780480545552, + "type": "decision", + "content": "Hosted relaycast telemetry prefers Agent Relay anonymous id: Hosted relaycast telemetry prefers Agent Relay anonymous id", + "raw": { + "question": "Hosted relaycast telemetry prefers Agent Relay anonymous id", + "chosen": "Hosted relaycast telemetry prefers Agent Relay anonymous id", + "alternatives": [], + "reasoning": "When the hosted backend receives X-Agent-Relay-Anonymous-Id, using it as PostHog distinctId ties CLI, SDK, and server-side events together while preserving workspace_id as an event property." + }, + "significance": "high" + }, + { + "ts": 1780480643216, + "type": "decision", + "content": "Kept SDK telemetry minimal: Kept SDK telemetry minimal", + "raw": { + "question": "Kept SDK telemetry minimal", + "chosen": "Kept SDK telemetry minimal", + "alternatives": [], + "reasoning": "SDK events capture stable method/workflow names, success, duration, and error class only, avoiding user content, paths, payloads, tokens, URLs, and argument values." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Routed telemetry context through the CLI, SDK, cloud client, Rust broker, and hosted relaycast engine. Added app/surface/harness common properties, minimal SDK method/workflow events, enabled cloud request identity headers only when telemetry is enabled, and made hosted relaycast prefer Agent Relay anonymous ids for PostHog distinct ids. Verified telemetry, cloud, SDK, CLI, broker, and relaycast engine checks.", + "approach": "Standard approach", + "confidence": 0.86 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "30d18e9ef73995f6c3ac4044f59d9261d17875f8", + "endRef": "30d18e9ef73995f6c3ac4044f59d9261d17875f8" + } +} diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml index f5cbd29d6..8a0ec604e 100644 --- a/.github/workflows/package-validation.yml +++ b/.github/workflows/package-validation.yml @@ -127,7 +127,7 @@ jobs: # Check dist files exist (skip non-Node package directories). # broker-* packages ship a Rust-built binary in bin/, not a JS # dist — they live under packages/ only for workspace linkage. - SKIP_PACKAGES="build-plans brand broker-darwin-arm64 broker-darwin-x64 broker-linux-arm64 broker-linux-x64 broker-win32-x64 personas" + SKIP_PACKAGES="build-plans brand broker-darwin-arm64 broker-darwin-x64 broker-linux-arm64 broker-linux-x64 broker-win32-x64 personas telemetry" for pkg_dir in packages/*/; do pkg_name=$(basename "$pkg_dir") if [ ! -f "$pkg_dir/package.json" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index c4dc226f3..3a1d49d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@agent-relay/sdk` adds the `relay.webhooks` namespace: `createInbound({ channel })` returns `{ url, token }` for posting `{ message, author }` into a channel, and `subscribe({ url, events, secret, headers })` for outbound HMAC-signed event delivery. - `@agent-relay/sdk` agent clients send via `sendMessage({ to })` (`#channel`, `@handle`, or an array of `@handle`s for a group DM), `reply({ messageId })`, and `react({ messageId, emoji })`; every message exposes `messageId`. - `@agent-relay/harnesses` adds `createHuman({ relay, name })` (self-registers a human, returns the live client) and re-exports `defineHarness` plus the harness contract types. +- `agent-relay` forwards CLI origin, orchestrator harness, and distinct client identity context to hosted Relaycast so backend telemetry can distinguish CLI/SDK traffic from raw API calls. ### Changed @@ -45,7 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `README.md` and `packages/sdk/README.md` now present Agent Relay around three public SDK categories: messaging, delivery, and actions. - `@agent-relay/sdk` actions accept Zod-compatible `safeParse` schemas alongside JSON-schema-lite, and `DeliveryRunner` can deliver inbox items to session targets through `receiveMessage(...)`. - `agent-relay` keeps default commands focused on messaging, MCP, diagnostics, setup, and telemetry; managed harness lifecycle now lives under `agent-relay driver ...`. -- Root builds now validate the simplified core package set: config, utils, telemetry, SDK, harness-driver, harnesses, and CLI. +- Root builds now validate the simplified core package set: config, utils, SDK, harness-driver, harnesses, and CLI. +- `@agent-relay/sdk` no longer emits client-side analytics or depends on `@agent-relay/telemetry`; SDK/API attribution uses Relaycast origin metadata instead. +- `agent-relay` CLI telemetry now posts through the hosted ingestion proxy at `https://i.agentrelay.com` by default. + +### Deprecated + +- `@agent-relay/telemetry` is deprecated as a public npm package; telemetry implementation is now internal to the `agent-relay` CLI. - `agent-relay mcp`: Agent Relay now ships its own MCP stdio server with underscore tool names such as `post_message` and `add_reaction`, and generated MCP configs use `npx -y agent-relay mcp`. - `agent-relay mcp`: renamed the bundled implementation and command override to Agent Relay MCP (`AGENT_RELAY_MCP_COMMAND`). - `agent-relay up`: broker startup no longer writes external MCP entries to project `.mcp.json`; spawned agents receive the MCP server through launch-time configuration. diff --git a/crates/broker/src/telemetry.rs b/crates/broker/src/telemetry.rs index 72a175d8a..3f43b187b 100644 --- a/crates/broker/src/telemetry.rs +++ b/crates/broker/src/telemetry.rs @@ -22,6 +22,8 @@ use tokio::sync::mpsc; /// binaries report to the production PostHog project. const POSTHOG_API_KEY: Option<&str> = option_env!("AGENT_RELAY_POSTHOG_KEY"); const POSTHOG_HOST: &str = "https://us.i.posthog.com"; +const UNKNOWN_ORCHESTRATOR_HARNESS: &str = "unknown"; +const ORCHESTRATOR_HARNESS_ENV: &str = "AGENT_RELAY_ORCHESTRATOR_HARNESS"; /// Returns the configured PostHog key iff it's non-empty. Empty strings are /// treated the same as "unset" so an accidentally-blank secret doesn't trip @@ -297,6 +299,144 @@ fn env_nonempty(key: &str) -> Option { }) } +fn sanitize_orchestrator_harness(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + if !trimmed.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + ' ' | '.' | '_' | '-' | '/' | '(' | ')' | ':' | '=' | ';' | ',' | '+' + ) + }) { + return None; + } + Some(trimmed.chars().take(120).collect::().to_lowercase()) +} + +fn infer_harness_from_command(command: &str) -> Option<&'static str> { + let lower = command.to_lowercase(); + let normalized = lower.replace('\\', "/"); + let base = normalized + .rsplit('/') + .next() + .unwrap_or(normalized.as_str()) + .trim_end_matches(".exe"); + let base = base + .strip_suffix(".cmd") + .or_else(|| base.strip_suffix(".bat")) + .unwrap_or(base); + + if base == "claude" || lower.contains("claude-code") { + return Some("claude-code"); + } + if base == "codex" || normalized.contains("/codex") { + return Some("codex"); + } + if base == "cursor" || base == "cursor-agent" || lower.contains("cursor") { + return Some("cursor"); + } + if base == "gemini" || base == "gemini-cli" || lower.contains("gemini-cli") { + return Some("gemini-cli"); + } + if base == "aider" || lower.contains("aider") { + return Some("aider"); + } + if base == "opencode" || lower.contains("opencode") { + return Some("opencode"); + } + if base == "goose" || lower.contains("goose") { + return Some("goose"); + } + if base == "droid" || lower.contains("droid") { + return Some("droid"); + } + if base == "amp" || normalized.contains("/amp") { + return Some("amp"); + } + if lower.contains("copilot") { + return Some("github-copilot"); + } + if base == "zed" || lower.contains("zed") { + return Some("zed"); + } + + None +} + +#[cfg(unix)] +fn lookup_process_info(pid: i32) -> Option<(i32, String)> { + if pid <= 0 { + return None; + } + let output = std::process::Command::new("ps") + .args(["-o", "ppid=", "-o", "comm=", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + let mut parts = stdout.split_whitespace(); + let ppid = parts.next()?.parse::().ok()?; + let command = parts.collect::>().join(" "); + if command.is_empty() { + None + } else { + Some((ppid, command)) + } +} + +#[cfg(unix)] +fn detect_process_orchestrator_harness() -> Option { + use std::collections::HashSet; + + let mut pid = nix::unistd::getppid().as_raw(); + let mut seen = HashSet::new(); + + for _ in 0..8 { + if pid <= 0 || !seen.insert(pid) { + break; + } + let Some((ppid, command)) = lookup_process_info(pid) else { + break; + }; + if let Some(harness) = infer_harness_from_command(&command) { + return Some(harness.to_string()); + } + if ppid == pid { + break; + } + pid = ppid; + } + + None +} + +#[cfg(not(unix))] +fn detect_process_orchestrator_harness() -> Option { + None +} + +fn detect_orchestrator_harness() -> String { + for key in [ + ORCHESTRATOR_HARNESS_ENV, + "RELAYCAST_HARNESS", + "X_RELAYCAST_HARNESS", + ] { + if let Ok(value) = std::env::var(key) { + if let Some(harness) = sanitize_orchestrator_harness(&value) { + return harness; + } + } + } + + detect_process_orchestrator_harness() + .unwrap_or_else(|| UNKNOWN_ORCHESTRATOR_HARNESS.to_string()) +} + /// Best-effort OS release string for telemetry tagging. Shells out to /// `uname -r` on unix (broker is unix-only anyway); returns `None` on /// failure so we just omit the property rather than risking a crash. @@ -344,6 +484,8 @@ pub struct TelemetryClient { /// OS release string (best-effort via `uname -r`, empty on failure / /// platforms where that isn't meaningful). os_version: Option, + /// Harness or agent CLI that appears to be driving Agent Relay. + orchestrator_harness: String, } #[derive(Debug, Serialize)] @@ -372,6 +514,7 @@ impl TelemetryClient { cli_version: None, sdk_version: None, os_version: None, + orchestrator_harness: UNKNOWN_ORCHESTRATOR_HARNESS.to_string(), } } @@ -419,6 +562,7 @@ impl TelemetryClient { cli_version: env_nonempty("AGENT_RELAY_CLI_VERSION"), sdk_version: env_nonempty("AGENT_RELAY_SDK_VERSION"), os_version: detect_os_version(), + orchestrator_harness: detect_orchestrator_harness(), } } @@ -444,6 +588,12 @@ impl TelemetryClient { // as a back-compat alias that mirrors `broker_version` here. if let Some(obj) = props.as_object_mut() { let broker_version = crate::util::version::broker_version(); + obj.insert("app".to_string(), json!("broker")); + obj.insert("surface".to_string(), json!("broker")); + obj.insert( + "orchestrator_harness".to_string(), + json!(self.orchestrator_harness.as_str()), + ); obj.insert("agent_relay_version".to_string(), json!(broker_version)); obj.insert("broker_version".to_string(), json!(broker_version)); if let Some(ref v) = self.cli_version { @@ -605,6 +755,7 @@ mod tests { cli_version: None, sdk_version: None, os_version: None, + orchestrator_harness: UNKNOWN_ORCHESTRATOR_HARNESS.to_string(), }; assert!(!client.is_enabled()); client.track(TelemetryEvent::BrokerStart); @@ -688,6 +839,33 @@ mod tests { std::env::remove_var("AGENT_RELAY_TEST_TELEMETRY_SET"); } + #[test] + fn sanitize_orchestrator_harness_normalizes_safe_values() { + assert_eq!( + sanitize_orchestrator_harness(" Codex CLI "), + Some("codex cli".to_string()) + ); + assert_eq!(sanitize_orchestrator_harness("bad\nvalue"), None); + assert_eq!(sanitize_orchestrator_harness(""), None); + } + + #[test] + fn infer_harness_from_command_recognizes_known_parents() { + assert_eq!( + infer_harness_from_command("/usr/local/bin/codex"), + Some("codex") + ); + assert_eq!( + infer_harness_from_command("/Applications/Cursor.app/Contents/MacOS/Cursor"), + Some("cursor") + ); + assert_eq!( + infer_harness_from_command(r"C:\Users\will\AppData\Roaming\npm\gemini.cmd"), + Some("gemini-cli") + ); + assert_eq!(infer_harness_from_command("/usr/bin/zsh"), None); + } + #[test] fn prefs_default_is_enabled() { let prefs = TelemetryPrefs::default(); diff --git a/package-lock.json b/package-lock.json index 3eac1e317..0cd8934d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17832,13 +17832,13 @@ "@agent-relay/config": "7.1.1", "@agent-relay/harness-driver": "7.1.1", "@agent-relay/sdk": "7.1.1", - "@agent-relay/telemetry": "7.1.1", "@agent-relay/utils": "7.1.1", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^2.5.1", "commander": "^12.1.0", "dotenv": "^17.2.3", "esbuild": "^0.27.2", + "posthog-node": "^5.29.2", "zod": "^3.23.8" }, "bin": { @@ -17930,13 +17930,7 @@ "packages/telemetry": { "name": "@agent-relay/telemetry", "version": "7.1.1", - "dependencies": { - "posthog-node": "^5.29.2" - }, - "devDependencies": { - "@types/node": "^22.19.3", - "vitest": "^4.1.0" - } + "deprecated": "@agent-relay/telemetry is deprecated. Telemetry is now internal to the agent-relay CLI." }, "packages/utils": { "name": "@agent-relay/utils", diff --git a/package.json b/package.json index f32726533..58e463ddb 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "web" ], "scripts": { - "typecheck": "npm run build:config && npm run build:cloud && npm run build:utils && npm run build:policy && npm run build:telemetry && npm run build:sdk && npm run build:harness-driver && npm run build:harnesses && cd packages/cli && npx tsc --noEmit", + "typecheck": "npm run build:config && npm run build:cloud && npm run build:utils && npm run build:policy && npm run build:sdk && npm run build:harness-driver && npm run build:harnesses && cd packages/cli && npx tsc --noEmit", "build": "npm run clean && npm run build:rust && npm run build:core", - "build:core": "npm run build:config && npm run build:cloud && npm run build:utils && npm run build:policy && npm run build:telemetry && npm run build:sdk && npm run build:harness-driver && npm run build:harnesses && npm run build:cli", + "build:core": "npm run build:config && npm run build:cloud && npm run build:utils && npm run build:policy && npm run build:sdk && npm run build:harness-driver && npm run build:harnesses && npm run build:cli", "build:packages": "npm run build:core", "build:packages:watch": "echo 'Package watch build was removed with the simplified core surface. Use package-specific watch commands instead.'", "build:cli": "npm --prefix packages/cli run build", diff --git a/packages/cli/package.json b/packages/cli/package.json index e55e2b167..03226ff90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,13 +47,13 @@ "@agent-relay/config": "7.1.1", "@agent-relay/harness-driver": "7.1.1", "@agent-relay/sdk": "7.1.1", - "@agent-relay/telemetry": "7.1.1", "@agent-relay/utils": "7.1.1", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^2.5.1", "commander": "^12.1.0", "dotenv": "^17.2.3", "esbuild": "^0.27.2", + "posthog-node": "^5.29.2", "zod": "^3.23.8" }, "engines": { diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index d718887f7..f5e9db371 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -9,7 +9,15 @@ import { Command } from 'commander'; import { config as dotenvConfig } from 'dotenv'; import { checkForUpdatesInBackground } from '@agent-relay/utils'; -import { initTelemetry, shutdown as shutdownTelemetry, track } from '@agent-relay/telemetry'; +import { + ORCHESTRATOR_HARNESS_ENV, + detectOrchestratorHarness, + getDistinctId, + initTelemetry, + isEnabled as isTelemetryEnabled, + shutdown as shutdownTelemetry, + track, +} from './telemetry/index.js'; import { CliExit } from './lib/exit.js'; import { errorClassName } from './lib/telemetry-helpers.js'; @@ -80,6 +88,14 @@ function resolveSdkVersion(): string | undefined { export const SDK_VERSION = resolveSdkVersion(); +const AGENT_RELAY_DISTINCT_ID_ENV = 'AGENT_RELAY_DISTINCT_ID'; +const TELEMETRY_SURFACE_ENV = 'AGENT_RELAY_TELEMETRY_SURFACE'; +const TELEMETRY_CLIENT_ENV = 'AGENT_RELAY_TELEMETRY_CLIENT'; + +function hasConfiguredTelemetryKey(): boolean { + return Boolean(process.env.POSTHOG_API_KEY?.trim() || process.env.AGENT_RELAY_POSTHOG_KEY?.trim()); +} + function resolveProgramName(argv: string[] = process.argv): string { const invocationPath = String(argv[1] ?? '').trim(); if (!invocationPath) { @@ -99,13 +115,29 @@ function resolveProgramName(argv: string[] = process.argv): string { * We only set these if they're not already present — so a parent caller that * has set its own values (e.g. in tests or in nested CLI invocations) wins. */ -function propagateVersionsToChildren(): void { +function propagateTelemetryContextToChildren(): string { + const orchestratorHarness = detectOrchestratorHarness(); + if (!process.env.AGENT_RELAY_CLI_VERSION) { process.env.AGENT_RELAY_CLI_VERSION = VERSION; } if (SDK_VERSION && !process.env.AGENT_RELAY_SDK_VERSION) { process.env.AGENT_RELAY_SDK_VERSION = SDK_VERSION; } + if (!process.env[ORCHESTRATOR_HARNESS_ENV]) { + process.env[ORCHESTRATOR_HARNESS_ENV] = orchestratorHarness; + } + if (!process.env[TELEMETRY_SURFACE_ENV]) { + process.env[TELEMETRY_SURFACE_ENV] = 'cli'; + } + if (!process.env[TELEMETRY_CLIENT_ENV]) { + process.env[TELEMETRY_CLIENT_ENV] = 'agent-relay'; + } + if (!process.env[AGENT_RELAY_DISTINCT_ID_ENV] && hasConfiguredTelemetryKey() && isTelemetryEnabled()) { + process.env[AGENT_RELAY_DISTINCT_ID_ENV] = getDistinctId(); + } + + return orchestratorHarness; } // Commands that should skip the update check / first-run-notice entirely. @@ -315,13 +347,16 @@ function collectTopLevelVerbs(program: Command): Set { export async function runCli(argv: string[] = process.argv): Promise { maybeRunUpdateCheck(VERSION, argv); - propagateVersionsToChildren(); + const orchestratorHarness = propagateTelemetryContextToChildren(); if (!shouldSkipTelemetryInit(argv)) { initTelemetry({ showNotice: true, cliVersion: VERSION, sdkVersion: SDK_VERSION, + app: 'cli', + surface: 'cli', + orchestratorHarness, }); } diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index d67883fe0..ba4c0b20e 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -29,7 +29,7 @@ vi.mock('@agent-relay/cloud', () => ({ syncWorkflowPatch: (...args: unknown[]) => cloudMocks.syncWorkflowPatch(...args), })); -vi.mock('@agent-relay/telemetry', () => ({ +vi.mock('../telemetry/index.js', () => ({ track: vi.fn(), })); diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 27e7c7533..3b5ec7492 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Command, InvalidArgumentError } from 'commander'; -import { track } from '@agent-relay/telemetry'; import { ensureAuthenticated, @@ -29,6 +28,7 @@ import { import { defaultExit } from '../lib/exit.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; +import { track } from '../telemetry/index.js'; const CLOUD_SYNC_PATCH_EXCLUDES = [ '.agent-bin/**', diff --git a/packages/cli/src/cli/commands/setup.ts b/packages/cli/src/cli/commands/setup.ts index 7cc5d3c6c..2257dc91d 100644 --- a/packages/cli/src/cli/commands/setup.ts +++ b/packages/cli/src/cli/commands/setup.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { enableTelemetry, disableTelemetry, getStatus, isDisabledByEnv } from '@agent-relay/telemetry'; import { defaultExit } from '../lib/exit.js'; +import { enableTelemetry, disableTelemetry, getStatus, isDisabledByEnv } from '../telemetry/index.js'; type ExitFn = (code: number) => never; @@ -40,7 +40,7 @@ function runTelemetryDefault(action: string | undefined, io: SetupIo): void { } enableTelemetry(); io.log('Telemetry enabled'); - io.log('Anonymous usage data will be collected to improve Agent Relay.'); + io.log('Usage telemetry will be collected to improve Agent Relay.'); return; } if (action === 'disable') { @@ -56,7 +56,7 @@ function runTelemetryDefault(action: string | undefined, io: SetupIo): void { if (status.disabledByEnv) { io.log('(Disabled via AGENT_RELAY_TELEMETRY_DISABLED or DO_NOT_TRACK environment variable)'); } - io.log(`Anonymous ID: ${status.anonymousId}`); + io.log(`Distinct ID: ${status.distinctId}`); if (status.notifiedAt) { io.log(`First run notice shown: ${new Date(status.notifiedAt).toLocaleString()}`); } diff --git a/packages/cli/src/cli/entrypoint.test.ts b/packages/cli/src/cli/entrypoint.test.ts index cd4672fe1..a16633e2e 100644 --- a/packages/cli/src/cli/entrypoint.test.ts +++ b/packages/cli/src/cli/entrypoint.test.ts @@ -15,8 +15,12 @@ function mockBootstrapDependencies(parseSpy: ReturnType): void { vi.doMock('@agent-relay/utils', () => ({ checkForUpdatesInBackground: vi.fn(), })); - vi.doMock('@agent-relay/telemetry', () => ({ + vi.doMock('./telemetry/index.js', () => ({ + ORCHESTRATOR_HARNESS_ENV: 'AGENT_RELAY_ORCHESTRATOR_HARNESS', + detectOrchestratorHarness: vi.fn(() => 'unknown'), + getDistinctId: vi.fn(() => 'distinct-test'), initTelemetry: vi.fn(), + isEnabled: vi.fn(() => false), track: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), })); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index fe72b6506..c737948ee 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -2,9 +2,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; -import { track } from '@agent-relay/telemetry'; import type { CoreDependencies, CoreProjectPaths, CoreRelay, SpawnedProcess } from '../commands/core.js'; +import { track } from '../telemetry/index.js'; import { buildBundledAgentRelayMcpCommand } from './agent-relay-mcp-command.js'; import { errorClassName } from './telemetry-helpers.js'; diff --git a/packages/cli/src/cli/lib/exit.ts b/packages/cli/src/cli/lib/exit.ts index 2e1372d89..d7e431b67 100644 --- a/packages/cli/src/cli/lib/exit.ts +++ b/packages/cli/src/cli/lib/exit.ts @@ -15,7 +15,7 @@ * `ExitSignal`), so this default only kicks in at production call sites. */ -import { shutdown as shutdownTelemetry } from '@agent-relay/telemetry'; +import { shutdown as shutdownTelemetry } from '../telemetry/index.js'; export class CliExit extends Error { /** Intended process exit code. */ diff --git a/packages/telemetry/src/client.ts b/packages/cli/src/cli/telemetry/client.ts similarity index 74% rename from packages/telemetry/src/client.ts rename to packages/cli/src/cli/telemetry/client.ts index b08030580..b15d059f0 100644 --- a/packages/telemetry/src/client.ts +++ b/packages/cli/src/cli/telemetry/client.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { isTelemetryEnabled, - getAnonymousId, + getDistinctId, wasNotified, markNotified, isDisabledByEnv, @@ -17,10 +17,11 @@ import { } from './config.js'; import type { CommonProperties, TelemetryEventName, TelemetryEventMap } from './events.js'; import { getPostHogConfig } from './posthog-config.js'; +import { detectOrchestratorHarness, UNKNOWN_ORCHESTRATOR_HARNESS } from './orchestrator-harness.js'; let client: PostHog | null = null; let commonProps: CommonProperties | null = null; -let anonymousId: string | null = null; +let distinctId: string | null = null; let initialized = false; function findPackageJson(startDir: string): string | null { @@ -60,6 +61,9 @@ function buildCommonProperties(versions: { cliVersion?: string; sdkVersion?: string; brokerVersion?: string; + app?: string; + surface?: string; + orchestratorHarness?: string; }): CommonProperties { // The primary version depends on who's emitting: prefer CLI > broker > SDK // > fallback. This keeps `agent_relay_version` meaningful for existing @@ -69,6 +73,26 @@ function buildCommonProperties(versions: { versions.cliVersion ?? versions.brokerVersion ?? versions.sdkVersion ?? getFallbackVersion(); return { + app: + versions.app ?? + (versions.cliVersion + ? 'cli' + : versions.brokerVersion + ? 'broker' + : versions.sdkVersion + ? 'sdk' + : 'unknown'), + surface: + versions.surface ?? + (versions.cliVersion + ? 'cli' + : versions.brokerVersion + ? 'broker' + : versions.sdkVersion + ? 'sdk' + : 'unknown'), + orchestrator_harness: + versions.orchestratorHarness ?? detectOrchestratorHarness() ?? UNKNOWN_ORCHESTRATOR_HARNESS, agent_relay_version: primary, ...(versions.cliVersion ? { cli_version: versions.cliVersion } : {}), ...(versions.sdkVersion ? { sdk_version: versions.sdkVersion } : {}), @@ -89,7 +113,7 @@ function showFirstRunNotice(): void { } console.log(''); - console.log('Agent Relay collects anonymous usage data to improve the product.'); + console.log('Agent Relay collects usage telemetry to improve the product.'); console.log('Run `agent-relay telemetry disable` to opt out.'); console.log('Learn more: https://agentrelay.com/telemetry'); console.log(''); @@ -110,21 +134,26 @@ export interface InitTelemetryOptions { sdkVersion?: string; /** `agent-relay-broker` Rust binary version, if known. */ brokerVersion?: string; + /** Component emitting telemetry, e.g. `cli`, `broker`, or `sdk`. */ + app?: string; + /** Product surface responsible for telemetry, e.g. `cli`, `cloud`, or `sdk`. */ + surface?: string; + /** Parent harness driving Agent Relay, if already detected by the caller. */ + orchestratorHarness?: string; } export function initTelemetry(options: InitTelemetryOptions = {}): void { if (initialized) return; initialized = true; + const posthogConfig = getPostHogConfig(); + if (!posthogConfig) return; + if (options.showNotice !== false) { showFirstRunNotice(); } - if (!isTelemetryEnabled()) return; - const posthogConfig = getPostHogConfig(); - if (!posthogConfig) return; - client = new PostHog(posthogConfig.apiKey, { host: posthogConfig.host, flushAt: 10, @@ -136,15 +165,21 @@ export function initTelemetry(options: InitTelemetryOptions = {}): void { cliVersion: options.cliVersion, sdkVersion: options.sdkVersion, brokerVersion: options.brokerVersion, + app: options.app, + surface: options.surface, + orchestratorHarness: options.orchestratorHarness, }); - anonymousId = getAnonymousId(); + distinctId = getDistinctId(); } -export function track(event: E, properties?: TelemetryEventMap[E]): void { - if (!client || !commonProps || !anonymousId) return; +export function track( + event: E, + properties?: TelemetryEventMap[E] & Partial +): void { + if (!client || !commonProps || !distinctId) return; client.capture({ - distinctId: anonymousId, + distinctId, event, properties: { ...commonProps, @@ -163,7 +198,7 @@ export async function shutdown(): Promise { } finally { client = null; commonProps = null; - anonymousId = null; + distinctId = null; initialized = false; } } @@ -172,19 +207,19 @@ export function isEnabled(): boolean { return isTelemetryEnabled(); } -export { getAnonymousId }; +export { getDistinctId }; export function getStatus(): { enabled: boolean; disabledByEnv: boolean; - anonymousId: string; + distinctId: string; notifiedAt: string | undefined; } { const prefs = loadPrefs(); return { enabled: isTelemetryEnabled(), disabledByEnv: isDisabledByEnv(), - anonymousId: prefs.anonymousId, + distinctId: prefs.distinctId, notifiedAt: prefs.notifiedAt, }; } diff --git a/packages/cli/src/cli/telemetry/config.test.ts b/packages/cli/src/cli/telemetry/config.test.ts new file mode 100644 index 000000000..749e7edc8 --- /dev/null +++ b/packages/cli/src/cli/telemetry/config.test.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + disableTelemetry, + enableTelemetry, + getDistinctId, + getPrefsPath, + isDisabledByEnv, + isTelemetryEnabled, + loadPrefs, + markNotified, + savePrefs, + wasNotified, +} from './config.js'; + +describe('telemetry preferences', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-telemetry-prefs-')); + vi.stubEnv('AGENT_RELAY_DATA_DIR', dataDir); + vi.stubEnv('AGENT_RELAY_TELEMETRY_DISABLED', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('creates default enabled preferences with a stable distinct id', () => { + const prefs = loadPrefs(); + + expect(prefs.enabled).toBe(true); + expect(prefs.notifiedAt).toBeUndefined(); + expect(prefs.distinctId).toMatch(/^[a-f0-9]{16}$/); + expect(getDistinctId()).toBe(prefs.distinctId); + }); + + it('loads legacy anonymous ids and writes the normalized field', () => { + fs.mkdirSync(path.dirname(getPrefsPath()), { recursive: true }); + fs.writeFileSync( + getPrefsPath(), + JSON.stringify({ + enabled: false, + notifiedAt: '2026-06-03T00:00:00.000Z', + anonymousId: 'legacy-id', + }), + 'utf-8' + ); + + expect(loadPrefs()).toEqual({ + enabled: false, + notifiedAt: '2026-06-03T00:00:00.000Z', + distinctId: 'legacy-id', + }); + expect(JSON.parse(fs.readFileSync(getPrefsPath(), 'utf-8'))).toEqual({ + enabled: false, + notifiedAt: '2026-06-03T00:00:00.000Z', + distinctId: 'legacy-id', + }); + }); + + it('honors environment opt-out before stored preferences', () => { + savePrefs({ enabled: true, distinctId: 'stored-id' }); + + vi.stubEnv('AGENT_RELAY_TELEMETRY_DISABLED', 'true'); + expect(isDisabledByEnv()).toBe(true); + expect(isTelemetryEnabled()).toBe(false); + + vi.stubEnv('AGENT_RELAY_TELEMETRY_DISABLED', '0'); + vi.stubEnv('DO_NOT_TRACK', '1'); + expect(isDisabledByEnv()).toBe(true); + expect(isTelemetryEnabled()).toBe(false); + }); + + it('persists enable, disable, and first-run notification state', () => { + disableTelemetry(); + expect(loadPrefs().enabled).toBe(false); + expect(isTelemetryEnabled()).toBe(false); + + enableTelemetry(); + expect(loadPrefs().enabled).toBe(true); + expect(isTelemetryEnabled()).toBe(true); + expect(wasNotified()).toBe(false); + + markNotified(); + expect(wasNotified()).toBe(true); + expect(loadPrefs().notifiedAt).toEqual(expect.any(String)); + }); +}); diff --git a/packages/telemetry/src/config.ts b/packages/cli/src/cli/telemetry/config.ts similarity index 78% rename from packages/telemetry/src/config.ts rename to packages/cli/src/cli/telemetry/config.ts index 1cfffa960..d1b052e05 100644 --- a/packages/telemetry/src/config.ts +++ b/packages/cli/src/cli/telemetry/config.ts @@ -5,17 +5,19 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { createAnonymousId } from './machine-id.js'; +import { createDistinctId } from './machine-id.js'; export interface TelemetryPrefs { /** Whether telemetry is enabled (default: true) */ enabled: boolean; /** ISO timestamp when user was shown the first-run notice */ notifiedAt?: string; - /** Anonymous ID derived from machine-id hash */ - anonymousId: string; + /** Stable hashed machine identifier used as the PostHog distinctId. */ + distinctId: string; } +type StoredTelemetryPrefs = Partial & { anonymousId?: string }; + export function getPrefsPath(): string { const configDir = process.env.AGENT_RELAY_DATA_DIR || path.join(os.homedir(), '.agentworkforce/relay'); return path.join(configDir, 'telemetry.json'); @@ -27,18 +29,19 @@ export function loadPrefs(): TelemetryPrefs { try { if (fs.existsSync(prefsPath)) { const content = fs.readFileSync(prefsPath, 'utf-8'); - const prefs = JSON.parse(content) as Partial; - - if (!prefs.anonymousId) { - prefs.anonymousId = createAnonymousId(); - savePrefs(prefs as TelemetryPrefs); - } - - return { + const prefs = JSON.parse(content) as StoredTelemetryPrefs; + const distinctId = prefs.distinctId ?? prefs.anonymousId ?? createDistinctId(); + const normalized: TelemetryPrefs = { enabled: prefs.enabled ?? true, notifiedAt: prefs.notifiedAt, - anonymousId: prefs.anonymousId, + distinctId, }; + + if (prefs.distinctId !== distinctId || prefs.anonymousId !== undefined) { + savePrefs(normalized); + } + + return normalized; } } catch { // Fall through to defaults @@ -46,7 +49,7 @@ export function loadPrefs(): TelemetryPrefs { return { enabled: true, - anonymousId: createAnonymousId(), + distinctId: createDistinctId(), }; } @@ -107,6 +110,6 @@ export function wasNotified(): boolean { return loadPrefs().notifiedAt !== undefined; } -export function getAnonymousId(): string { - return loadPrefs().anonymousId; +export function getDistinctId(): string { + return loadPrefs().distinctId; } diff --git a/packages/telemetry/src/events.ts b/packages/cli/src/cli/telemetry/events.ts similarity index 95% rename from packages/telemetry/src/events.ts rename to packages/cli/src/cli/telemetry/events.ts index bcc57a935..5e52dfb9a 100644 --- a/packages/telemetry/src/events.ts +++ b/packages/cli/src/cli/telemetry/events.ts @@ -21,6 +21,12 @@ /** Source of spawn/release action */ export type ActionSource = 'human_cli' | 'human_dashboard' | 'agent' | 'protocol'; +/** Component that emitted the telemetry event. */ +export type TelemetryApp = 'cli' | 'broker' | 'sdk' | 'relaycast-server' | 'dashboard' | 'unknown'; + +/** User-facing product surface responsible for the event. */ +export type TelemetrySurface = 'cli' | 'broker' | 'sdk' | 'cloud' | 'mcp' | 'dashboard' | 'unknown'; + /** * Reason for agent release. * @@ -53,6 +59,12 @@ export type ReleaseReason = string; * broker-originated events. */ export interface CommonProperties { + /** Component that emitted the event, e.g. `cli`, `broker`, `sdk`. */ + app: TelemetryApp | string; + /** Product surface responsible for the event, e.g. `cli`, `sdk`, `cloud`. */ + surface: TelemetrySurface | string; + /** Parent harness driving Agent Relay, e.g. `claude-code`, `codex`, `cursor`, or `unknown`. */ + orchestrator_harness: string; /** Back-compat alias — same value as the emitter's primary version. */ agent_relay_version: string; /** `agent-relay` CLI version, if known to the emitter. */ diff --git a/packages/telemetry/src/index.ts b/packages/cli/src/cli/telemetry/index.ts similarity index 64% rename from packages/telemetry/src/index.ts rename to packages/cli/src/cli/telemetry/index.ts index a82c4c376..a63a0cddb 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/cli/src/cli/telemetry/index.ts @@ -1,5 +1,5 @@ /** - * @agent-relay/telemetry - Anonymous usage analytics (opt-out via env or CLI) + * CLI telemetry internals - usage analytics (opt-out via env or CLI) */ export { @@ -7,7 +7,7 @@ export { track, shutdown, isEnabled, - getAnonymousId, + getDistinctId, getStatus, type InitTelemetryOptions, } from './client.js'; @@ -28,6 +28,8 @@ export { export type { CommonProperties, ActionSource, + TelemetryApp, + TelemetrySurface, ReleaseReason, WorkflowFileType, BrokerStartEvent, @@ -50,4 +52,14 @@ export type { TelemetryEventMap, } from './events.js'; -export { loadMachineId, createAnonymousId, getMachineIdPath } from './machine-id.js'; +export { loadMachineId, createDistinctId, getMachineIdPath } from './machine-id.js'; + +export { + ORCHESTRATOR_HARNESS_ENV, + UNKNOWN_ORCHESTRATOR_HARNESS, + detectOrchestratorHarness, + inferHarnessFromCommand, + sanitizeOrchestratorHarness, + type DetectOrchestratorHarnessOptions, + type ProcessInfo, +} from './orchestrator-harness.js'; diff --git a/packages/cli/src/cli/telemetry/machine-id.test.ts b/packages/cli/src/cli/telemetry/machine-id.test.ts new file mode 100644 index 000000000..bf70bbe87 --- /dev/null +++ b/packages/cli/src/cli/telemetry/machine-id.test.ts @@ -0,0 +1,45 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDistinctId, getMachineIdPath, loadMachineId } from './machine-id.js'; + +describe('telemetry machine id', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-telemetry-machine-')); + vi.stubEnv('AGENT_RELAY_DATA_DIR', dataDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('reads an existing machine id and derives a truncated sha256 distinct id', () => { + fs.mkdirSync(path.dirname(getMachineIdPath()), { recursive: true }); + fs.writeFileSync(getMachineIdPath(), 'machine-id-123\n', 'utf-8'); + + expect(loadMachineId()).toBe('machine-id-123'); + expect(createDistinctId()).toBe( + createHash('sha256').update('machine-id-123').digest('hex').substring(0, 16) + ); + }); + + it('creates a machine id when none exists', () => { + const machineId = loadMachineId(); + + expect(machineId).toMatch(new RegExp(`^${os.hostname()}-[a-f0-9]{16}$`)); + expect(fs.readFileSync(getMachineIdPath(), 'utf-8')).toBe(machineId); + }); + + it('falls back to an ephemeral id when the path cannot be read as a file', () => { + fs.mkdirSync(getMachineIdPath(), { recursive: true }); + + expect(loadMachineId()).toMatch(new RegExp(`^${os.hostname()}-[a-z0-9]+$`)); + }); +}); diff --git a/packages/telemetry/src/machine-id.ts b/packages/cli/src/cli/telemetry/machine-id.ts similarity index 94% rename from packages/telemetry/src/machine-id.ts rename to packages/cli/src/cli/telemetry/machine-id.ts index 3a5dc6a94..debcc539b 100644 --- a/packages/telemetry/src/machine-id.ts +++ b/packages/cli/src/cli/telemetry/machine-id.ts @@ -1,5 +1,5 @@ /** - * Machine ID utilities for anonymous user identification. + * Machine ID utilities for stable telemetry identity. * Uses existing machine-id file at ~/.local/share/agentworkforce/relay/machine-id */ @@ -58,7 +58,7 @@ export function loadMachineId(): string { } /** SHA256 hash of machine ID, truncated to 16 chars */ -export function createAnonymousId(): string { +export function createDistinctId(): string { const machineId = loadMachineId(); return createHash('sha256').update(machineId).digest('hex').substring(0, 16); } diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts new file mode 100644 index 000000000..cb3efa28b --- /dev/null +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectOrchestratorHarness, + inferHarnessFromCommand, + sanitizeOrchestratorHarness, +} from './orchestrator-harness.js'; + +describe('orchestrator harness detection', () => { + it('sanitizes explicit harness values for headers and telemetry', () => { + expect(sanitizeOrchestratorHarness(' Claude-Code/1.2 (model=opus) ')).toBe( + 'claude-code/1.2 (model=opus)' + ); + expect(sanitizeOrchestratorHarness('bad\r\nvalue')).toBeUndefined(); + expect(sanitizeOrchestratorHarness(' '.repeat(3))).toBeUndefined(); + }); + + it('maps common process command names to harness identifiers', () => { + expect(inferHarnessFromCommand('/usr/local/bin/claude')).toBe('claude-code'); + expect(inferHarnessFromCommand('/opt/homebrew/bin/codex')).toBe('codex'); + expect(inferHarnessFromCommand('/Applications/Cursor.app/Contents/MacOS/Cursor')).toBe('cursor'); + expect(inferHarnessFromCommand('/usr/local/bin/gemini-cli')).toBe('gemini-cli'); + expect(inferHarnessFromCommand(String.raw`C:\Users\will\AppData\Roaming\npm\gemini.cmd`)).toBe( + 'gemini-cli' + ); + }); + + it('prefers an explicit environment override', () => { + expect( + detectOrchestratorHarness({ + env: { AGENT_RELAY_ORCHESTRATOR_HARNESS: 'Codex' }, + processLookup: () => ({ pid: 10, command: 'claude' }), + }) + ).toBe('codex'); + }); + + it('walks parent processes until it finds a known harness', () => { + expect( + detectOrchestratorHarness({ + env: {}, + startPid: 10, + processLookup: (pid) => { + if (pid === 10) return { pid, ppid: 9, command: 'zsh' }; + if (pid === 9) return { pid, ppid: 8, command: '/usr/bin/codex' }; + return undefined; + }, + }) + ).toBe('codex'); + }); + + it('returns unknown when no process matches', () => { + expect( + detectOrchestratorHarness({ + env: {}, + startPid: 10, + processLookup: () => undefined, + }) + ).toBe('unknown'); + }); +}); diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.ts new file mode 100644 index 000000000..2e0d28284 --- /dev/null +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +export const UNKNOWN_ORCHESTRATOR_HARNESS = 'unknown'; +export const ORCHESTRATOR_HARNESS_ENV = 'AGENT_RELAY_ORCHESTRATOR_HARNESS'; + +const HARNESS_MAX_LENGTH = 120; +const HARNESS_ALLOWED = /^[a-z0-9 ._\-/():=;,+]+$/i; +const EXPLICIT_HARNESS_ENV_KEYS = [ + ORCHESTRATOR_HARNESS_ENV, + 'RELAYCAST_HARNESS', + 'X_RELAYCAST_HARNESS', +] as const; + +export interface ProcessInfo { + pid: number; + ppid?: number; + command?: string; +} + +export interface DetectOrchestratorHarnessOptions { + env?: NodeJS.ProcessEnv; + startPid?: number; + maxDepth?: number; + processLookup?: (pid: number) => ProcessInfo | undefined; +} + +export function sanitizeOrchestratorHarness(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (!HARNESS_ALLOWED.test(trimmed)) return undefined; + return trimmed.slice(0, HARNESS_MAX_LENGTH).toLowerCase(); +} + +export function inferHarnessFromCommand(command: string | undefined): string | undefined { + if (!command) return undefined; + const lower = command.toLowerCase(); + const normalized = lower.replace(/\\/g, '/'); + const base = path.basename(normalized).replace(/\.(exe|cmd|bat)$/i, ''); + + if (base === 'claude' || lower.includes('claude-code')) return 'claude-code'; + if (base === 'codex' || normalized.includes('/codex')) return 'codex'; + if (base === 'cursor' || base === 'cursor-agent' || lower.includes('cursor')) return 'cursor'; + if (base === 'gemini' || base === 'gemini-cli' || lower.includes('gemini-cli')) return 'gemini-cli'; + if (base === 'aider' || lower.includes('aider')) return 'aider'; + if (base === 'opencode' || lower.includes('opencode')) return 'opencode'; + if (base === 'goose' || lower.includes('goose')) return 'goose'; + if (base === 'droid' || lower.includes('droid')) return 'droid'; + if (base === 'amp' || normalized.includes('/amp')) return 'amp'; + if (lower.includes('copilot')) return 'github-copilot'; + if (base === 'zed' || lower.includes('zed')) return 'zed'; + + return undefined; +} + +function lookupProcessInfo(pid: number): ProcessInfo | undefined { + if (!Number.isInteger(pid) || pid <= 0) return undefined; + if (process.platform === 'win32') return undefined; + + try { + const result = spawnSync('ps', ['-o', 'ppid=', '-o', 'comm=', '-p', String(pid)], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1000, + }); + if (result.status !== 0) return undefined; + const line = result.stdout.trim(); + if (!line) return undefined; + const match = line.match(/^(\d+)\s+(.+)$/); + if (!match) return undefined; + return { + pid, + ppid: Number.parseInt(match[1], 10), + command: match[2].trim(), + }; + } catch { + return undefined; + } +} + +export function detectOrchestratorHarness(options: DetectOrchestratorHarnessOptions = {}): string { + const env = options.env ?? process.env; + for (const key of EXPLICIT_HARNESS_ENV_KEYS) { + const value = sanitizeOrchestratorHarness(env[key]); + if (value) return value; + } + + const lookup = options.processLookup ?? lookupProcessInfo; + let pid = options.startPid ?? process.ppid; + const maxDepth = Math.max(1, options.maxDepth ?? 8); + const seen = new Set(); + + for (let depth = 0; depth < maxDepth; depth += 1) { + if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) break; + seen.add(pid); + + const info = lookup(pid); + const harness = inferHarnessFromCommand(info?.command); + if (harness) return harness; + + if (!info?.ppid || info.ppid === pid) break; + pid = info.ppid; + } + + return UNKNOWN_ORCHESTRATOR_HARNESS; +} diff --git a/packages/cli/src/cli/telemetry/posthog-config.test.ts b/packages/cli/src/cli/telemetry/posthog-config.test.ts new file mode 100644 index 000000000..15c265e0b --- /dev/null +++ b/packages/cli/src/cli/telemetry/posthog-config.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getPostHogConfig } from './posthog-config.js'; + +describe('PostHog telemetry config', () => { + beforeEach(() => { + vi.stubEnv('AGENT_RELAY_POSTHOG_KEY', ''); + vi.stubEnv('POSTHOG_API_KEY', ''); + vi.stubEnv('POSTHOG_HOST', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('uses the hosted Agent Relay ingestion proxy by default', () => { + vi.stubEnv('AGENT_RELAY_POSTHOG_KEY', 'relay-key'); + + expect(getPostHogConfig()).toEqual({ + apiKey: 'relay-key', + host: 'https://i.agentrelay.com', + }); + }); + + it('allows POSTHOG_HOST to override the hosted proxy', () => { + vi.stubEnv('POSTHOG_API_KEY', 'debug-key'); + vi.stubEnv('POSTHOG_HOST', 'https://posthog.example.test'); + + expect(getPostHogConfig()).toEqual({ + apiKey: 'debug-key', + host: 'https://posthog.example.test', + }); + }); + + it('does not configure telemetry without a key', () => { + expect(getPostHogConfig()).toBeNull(); + }); +}); diff --git a/packages/telemetry/src/posthog-config.ts b/packages/cli/src/cli/telemetry/posthog-config.ts similarity index 90% rename from packages/telemetry/src/posthog-config.ts rename to packages/cli/src/cli/telemetry/posthog-config.ts index eb5598c63..a2c4ce276 100644 --- a/packages/telemetry/src/posthog-config.ts +++ b/packages/cli/src/cli/telemetry/posthog-config.ts @@ -10,7 +10,8 @@ * POSTHOG_API_KEY - Per-process override (mainly for local * debugging or staging). Wins over * `AGENT_RELAY_POSTHOG_KEY` when set. - * POSTHOG_HOST - Override host URL. + * POSTHOG_HOST - Override host URL. Defaults to the hosted + * Agent Relay ingestion proxy. * * Key selection order: * 1. POSTHOG_API_KEY (process override, any environment) @@ -18,7 +19,7 @@ * 3. None → returns `null` and `initTelemetry()` becomes a no-op. */ -const HOST = 'https://us.i.posthog.com'; +const HOST = 'https://i.agentrelay.com'; function readKey(name: string): string | null { const raw = process.env[name]; diff --git a/packages/cloud/src/api-client.ts b/packages/cloud/src/api-client.ts index 68cf57893..f329f2e6b 100644 --- a/packages/cloud/src/api-client.ts +++ b/packages/cloud/src/api-client.ts @@ -1,4 +1,5 @@ import { REFRESH_WINDOW_MS } from './types.js'; +import { appendAgentRelayTelemetryHeaders } from './telemetry-headers.js'; export type CloudApiClientOptions = { apiUrl: string; @@ -157,7 +158,7 @@ export class CloudApiClient { private buildHeaders(headers: HeaderInput | undefined): Headers { const merged = new Headers(headers); merged.set('Authorization', `Bearer ${this.accessToken}`); - return merged; + return appendAgentRelayTelemetryHeaders(merged); } private shouldRefresh(): boolean { diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index 4f16a6b1c..402bc9e3b 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -22,7 +22,7 @@ vi.mock('node:child_process', () => ({ spawn: childProcessMocks.spawn, })); -import { ensureAuthenticated, readStoredAuth, refreshStoredAuth } from './auth.js'; +import { authorizedApiFetch, ensureAuthenticated, readStoredAuth, refreshStoredAuth } from './auth.js'; import type { StoredAuth } from './types.js'; const FILE_AUTH: StoredAuth = { @@ -280,3 +280,91 @@ describe('refreshStoredAuth', () => { expect(fsMocks.mkdir).not.toHaveBeenCalled(); }); }); + +describe('authorizedApiFetch telemetry headers', () => { + const telemetryEnvKeys = [ + 'AGENT_RELAY_DISTINCT_ID', + 'AGENT_RELAY_ORCHESTRATOR_HARNESS', + 'AGENT_RELAY_TELEMETRY_SURFACE', + 'AGENT_RELAY_TELEMETRY_CLIENT', + 'AGENT_RELAY_CLI_VERSION', + 'AGENT_RELAY_SDK_VERSION', + 'AGENT_RELAY_TELEMETRY_DISABLED', + 'DO_NOT_TRACK', + ] as const; + + function clearTelemetryEnv(): void { + for (const key of telemetryEnvKeys) { + delete process.env[key]; + } + } + + it('adds Agent Relay identity and origin headers when the CLI provides a telemetry distinct id', async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + + const previousEnv = { ...process.env }; + clearTelemetryEnv(); + process.env.AGENT_RELAY_DISTINCT_ID = 'abc123def4567890'; + process.env.AGENT_RELAY_ORCHESTRATOR_HARNESS = 'Codex'; + process.env.AGENT_RELAY_TELEMETRY_SURFACE = 'cli'; + process.env.AGENT_RELAY_TELEMETRY_CLIENT = 'agent-relay'; + process.env.AGENT_RELAY_CLI_VERSION = '7.1.1'; + + try { + await authorizedApiFetch( + { + apiUrl: 'https://api.example.test', + accessToken: 'access-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }, + '/api/v1/workflows/run', + { + method: 'POST', + headers: { Accept: 'application/json' }, + } + ); + } finally { + process.env = previousEnv; + } + + expect(fetchSpy).toHaveBeenCalledOnce(); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = new Headers(init.headers); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('authorization')).toBe('Bearer access-token'); + expect(headers.get('x-agent-relay-distinct-id')).toBe('abc123def4567890'); + expect(headers.get('x-relaycast-harness')).toBe('Codex'); + expect(headers.get('x-relaycast-origin-surface')).toBe('cli'); + expect(headers.get('x-relaycast-origin-client')).toBe('agent-relay'); + expect(headers.get('x-relaycast-origin-version')).toBe('7.1.1'); + }); + + it('omits telemetry headers when no distinct id is provided', async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + const previousEnv = { ...process.env }; + clearTelemetryEnv(); + + try { + await authorizedApiFetch( + { + apiUrl: 'https://api.example.test', + accessToken: 'access-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }, + '/api/v1/workflows/run', + { method: 'POST' } + ); + } finally { + process.env = previousEnv; + } + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = new Headers(init.headers); + expect(headers.get('x-agent-relay-distinct-id')).toBeNull(); + expect(headers.get('x-relaycast-harness')).toBeNull(); + }); +}); diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 2b7928ec5..bc52ae6a6 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import { buildApiUrl } from './api-client.js'; +import { appendAgentRelayTelemetryHeaders } from './telemetry-headers.js'; import { AUTH_FILE_PATH, REFRESH_WINDOW_MS, type StoredAuth } from './types.js'; const envBackedAuth = new WeakSet(); @@ -339,13 +340,16 @@ function apiFetch( requestPath: string, init: RequestInit ): Promise { + const headers = new Headers(init.headers); + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + headers.set('authorization', `Bearer ${accessToken}`); + appendAgentRelayTelemetryHeaders(headers); + return fetch(buildApiUrl(apiUrl, requestPath), { ...init, - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${accessToken}`, - ...(init.headers ?? {}), - }, + headers, }); } diff --git a/packages/cloud/src/telemetry-headers.ts b/packages/cloud/src/telemetry-headers.ts new file mode 100644 index 000000000..2b6832942 --- /dev/null +++ b/packages/cloud/src/telemetry-headers.ts @@ -0,0 +1,81 @@ +export const AGENT_RELAY_DISTINCT_ID_HEADER = 'X-Agent-Relay-Distinct-Id'; +export const RELAYCAST_HARNESS_HEADER = 'X-Relaycast-Harness'; +export const RELAYCAST_ORIGIN_SURFACE_HEADER = 'X-Relaycast-Origin-Surface'; +export const RELAYCAST_ORIGIN_CLIENT_HEADER = 'X-Relaycast-Origin-Client'; +export const RELAYCAST_ORIGIN_VERSION_HEADER = 'X-Relaycast-Origin-Version'; + +const AGENT_RELAY_DISTINCT_ID_ENV = 'AGENT_RELAY_DISTINCT_ID'; +const ORCHESTRATOR_HARNESS_ENV = 'AGENT_RELAY_ORCHESTRATOR_HARNESS'; +const TELEMETRY_SURFACE_ENV = 'AGENT_RELAY_TELEMETRY_SURFACE'; +const TELEMETRY_CLIENT_ENV = 'AGENT_RELAY_TELEMETRY_CLIENT'; + +const DISTINCT_ID_ALLOWED = /^[a-z0-9._:-]+$/i; +const HEADER_VALUE_ALLOWED = /^[a-z0-9 ._\-/():=;,+@]+$/i; + +function sanitizeHeaderValue( + raw: string | undefined, + options: { maxLength: number; pattern?: RegExp } +): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (options.pattern && !options.pattern.test(trimmed)) return undefined; + return trimmed.slice(0, options.maxLength); +} + +function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv): boolean { + const disabled = env.AGENT_RELAY_TELEMETRY_DISABLED ?? env.DO_NOT_TRACK; + return disabled === '1' || disabled?.toLowerCase() === 'true'; +} + +export function buildAgentRelayTelemetryHeaders( + env: NodeJS.ProcessEnv = process.env +): Record { + if (isTelemetryDisabledByEnv(env)) return {}; + + const distinctId = sanitizeHeaderValue(env[AGENT_RELAY_DISTINCT_ID_ENV], { + maxLength: 128, + pattern: DISTINCT_ID_ALLOWED, + }); + if (!distinctId) return {}; + + const headers: Record = { + [AGENT_RELAY_DISTINCT_ID_HEADER]: distinctId, + }; + + const harness = sanitizeHeaderValue(env[ORCHESTRATOR_HARNESS_ENV], { + maxLength: 120, + pattern: HEADER_VALUE_ALLOWED, + }); + const surface = sanitizeHeaderValue(env[TELEMETRY_SURFACE_ENV], { + maxLength: 32, + pattern: HEADER_VALUE_ALLOWED, + }); + const client = sanitizeHeaderValue(env[TELEMETRY_CLIENT_ENV], { + maxLength: 80, + pattern: HEADER_VALUE_ALLOWED, + }); + const version = sanitizeHeaderValue(env.AGENT_RELAY_CLI_VERSION ?? env.AGENT_RELAY_SDK_VERSION, { + maxLength: 48, + pattern: HEADER_VALUE_ALLOWED, + }); + + if (harness) headers[RELAYCAST_HARNESS_HEADER] = harness; + if (surface) headers[RELAYCAST_ORIGIN_SURFACE_HEADER] = surface; + if (client) headers[RELAYCAST_ORIGIN_CLIENT_HEADER] = client; + if (version) headers[RELAYCAST_ORIGIN_VERSION_HEADER] = version; + + return headers; +} + +export function appendAgentRelayTelemetryHeaders( + headers: Headers, + env: NodeJS.ProcessEnv = process.env +): Headers { + for (const [name, value] of Object.entries(buildAgentRelayTelemetryHeaders(env))) { + if (!headers.has(name)) { + headers.set(name, value); + } + } + return headers; +} diff --git a/packages/sdk/src/facade.ts b/packages/sdk/src/facade.ts index 9770548ba..8a5770aa0 100644 --- a/packages/sdk/src/facade.ts +++ b/packages/sdk/src/facade.ts @@ -218,7 +218,7 @@ export function createEnrichedMessages( ): EnrichedMessages { const enriched: EnrichedMessages = Object.create(base) as EnrichedMessages; - enriched.send = (input: RelaySendChannelMessageInput | RelaySendMessageInput) => { + enriched.send = async (input: RelaySendChannelMessageInput | RelaySendMessageInput) => { if ('channel' in input && input.channel) { return base.send(input); } @@ -253,7 +253,7 @@ export function createEnrichedMessages( }); }; - enriched.reply = (input: RelayReplyInput) => { + enriched.reply = async (input: RelayReplyInput) => { const messages = resolveFrom(input.from); return messages.reply({ messageId: resolveMessageId(input.messageId ?? input.thread), @@ -263,7 +263,7 @@ export function createEnrichedMessages( }); }; - enriched.react = ((arg1: string | RelayReactInput, arg2?: string): Promise => { + enriched.react = (async (arg1: string | RelayReactInput, arg2?: string): Promise => { if (typeof arg1 === 'string') { return base.react(arg1, arg2 as string); } @@ -272,7 +272,7 @@ export function createEnrichedMessages( return messages.react(messageId, arg1.emoji); }) as EnrichedMessages['react']; - enriched.dm = (input: RelayDirectInput) => { + enriched.dm = async (input: RelayDirectInput) => { const messages = resolveFrom(input.from); return messages.direct({ to: resolveAgentName(input.to), @@ -325,7 +325,7 @@ export function createWorkspaceFacade(messaging: RelayMessaging, deps?: Workspac return { info: () => messaging.workspace.info(), register: register as RelayWorkspace['register'], - reconnect: ({ apiToken }) => { + reconnect: async ({ apiToken }) => { if (!deps) { throw new Error('reconnect() is only available on the workspace client.'); } diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json index 0a478323a..0c970cfdd 100644 --- a/packages/sdk/tsconfig.build.json +++ b/packages/sdk/tsconfig.build.json @@ -23,6 +23,7 @@ "src/index.ts", "src/agent-relay.ts", "src/capabilities.ts", + "src/facade.ts", "src/relaycast-errors.ts", "src/messaging/**/*.ts", "src/delivery/**/*.ts", diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 5071ab58f..3ff6a392f 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -15,6 +15,7 @@ "src/index.ts", "src/agent-relay.ts", "src/capabilities.ts", + "src/facade.ts", "src/relaycast-errors.ts", "src/messaging/**/*.ts", "src/delivery/**/*.ts", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 3318542c4..fe52b56bc 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,36 +1,14 @@ { "name": "@agent-relay/telemetry", "version": "7.1.1", - "description": "Anonymous telemetry for Agent Relay usage analytics", + "description": "Deprecated Agent Relay telemetry package placeholder", + "private": true, + "deprecated": "@agent-relay/telemetry is deprecated. Telemetry is now internal to the agent-relay CLI.", "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md" - ], "scripts": { - "build": "tsc", + "build": "echo '@agent-relay/telemetry is deprecated; telemetry now lives inside the agent-relay CLI.'", "clean": "rm -rf dist", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "posthog-node": "^5.29.2" - }, - "devDependencies": { - "@types/node": "^22.19.3", - "vitest": "^4.1.0" - }, - "publishConfig": { - "access": "public" + "test": "echo '@agent-relay/telemetry is deprecated; no package tests to run.'" }, "repository": { "type": "git", diff --git a/packages/telemetry/tsconfig.json b/packages/telemetry/tsconfig.json deleted file mode 100644 index 62b78095e..000000000 --- a/packages/telemetry/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "resolveJsonModule": true, - "isolatedModules": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -}