From f9f18f6819e2ac8a4290d9b6af515057e90ce86d Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 3 Jun 2026 06:03:33 -0400 Subject: [PATCH 1/7] feat(telemetry): route context through cli sdk and cloud --- .../2026-06/traj_6kef2z1redhb/summary.md | 41 ++++ .../2026-06/traj_6kef2z1redhb/trajectory.json | 77 ++++++++ CHANGELOG.md | 1 + crates/broker/src/telemetry.rs | 178 +++++++++++++++++ package-lock.json | 1 + packages/cli/src/cli/bootstrap.ts | 41 +++- packages/cli/src/cli/entrypoint.test.ts | 4 + packages/cloud/src/api-client.ts | 3 +- packages/cloud/src/auth.test.ts | 90 ++++++++- packages/cloud/src/auth.ts | 14 +- packages/cloud/src/telemetry-headers.ts | 81 ++++++++ packages/sdk/package.json | 1 + packages/sdk/src/agent-relay.ts | 33 ++-- packages/sdk/src/facade.ts | 186 +++++++++--------- packages/sdk/src/telemetry.ts | 106 ++++++++++ packages/sdk/tsconfig.build.json | 6 +- packages/sdk/tsconfig.json | 6 +- packages/telemetry/src/client.ts | 45 ++++- packages/telemetry/src/events.ts | 44 +++++ packages/telemetry/src/index.ts | 14 ++ .../src/orchestrator-harness.test.ts | 60 ++++++ .../telemetry/src/orchestrator-harness.ts | 107 ++++++++++ 22 files changed, 1017 insertions(+), 122 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json create mode 100644 packages/cloud/src/telemetry-headers.ts create mode 100644 packages/sdk/src/telemetry.ts create mode 100644 packages/telemetry/src/orchestrator-harness.test.ts create mode 100644 packages/telemetry/src/orchestrator-harness.ts 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..b3241d321 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md @@ -0,0 +1,41 @@ +# 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..02b422428 --- /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" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c194f920e..ee9d8a265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,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` telemetry now tags CLI, broker, SDK, and hosted cloud requests with app, surface, orchestrator harness, and anonymous client identity context when telemetry is enabled. ### Changed diff --git a/crates/broker/src/telemetry.rs b/crates/broker/src/telemetry.rs index 72a175d8a..221474256 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.trim().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 aeb62191d..142fc2201 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18268,6 +18268,7 @@ "name": "@agent-relay/sdk", "version": "7.1.1", "dependencies": { + "@agent-relay/telemetry": "7.1.1", "@relaycast/sdk": "^2.3.0" }, "devDependencies": { diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index d718887f7..260711880 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, + getAnonymousId, + initTelemetry, + isEnabled as isTelemetryEnabled, + shutdown as shutdownTelemetry, + track, +} from '@agent-relay/telemetry'; 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_CLIENT_ID_ENV = 'AGENT_RELAY_CLIENT_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_CLIENT_ID_ENV] && hasConfiguredTelemetryKey() && isTelemetryEnabled()) { + process.env[AGENT_RELAY_CLIENT_ID_ENV] = getAnonymousId(); + } + + 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/entrypoint.test.ts b/packages/cli/src/cli/entrypoint.test.ts index cd4672fe1..dcb5b2a9a 100644 --- a/packages/cli/src/cli/entrypoint.test.ts +++ b/packages/cli/src/cli/entrypoint.test.ts @@ -16,7 +16,11 @@ function mockBootstrapDependencies(parseSpy: ReturnType): void { checkForUpdatesInBackground: vi.fn(), })); vi.doMock('@agent-relay/telemetry', () => ({ + ORCHESTRATOR_HARNESS_ENV: 'AGENT_RELAY_ORCHESTRATOR_HARNESS', + detectOrchestratorHarness: vi.fn(() => 'unknown'), + getAnonymousId: vi.fn(() => 'anon-test'), initTelemetry: vi.fn(), + isEnabled: vi.fn(() => false), track: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), })); 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..7267c9eba 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_CLIENT_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 client 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_CLIENT_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-anonymous-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 client 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-anonymous-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..d85104876 --- /dev/null +++ b/packages/cloud/src/telemetry-headers.ts @@ -0,0 +1,81 @@ +export const AGENT_RELAY_ANONYMOUS_ID_HEADER = 'X-Agent-Relay-Anonymous-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_CLIENT_ID_ENV = 'AGENT_RELAY_CLIENT_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 CLIENT_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 clientId = sanitizeHeaderValue(env[AGENT_RELAY_CLIENT_ID_ENV], { + maxLength: 128, + pattern: CLIENT_ID_ALLOWED, + }); + if (!clientId) return {}; + + const headers: Record = { + [AGENT_RELAY_ANONYMOUS_ID_HEADER]: clientId, + }; + + 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/package.json b/packages/sdk/package.json index 475307ca5..6e37ead26 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -58,6 +58,7 @@ "@types/node": "^22.13.10" }, "dependencies": { + "@agent-relay/telemetry": "7.1.1", "@relaycast/sdk": "^2.3.0" } } diff --git a/packages/sdk/src/agent-relay.ts b/packages/sdk/src/agent-relay.ts index 7d58b6f04..c43d3a5b9 100644 --- a/packages/sdk/src/agent-relay.ts +++ b/packages/sdk/src/agent-relay.ts @@ -32,6 +32,7 @@ import { type RelayEvent, } from './listeners.js'; import type { AgentSessionEvent } from './session/index.js'; +import { withSdkWorkflowTelemetry } from './telemetry.js'; export interface AgentRelayOptions extends RelaycastMessagingOptions { messaging?: RelayMessaging; @@ -92,21 +93,23 @@ export class AgentRelay implements AgentRelayAgent { } static async createWorkspace(input: string | AgentRelayCreateWorkspaceInput): Promise { - const options = typeof input === 'string' ? { name: input } : input; - const workspace = (await RelayCast.createWorkspace(options.name, { - baseUrl: options.baseUrl, - })) as Record; - const workspaceKey = extractWorkspaceKey(workspace); - - if (!workspaceKey) { - throw new Error('Workspace created, but the response did not include a workspace key.'); - } - - return new AgentRelay({ - workspaceKey, - baseUrl: options.baseUrl, - retryPolicy: options.retryPolicy, - actions: options.actions, + return withSdkWorkflowTelemetry('create_workspace', async () => { + const options = typeof input === 'string' ? { name: input } : input; + const workspace = (await RelayCast.createWorkspace(options.name, { + baseUrl: options.baseUrl, + })) as Record; + const workspaceKey = extractWorkspaceKey(workspace); + + if (!workspaceKey) { + throw new Error('Workspace created, but the response did not include a workspace key.'); + } + + return new AgentRelay({ + workspaceKey, + baseUrl: options.baseUrl, + retryPolicy: options.retryPolicy, + actions: options.actions, + }); }); } diff --git a/packages/sdk/src/facade.ts b/packages/sdk/src/facade.ts index 9770548ba..fcaf9cf2c 100644 --- a/packages/sdk/src/facade.ts +++ b/packages/sdk/src/facade.ts @@ -20,6 +20,7 @@ import { } from './actions/index.js'; import type { DeliveryMode } from './delivery/index.js'; import type { RelayAgentHandle } from './listeners.js'; +import { withSdkMethodTelemetry } from './telemetry.js'; /** * A reference to an agent accepted by the high-level facade APIs. Agents may be @@ -218,119 +219,124 @@ export function createEnrichedMessages( ): EnrichedMessages { const enriched: EnrichedMessages = Object.create(base) as EnrichedMessages; - enriched.send = (input: RelaySendChannelMessageInput | RelaySendMessageInput) => { - if ('channel' in input && input.channel) { - return base.send(input); - } - const sendInput = input as RelaySendMessageInput; - const messages = resolveFrom(sendInput.from); - const text = buildText(sendInput.text ?? sendInput.msg, sendInput.mentions); - if (Array.isArray(sendInput.to)) { - return messages.groupDirect({ - participants: sendInput.to.map(resolveAgentName), - text, - attachments: sendInput.attachments, - mode: sendInput.mode, - idempotencyKey: sendInput.idempotencyKey, - }); - } - if (isChannelTarget(sendInput.to)) { - return messages.send({ - channel: stripSigil(sendInput.to), + enriched.send = (input: RelaySendChannelMessageInput | RelaySendMessageInput) => + withSdkMethodTelemetry('messages.send', async () => { + if ('channel' in input && input.channel) { + return base.send(input); + } + const sendInput = input as RelaySendMessageInput; + const messages = resolveFrom(sendInput.from); + const text = buildText(sendInput.text ?? sendInput.msg, sendInput.mentions); + if (Array.isArray(sendInput.to)) { + return messages.groupDirect({ + participants: sendInput.to.map(resolveAgentName), + text, + attachments: sendInput.attachments, + mode: sendInput.mode, + idempotencyKey: sendInput.idempotencyKey, + }); + } + if (isChannelTarget(sendInput.to)) { + return messages.send({ + channel: stripSigil(sendInput.to), + text, + blocks: sendInput.blocks, + attachments: sendInput.attachments, + mode: sendInput.mode, + idempotencyKey: sendInput.idempotencyKey, + }); + } + return messages.direct({ + to: resolveAgentName(sendInput.to), text, - blocks: sendInput.blocks, attachments: sendInput.attachments, mode: sendInput.mode, idempotencyKey: sendInput.idempotencyKey, }); - } - return messages.direct({ - to: resolveAgentName(sendInput.to), - text, - attachments: sendInput.attachments, - mode: sendInput.mode, - idempotencyKey: sendInput.idempotencyKey, }); - }; - enriched.reply = (input: RelayReplyInput) => { - const messages = resolveFrom(input.from); - return messages.reply({ - messageId: resolveMessageId(input.messageId ?? input.thread), - text: input.text, - blocks: input.blocks, - idempotencyKey: input.idempotencyKey, + enriched.reply = (input: RelayReplyInput) => + withSdkMethodTelemetry('messages.reply', async () => { + const messages = resolveFrom(input.from); + return messages.reply({ + messageId: resolveMessageId(input.messageId ?? input.thread), + text: input.text, + blocks: input.blocks, + idempotencyKey: input.idempotencyKey, + }); }); - }; enriched.react = ((arg1: string | RelayReactInput, arg2?: string): Promise => { - if (typeof arg1 === 'string') { - return base.react(arg1, arg2 as string); - } - const messageId = typeof arg1.message === 'string' ? arg1.message : arg1.message.id; - const messages = resolveFrom(arg1.agent); - return messages.react(messageId, arg1.emoji); + return withSdkMethodTelemetry('messages.react', async () => { + if (typeof arg1 === 'string') { + return base.react(arg1, arg2 as string); + } + const messageId = typeof arg1.message === 'string' ? arg1.message : arg1.message.id; + const messages = resolveFrom(arg1.agent); + return messages.react(messageId, arg1.emoji); + }); }) as EnrichedMessages['react']; - enriched.dm = (input: RelayDirectInput) => { - const messages = resolveFrom(input.from); - return messages.direct({ - to: resolveAgentName(input.to), - text: input.text ?? input.msg ?? '', - attachments: input.attachments, - mode: input.mode, - idempotencyKey: input.idempotencyKey, + enriched.dm = (input: RelayDirectInput) => + withSdkMethodTelemetry('messages.direct', async () => { + const messages = resolveFrom(input.from); + return messages.direct({ + to: resolveAgentName(input.to), + text: input.text ?? input.msg ?? '', + attachments: input.attachments, + mode: input.mode, + idempotencyKey: input.idempotencyKey, + }); }); - }; return enriched; } export function createWorkspaceFacade(messaging: RelayMessaging, deps?: WorkspaceFacadeDeps): RelayWorkspace { - const register = async ( - agents: AgentLike | AgentLike[] - ): Promise => { - if (!deps) { - throw new Error('register() is only available on the workspace client.'); - } - const list = Array.isArray(agents) ? agents : [agents]; - const inputs = list.map((agent) => - typeof agent === 'string' - ? { name: stripSigil(agent) } - : { - name: resolveAgentName(agent), - type: agent.type, - persona: agent.persona, - metadata: agent.metadata, - } - ); - - // Fail fast on in-batch duplicates so a batch can't partially register - // before the relay rejects a later duplicate name. - const seen = new Set(); - for (const { name } of inputs) { - if (seen.has(name)) { - throw new Error(`Duplicate agent name in register(): "${name}".`); + const register = async (agents: AgentLike | AgentLike[]): Promise => + withSdkMethodTelemetry('workspace.register', async () => { + if (!deps) { + throw new Error('register() is only available on the workspace client.'); + } + const list = Array.isArray(agents) ? agents : [agents]; + const inputs = list.map((agent) => + typeof agent === 'string' + ? { name: stripSigil(agent) } + : { + name: resolveAgentName(agent), + type: agent.type, + persona: agent.persona, + metadata: agent.metadata, + } + ); + + // Fail fast on in-batch duplicates so a batch can't partially register + // before the relay rejects a later duplicate name. + const seen = new Set(); + for (const { name } of inputs) { + if (seen.has(name)) { + throw new Error(`Duplicate agent name in register(): "${name}".`); + } + seen.add(name); } - seen.add(name); - } - const clients: RelayAgentClient[] = []; - for (const input of inputs) { - clients.push(deps.buildAgentClient(await messaging.agents.register(input))); - } - return Array.isArray(agents) ? clients : clients[0]; - }; + const clients: RelayAgentClient[] = []; + for (const input of inputs) { + clients.push(deps.buildAgentClient(await messaging.agents.register(input))); + } + return Array.isArray(agents) ? clients : clients[0]; + }); return { - info: () => messaging.workspace.info(), + info: () => withSdkMethodTelemetry('workspace.info', () => messaging.workspace.info()), register: register as RelayWorkspace['register'], - reconnect: ({ apiToken }) => { - if (!deps) { - throw new Error('reconnect() is only available on the workspace client.'); - } - return deps.reconnectAgent(apiToken); - }, + reconnect: ({ apiToken }) => + withSdkMethodTelemetry('workspace.reconnect', async () => { + if (!deps) { + throw new Error('reconnect() is only available on the workspace client.'); + } + return deps.reconnectAgent(apiToken); + }), }; } diff --git a/packages/sdk/src/telemetry.ts b/packages/sdk/src/telemetry.ts new file mode 100644 index 000000000..316a1bc2f --- /dev/null +++ b/packages/sdk/src/telemetry.ts @@ -0,0 +1,106 @@ +import { + detectOrchestratorHarness, + initTelemetry, + track, + type CommonProperties, +} from '@agent-relay/telemetry'; + +let initialized = false; +let cachedHarness: string | undefined; + +function resolveSdkVersion(): string | undefined { + const version = process.env.AGENT_RELAY_SDK_VERSION?.trim(); + return version || undefined; +} + +function orchestratorHarness(): string { + cachedHarness ??= detectOrchestratorHarness(); + return cachedHarness; +} + +function errorClassName(error: unknown): string | undefined { + if (error instanceof Error) return error.constructor.name; + if (error && typeof error === 'object') { + const ctor = (error as { constructor?: { name?: string } }).constructor; + return ctor?.name || 'Object'; + } + return typeof error; +} + +export function initSdkTelemetry(): void { + if (initialized) return; + initialized = true; + initTelemetry({ + showNotice: false, + sdkVersion: resolveSdkVersion(), + app: 'sdk', + surface: 'sdk', + orchestratorHarness: orchestratorHarness(), + }); +} + +function sdkCommonOverrides(): Partial { + return { + app: 'sdk', + surface: 'sdk', + orchestrator_harness: orchestratorHarness(), + }; +} + +export function trackSdkMethodCall(input: { + method: string; + success: boolean; + durationMs: number; + error?: unknown; +}): void { + initSdkTelemetry(); + const errorClass = input.error === undefined ? undefined : errorClassName(input.error); + track('sdk_method_call', { + method: input.method, + success: input.success, + duration_ms: input.durationMs, + ...(errorClass ? { error_class: errorClass } : {}), + ...sdkCommonOverrides(), + }); +} + +export function trackSdkWorkflowRun(input: { + operation: string; + success: boolean; + durationMs: number; + error?: unknown; +}): void { + initSdkTelemetry(); + const errorClass = input.error === undefined ? undefined : errorClassName(input.error); + track('sdk_workflow_run', { + operation: input.operation, + success: input.success, + duration_ms: input.durationMs, + ...(errorClass ? { error_class: errorClass } : {}), + ...sdkCommonOverrides(), + }); +} + +export async function withSdkMethodTelemetry(method: string, fn: () => Promise): Promise { + const started = Date.now(); + try { + const result = await fn(); + trackSdkMethodCall({ method, success: true, durationMs: Date.now() - started }); + return result; + } catch (error) { + trackSdkMethodCall({ method, success: false, durationMs: Date.now() - started, error }); + throw error; + } +} + +export async function withSdkWorkflowTelemetry(operation: string, fn: () => Promise): Promise { + const started = Date.now(); + try { + const result = await fn(); + trackSdkWorkflowRun({ operation, success: true, durationMs: Date.now() - started }); + return result; + } catch (error) { + trackSdkWorkflowRun({ operation, success: false, durationMs: Date.now() - started, error }); + throw error; + } +} diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json index 0a478323a..7375988b1 100644 --- a/packages/sdk/tsconfig.build.json +++ b/packages/sdk/tsconfig.build.json @@ -8,7 +8,9 @@ "@agent-relay/config": ["../config/dist/index.d.ts"], "@agent-relay/config/*": ["../config/dist/*"], "@agent-relay/cloud": ["../cloud/dist/index.d.ts"], - "@agent-relay/cloud/*": ["../cloud/dist/*"] + "@agent-relay/cloud/*": ["../cloud/dist/*"], + "@agent-relay/telemetry": ["../telemetry/dist/index.d.ts"], + "@agent-relay/telemetry/*": ["../telemetry/dist/*"] }, "strict": true, "declaration": true, @@ -23,6 +25,8 @@ "src/index.ts", "src/agent-relay.ts", "src/capabilities.ts", + "src/facade.ts", + "src/telemetry.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..bea136120 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -7,7 +7,9 @@ "@agent-relay/config": ["../config/src/index.ts"], "@agent-relay/config/*": ["../config/src/*"], "@agent-relay/cloud": ["../cloud/src/index.ts"], - "@agent-relay/cloud/*": ["../cloud/src/*"] + "@agent-relay/cloud/*": ["../cloud/src/*"], + "@agent-relay/telemetry": ["../telemetry/src/index.ts"], + "@agent-relay/telemetry/*": ["../telemetry/src/*"] }, "noEmit": true }, @@ -15,6 +17,8 @@ "src/index.ts", "src/agent-relay.ts", "src/capabilities.ts", + "src/facade.ts", + "src/telemetry.ts", "src/relaycast-errors.ts", "src/messaging/**/*.ts", "src/delivery/**/*.ts", diff --git a/packages/telemetry/src/client.ts b/packages/telemetry/src/client.ts index b08030580..6c973694a 100644 --- a/packages/telemetry/src/client.ts +++ b/packages/telemetry/src/client.ts @@ -17,6 +17,7 @@ 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; @@ -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 } : {}), @@ -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,11 +165,17 @@ 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(); } -export function track(event: E, properties?: TelemetryEventMap[E]): void { +export function track( + event: E, + properties?: TelemetryEventMap[E] & Partial +): void { if (!client || !commonProps || !anonymousId) return; client.capture({ diff --git a/packages/telemetry/src/events.ts b/packages/telemetry/src/events.ts index bcc57a935..bc5bc731a 100644 --- a/packages/telemetry/src/events.ts +++ b/packages/telemetry/src/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. */ @@ -193,6 +205,34 @@ export interface CliCommandCompleteEvent { error_class?: string; } +/** + * sdk_method_call - Emitted by @agent-relay/sdk around high-level public methods. + */ +export interface SdkMethodCallEvent { + /** Stable method identifier, e.g. `messages.send` or `workspace.register`. */ + method: string; + /** True if the method completed without throwing. */ + success: boolean; + /** Wall-clock duration in milliseconds. */ + duration_ms: number; + /** Error constructor name on failure. */ + error_class?: string; +} + +/** + * sdk_workflow_run - Emitted by @agent-relay/sdk for higher-level SDK workflows. + */ +export interface SdkWorkflowRunEvent { + /** Stable operation identifier, e.g. `create_workspace`. */ + operation: string; + /** True if the workflow completed without throwing. */ + success: boolean; + /** Wall-clock duration in milliseconds. */ + duration_ms: number; + /** Error constructor name on failure. */ + error_class?: string; +} + // ============================================================================= // Tier 3: Domain Events (high-signal product flows) // ============================================================================= @@ -355,6 +395,8 @@ export type TelemetryEventName = | 'message_send' | 'cli_command_run' | 'cli_command_complete' + | 'sdk_method_call' + | 'sdk_workflow_run' | 'workflow_run' | 'cloud_auth' | 'cloud_workflow_run' @@ -374,6 +416,8 @@ export interface TelemetryEventMap { message_send: MessageSendEvent; cli_command_run: CliCommandRunEvent; cli_command_complete: CliCommandCompleteEvent; + sdk_method_call: SdkMethodCallEvent; + sdk_workflow_run: SdkWorkflowRunEvent; workflow_run: WorkflowRunEvent; cloud_auth: CloudAuthEvent; cloud_workflow_run: CloudWorkflowRunEvent; diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index a82c4c376..e8a4af9dc 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -28,6 +28,8 @@ export { export type { CommonProperties, ActionSource, + TelemetryApp, + TelemetrySurface, ReleaseReason, WorkflowFileType, BrokerStartEvent, @@ -39,6 +41,8 @@ export type { MessageSendEvent, CliCommandRunEvent, CliCommandCompleteEvent, + SdkMethodCallEvent, + SdkWorkflowRunEvent, WorkflowRunEvent, CloudAuthEvent, CloudWorkflowRunEvent, @@ -51,3 +55,13 @@ export type { } from './events.js'; export { loadMachineId, createAnonymousId, 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/telemetry/src/orchestrator-harness.test.ts b/packages/telemetry/src/orchestrator-harness.test.ts new file mode 100644 index 000000000..cb3efa28b --- /dev/null +++ b/packages/telemetry/src/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/telemetry/src/orchestrator-harness.ts b/packages/telemetry/src/orchestrator-harness.ts new file mode 100644 index 000000000..2e0d28284 --- /dev/null +++ b/packages/telemetry/src/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; +} From 6e5d7938d31d048203a61627ad42e54d0b6f4ef4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 10:04:56 +0000 Subject: [PATCH 2/7] style: auto-format with Prettier --- .../completed/2026-06/traj_6kef2z1redhb/summary.md | 6 +++++- .../completed/2026-06/traj_6kef2z1redhb/trajectory.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md index b3241d321..2db9eacc3 100644 --- a/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md +++ b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md @@ -18,14 +18,17 @@ Routed telemetry context through the CLI, SDK, cloud client, Rust broker, and ho ## 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. @@ -34,7 +37,8 @@ Routed telemetry context through the CLI, SDK, cloud client, Rust broker, and ho ## Chapters ### 1. Work -*Agent: default* + +_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 diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json index 02b422428..9a9c78b37 100644 --- a/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json +++ b/.agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/trajectory.json @@ -74,4 +74,4 @@ "startRef": "30d18e9ef73995f6c3ac4044f59d9261d17875f8", "endRef": "30d18e9ef73995f6c3ac4044f59d9261d17875f8" } -} \ No newline at end of file +} From 070773911b55da4efc1654a80b6bd626b9653d87 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 3 Jun 2026 06:45:49 -0400 Subject: [PATCH 3/7] refactor(telemetry): call client identity distinct id --- CHANGELOG.md | 2 +- packages/cli/src/cli/bootstrap.ts | 6 +++--- packages/cloud/src/auth.test.ts | 12 ++++++------ packages/cloud/src/telemetry-headers.ts | 14 +++++++------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9d8a265..1b525ff96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,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` telemetry now tags CLI, broker, SDK, and hosted cloud requests with app, surface, orchestrator harness, and anonymous client identity context when telemetry is enabled. +- `agent-relay` telemetry now tags CLI, broker, SDK, and hosted cloud requests with app, surface, orchestrator harness, and distinct client identity context when telemetry is enabled. ### Changed diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index 260711880..d1dd38e63 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -88,7 +88,7 @@ function resolveSdkVersion(): string | undefined { export const SDK_VERSION = resolveSdkVersion(); -const AGENT_RELAY_CLIENT_ID_ENV = 'AGENT_RELAY_CLIENT_ID'; +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'; @@ -133,8 +133,8 @@ function propagateTelemetryContextToChildren(): string { if (!process.env[TELEMETRY_CLIENT_ENV]) { process.env[TELEMETRY_CLIENT_ENV] = 'agent-relay'; } - if (!process.env[AGENT_RELAY_CLIENT_ID_ENV] && hasConfiguredTelemetryKey() && isTelemetryEnabled()) { - process.env[AGENT_RELAY_CLIENT_ID_ENV] = getAnonymousId(); + if (!process.env[AGENT_RELAY_DISTINCT_ID_ENV] && hasConfiguredTelemetryKey() && isTelemetryEnabled()) { + process.env[AGENT_RELAY_DISTINCT_ID_ENV] = getAnonymousId(); } return orchestratorHarness; diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index 7267c9eba..402bc9e3b 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -283,7 +283,7 @@ describe('refreshStoredAuth', () => { describe('authorizedApiFetch telemetry headers', () => { const telemetryEnvKeys = [ - 'AGENT_RELAY_CLIENT_ID', + 'AGENT_RELAY_DISTINCT_ID', 'AGENT_RELAY_ORCHESTRATOR_HARNESS', 'AGENT_RELAY_TELEMETRY_SURFACE', 'AGENT_RELAY_TELEMETRY_CLIENT', @@ -299,13 +299,13 @@ describe('authorizedApiFetch telemetry headers', () => { } } - it('adds Agent Relay identity and origin headers when the CLI provides a telemetry client id', async () => { + 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_CLIENT_ID = 'abc123def4567890'; + 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'; @@ -334,14 +334,14 @@ describe('authorizedApiFetch telemetry headers', () => { 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-anonymous-id')).toBe('abc123def4567890'); + 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 client id is provided', async () => { + 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 }; @@ -364,7 +364,7 @@ describe('authorizedApiFetch telemetry headers', () => { const init = fetchSpy.mock.calls[0][1] as RequestInit; const headers = new Headers(init.headers); - expect(headers.get('x-agent-relay-anonymous-id')).toBeNull(); + expect(headers.get('x-agent-relay-distinct-id')).toBeNull(); expect(headers.get('x-relaycast-harness')).toBeNull(); }); }); diff --git a/packages/cloud/src/telemetry-headers.ts b/packages/cloud/src/telemetry-headers.ts index d85104876..2b6832942 100644 --- a/packages/cloud/src/telemetry-headers.ts +++ b/packages/cloud/src/telemetry-headers.ts @@ -1,15 +1,15 @@ -export const AGENT_RELAY_ANONYMOUS_ID_HEADER = 'X-Agent-Relay-Anonymous-Id'; +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_CLIENT_ID_ENV = 'AGENT_RELAY_CLIENT_ID'; +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 CLIENT_ID_ALLOWED = /^[a-z0-9._:-]+$/i; +const DISTINCT_ID_ALLOWED = /^[a-z0-9._:-]+$/i; const HEADER_VALUE_ALLOWED = /^[a-z0-9 ._\-/():=;,+@]+$/i; function sanitizeHeaderValue( @@ -33,14 +33,14 @@ export function buildAgentRelayTelemetryHeaders( ): Record { if (isTelemetryDisabledByEnv(env)) return {}; - const clientId = sanitizeHeaderValue(env[AGENT_RELAY_CLIENT_ID_ENV], { + const distinctId = sanitizeHeaderValue(env[AGENT_RELAY_DISTINCT_ID_ENV], { maxLength: 128, - pattern: CLIENT_ID_ALLOWED, + pattern: DISTINCT_ID_ALLOWED, }); - if (!clientId) return {}; + if (!distinctId) return {}; const headers: Record = { - [AGENT_RELAY_ANONYMOUS_ID_HEADER]: clientId, + [AGENT_RELAY_DISTINCT_ID_HEADER]: distinctId, }; const harness = sanitizeHeaderValue(env[ORCHESTRATOR_HARNESS_ENV], { From c1d79d33bb8415c868fac03167e422dddfbbf61b Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 3 Jun 2026 07:09:42 -0400 Subject: [PATCH 4/7] refactor(telemetry): keep analytics out of SDK --- CHANGELOG.md | 9 +- package-lock.json | 11 +- package.json | 4 +- packages/cli/package.json | 2 +- packages/cli/src/cli/bootstrap.ts | 6 +- packages/cli/src/cli/commands/cloud.test.ts | 2 +- packages/cli/src/cli/commands/cloud.ts | 2 +- packages/cli/src/cli/commands/setup.ts | 6 +- packages/cli/src/cli/entrypoint.test.ts | 4 +- packages/cli/src/cli/lib/broker-lifecycle.ts | 2 +- packages/cli/src/cli/lib/exit.ts | 2 +- .../src => cli/src/cli/telemetry}/client.ts | 20 +- .../src => cli/src/cli/telemetry}/config.ts | 33 +-- .../src => cli/src/cli/telemetry}/events.ts | 32 --- .../src => cli/src/cli/telemetry}/index.ts | 8 +- .../src/cli/telemetry}/machine-id.ts | 4 +- .../telemetry}/orchestrator-harness.test.ts | 0 .../cli/telemetry}/orchestrator-harness.ts | 0 .../src/cli/telemetry}/posthog-config.ts | 0 packages/sdk/package.json | 1 - .../sdk/src/__tests__/agent-relay.test.ts | 44 ++-- packages/sdk/src/agent-relay.ts | 33 ++- packages/sdk/src/facade.ts | 191 +++++++++--------- packages/sdk/src/telemetry.ts | 106 ---------- packages/sdk/tsconfig.build.json | 5 +- packages/sdk/tsconfig.json | 5 +- packages/telemetry/package.json | 32 +-- packages/telemetry/tsconfig.json | 21 -- 28 files changed, 196 insertions(+), 389 deletions(-) rename packages/{telemetry/src => cli/src/cli/telemetry}/client.ts (94%) rename packages/{telemetry/src => cli/src/cli/telemetry}/config.ts (78%) rename packages/{telemetry/src => cli/src/cli/telemetry}/events.ts (93%) rename packages/{telemetry/src => cli/src/cli/telemetry}/index.ts (83%) rename packages/{telemetry/src => cli/src/cli/telemetry}/machine-id.ts (94%) rename packages/{telemetry/src => cli/src/cli/telemetry}/orchestrator-harness.test.ts (100%) rename packages/{telemetry/src => cli/src/cli/telemetry}/orchestrator-harness.ts (100%) rename packages/{telemetry/src => cli/src/cli/telemetry}/posthog-config.ts (100%) delete mode 100644 packages/sdk/src/telemetry.ts delete mode 100644 packages/telemetry/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b525ff96..1844f72d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,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` telemetry now tags CLI, broker, SDK, and hosted cloud requests with app, surface, orchestrator harness, and distinct client identity context when telemetry is enabled. +- `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 +45,12 @@ 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. + +### 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/package-lock.json b/package-lock.json index 142fc2201..0ad242ae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18158,13 +18158,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.3.0", "commander": "^12.1.0", "dotenv": "^17.2.3", "esbuild": "^0.27.2", + "posthog-node": "^5.29.2", "zod": "^3.23.8" }, "bin": { @@ -18268,7 +18268,6 @@ "name": "@agent-relay/sdk", "version": "7.1.1", "dependencies": { - "@agent-relay/telemetry": "7.1.1", "@relaycast/sdk": "^2.3.0" }, "devDependencies": { @@ -18278,13 +18277,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 135172316..ee71d72d6 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.3.0", "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 d1dd38e63..f5e9db371 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -12,12 +12,12 @@ import { checkForUpdatesInBackground } from '@agent-relay/utils'; import { ORCHESTRATOR_HARNESS_ENV, detectOrchestratorHarness, - getAnonymousId, + getDistinctId, initTelemetry, isEnabled as isTelemetryEnabled, shutdown as shutdownTelemetry, track, -} from '@agent-relay/telemetry'; +} from './telemetry/index.js'; import { CliExit } from './lib/exit.js'; import { errorClassName } from './lib/telemetry-helpers.js'; @@ -134,7 +134,7 @@ function propagateTelemetryContextToChildren(): string { process.env[TELEMETRY_CLIENT_ENV] = 'agent-relay'; } if (!process.env[AGENT_RELAY_DISTINCT_ID_ENV] && hasConfiguredTelemetryKey() && isTelemetryEnabled()) { - process.env[AGENT_RELAY_DISTINCT_ID_ENV] = getAnonymousId(); + process.env[AGENT_RELAY_DISTINCT_ID_ENV] = getDistinctId(); } return 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 dcb5b2a9a..a16633e2e 100644 --- a/packages/cli/src/cli/entrypoint.test.ts +++ b/packages/cli/src/cli/entrypoint.test.ts @@ -15,10 +15,10 @@ 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'), - getAnonymousId: vi.fn(() => 'anon-test'), + getDistinctId: vi.fn(() => 'distinct-test'), initTelemetry: vi.fn(), isEnabled: vi.fn(() => false), track: vi.fn(), 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 94% rename from packages/telemetry/src/client.ts rename to packages/cli/src/cli/telemetry/client.ts index 6c973694a..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, @@ -21,7 +21,7 @@ import { detectOrchestratorHarness, UNKNOWN_ORCHESTRATOR_HARNESS } from './orche 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 { @@ -113,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(''); @@ -169,17 +169,17 @@ export function initTelemetry(options: InitTelemetryOptions = {}): void { surface: options.surface, orchestratorHarness: options.orchestratorHarness, }); - anonymousId = getAnonymousId(); + distinctId = getDistinctId(); } export function track( event: E, properties?: TelemetryEventMap[E] & Partial ): void { - if (!client || !commonProps || !anonymousId) return; + if (!client || !commonProps || !distinctId) return; client.capture({ - distinctId: anonymousId, + distinctId, event, properties: { ...commonProps, @@ -198,7 +198,7 @@ export async function shutdown(): Promise { } finally { client = null; commonProps = null; - anonymousId = null; + distinctId = null; initialized = false; } } @@ -207,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/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 93% rename from packages/telemetry/src/events.ts rename to packages/cli/src/cli/telemetry/events.ts index bc5bc731a..5e52dfb9a 100644 --- a/packages/telemetry/src/events.ts +++ b/packages/cli/src/cli/telemetry/events.ts @@ -205,34 +205,6 @@ export interface CliCommandCompleteEvent { error_class?: string; } -/** - * sdk_method_call - Emitted by @agent-relay/sdk around high-level public methods. - */ -export interface SdkMethodCallEvent { - /** Stable method identifier, e.g. `messages.send` or `workspace.register`. */ - method: string; - /** True if the method completed without throwing. */ - success: boolean; - /** Wall-clock duration in milliseconds. */ - duration_ms: number; - /** Error constructor name on failure. */ - error_class?: string; -} - -/** - * sdk_workflow_run - Emitted by @agent-relay/sdk for higher-level SDK workflows. - */ -export interface SdkWorkflowRunEvent { - /** Stable operation identifier, e.g. `create_workspace`. */ - operation: string; - /** True if the workflow completed without throwing. */ - success: boolean; - /** Wall-clock duration in milliseconds. */ - duration_ms: number; - /** Error constructor name on failure. */ - error_class?: string; -} - // ============================================================================= // Tier 3: Domain Events (high-signal product flows) // ============================================================================= @@ -395,8 +367,6 @@ export type TelemetryEventName = | 'message_send' | 'cli_command_run' | 'cli_command_complete' - | 'sdk_method_call' - | 'sdk_workflow_run' | 'workflow_run' | 'cloud_auth' | 'cloud_workflow_run' @@ -416,8 +386,6 @@ export interface TelemetryEventMap { message_send: MessageSendEvent; cli_command_run: CliCommandRunEvent; cli_command_complete: CliCommandCompleteEvent; - sdk_method_call: SdkMethodCallEvent; - sdk_workflow_run: SdkWorkflowRunEvent; workflow_run: WorkflowRunEvent; cloud_auth: CloudAuthEvent; cloud_workflow_run: CloudWorkflowRunEvent; diff --git a/packages/telemetry/src/index.ts b/packages/cli/src/cli/telemetry/index.ts similarity index 83% rename from packages/telemetry/src/index.ts rename to packages/cli/src/cli/telemetry/index.ts index e8a4af9dc..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'; @@ -41,8 +41,6 @@ export type { MessageSendEvent, CliCommandRunEvent, CliCommandCompleteEvent, - SdkMethodCallEvent, - SdkWorkflowRunEvent, WorkflowRunEvent, CloudAuthEvent, CloudWorkflowRunEvent, @@ -54,7 +52,7 @@ 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, 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/telemetry/src/orchestrator-harness.test.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.test.ts similarity index 100% rename from packages/telemetry/src/orchestrator-harness.test.ts rename to packages/cli/src/cli/telemetry/orchestrator-harness.test.ts diff --git a/packages/telemetry/src/orchestrator-harness.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.ts similarity index 100% rename from packages/telemetry/src/orchestrator-harness.ts rename to packages/cli/src/cli/telemetry/orchestrator-harness.ts diff --git a/packages/telemetry/src/posthog-config.ts b/packages/cli/src/cli/telemetry/posthog-config.ts similarity index 100% rename from packages/telemetry/src/posthog-config.ts rename to packages/cli/src/cli/telemetry/posthog-config.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6e37ead26..475307ca5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -58,7 +58,6 @@ "@types/node": "^22.13.10" }, "dependencies": { - "@agent-relay/telemetry": "7.1.1", "@relaycast/sdk": "^2.3.0" } } diff --git a/packages/sdk/src/__tests__/agent-relay.test.ts b/packages/sdk/src/__tests__/agent-relay.test.ts index 9860d80b5..326db6bc0 100644 --- a/packages/sdk/src/__tests__/agent-relay.test.ts +++ b/packages/sdk/src/__tests__/agent-relay.test.ts @@ -2,27 +2,29 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const relaycastMocks = vi.hoisted(() => { const createWorkspace = vi.fn(); - const relayCast = vi.fn().mockImplementation((config: Record) => ({ - config, - agents: { - list: vi.fn(async () => []), - get: vi.fn(), - register: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - presence: vi.fn(async () => []), - }, - channels: { - list: vi.fn(async () => []), - get: vi.fn(), - }, - messages: { - list: vi.fn(async () => []), - get: vi.fn(), - thread: vi.fn(), - reactions: vi.fn(async () => []), - }, - })); + const relayCast = vi.fn().mockImplementation(function (config: Record) { + return { + config, + agents: { + list: vi.fn(async () => []), + get: vi.fn(), + register: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + presence: vi.fn(async () => []), + }, + channels: { + list: vi.fn(async () => []), + get: vi.fn(), + }, + messages: { + list: vi.fn(async () => []), + get: vi.fn(), + thread: vi.fn(), + reactions: vi.fn(async () => []), + }, + }; + }); return { createWorkspace, relayCast }; }); diff --git a/packages/sdk/src/agent-relay.ts b/packages/sdk/src/agent-relay.ts index c43d3a5b9..7d58b6f04 100644 --- a/packages/sdk/src/agent-relay.ts +++ b/packages/sdk/src/agent-relay.ts @@ -32,7 +32,6 @@ import { type RelayEvent, } from './listeners.js'; import type { AgentSessionEvent } from './session/index.js'; -import { withSdkWorkflowTelemetry } from './telemetry.js'; export interface AgentRelayOptions extends RelaycastMessagingOptions { messaging?: RelayMessaging; @@ -93,23 +92,21 @@ export class AgentRelay implements AgentRelayAgent { } static async createWorkspace(input: string | AgentRelayCreateWorkspaceInput): Promise { - return withSdkWorkflowTelemetry('create_workspace', async () => { - const options = typeof input === 'string' ? { name: input } : input; - const workspace = (await RelayCast.createWorkspace(options.name, { - baseUrl: options.baseUrl, - })) as Record; - const workspaceKey = extractWorkspaceKey(workspace); - - if (!workspaceKey) { - throw new Error('Workspace created, but the response did not include a workspace key.'); - } - - return new AgentRelay({ - workspaceKey, - baseUrl: options.baseUrl, - retryPolicy: options.retryPolicy, - actions: options.actions, - }); + const options = typeof input === 'string' ? { name: input } : input; + const workspace = (await RelayCast.createWorkspace(options.name, { + baseUrl: options.baseUrl, + })) as Record; + const workspaceKey = extractWorkspaceKey(workspace); + + if (!workspaceKey) { + throw new Error('Workspace created, but the response did not include a workspace key.'); + } + + return new AgentRelay({ + workspaceKey, + baseUrl: options.baseUrl, + retryPolicy: options.retryPolicy, + actions: options.actions, }); } diff --git a/packages/sdk/src/facade.ts b/packages/sdk/src/facade.ts index fcaf9cf2c..79f41d898 100644 --- a/packages/sdk/src/facade.ts +++ b/packages/sdk/src/facade.ts @@ -20,7 +20,6 @@ import { } from './actions/index.js'; import type { DeliveryMode } from './delivery/index.js'; import type { RelayAgentHandle } from './listeners.js'; -import { withSdkMethodTelemetry } from './telemetry.js'; /** * A reference to an agent accepted by the high-level facade APIs. Agents may be @@ -219,124 +218,122 @@ export function createEnrichedMessages( ): EnrichedMessages { const enriched: EnrichedMessages = Object.create(base) as EnrichedMessages; - enriched.send = (input: RelaySendChannelMessageInput | RelaySendMessageInput) => - withSdkMethodTelemetry('messages.send', async () => { - if ('channel' in input && input.channel) { - return base.send(input); - } - const sendInput = input as RelaySendMessageInput; - const messages = resolveFrom(sendInput.from); - const text = buildText(sendInput.text ?? sendInput.msg, sendInput.mentions); - if (Array.isArray(sendInput.to)) { - return messages.groupDirect({ - participants: sendInput.to.map(resolveAgentName), - text, - attachments: sendInput.attachments, - mode: sendInput.mode, - idempotencyKey: sendInput.idempotencyKey, - }); - } - if (isChannelTarget(sendInput.to)) { - return messages.send({ - channel: stripSigil(sendInput.to), - text, - blocks: sendInput.blocks, - attachments: sendInput.attachments, - mode: sendInput.mode, - idempotencyKey: sendInput.idempotencyKey, - }); - } - return messages.direct({ - to: resolveAgentName(sendInput.to), + enriched.send = async (input: RelaySendChannelMessageInput | RelaySendMessageInput) => { + if ('channel' in input && input.channel) { + return base.send(input); + } + const sendInput = input as RelaySendMessageInput; + const messages = resolveFrom(sendInput.from); + const text = buildText(sendInput.text ?? sendInput.msg, sendInput.mentions); + if (Array.isArray(sendInput.to)) { + return messages.groupDirect({ + participants: sendInput.to.map(resolveAgentName), text, attachments: sendInput.attachments, mode: sendInput.mode, idempotencyKey: sendInput.idempotencyKey, }); - }); - - enriched.reply = (input: RelayReplyInput) => - withSdkMethodTelemetry('messages.reply', async () => { - const messages = resolveFrom(input.from); - return messages.reply({ - messageId: resolveMessageId(input.messageId ?? input.thread), - text: input.text, - blocks: input.blocks, - idempotencyKey: input.idempotencyKey, + } + if (isChannelTarget(sendInput.to)) { + return messages.send({ + channel: stripSigil(sendInput.to), + text, + blocks: sendInput.blocks, + attachments: sendInput.attachments, + mode: sendInput.mode, + idempotencyKey: sendInput.idempotencyKey, }); + } + return messages.direct({ + to: resolveAgentName(sendInput.to), + text, + attachments: sendInput.attachments, + mode: sendInput.mode, + idempotencyKey: sendInput.idempotencyKey, }); + }; - enriched.react = ((arg1: string | RelayReactInput, arg2?: string): Promise => { - return withSdkMethodTelemetry('messages.react', async () => { - if (typeof arg1 === 'string') { - return base.react(arg1, arg2 as string); - } - const messageId = typeof arg1.message === 'string' ? arg1.message : arg1.message.id; - const messages = resolveFrom(arg1.agent); - return messages.react(messageId, arg1.emoji); + enriched.reply = async (input: RelayReplyInput) => { + const messages = resolveFrom(input.from); + return messages.reply({ + messageId: resolveMessageId(input.messageId ?? input.thread), + text: input.text, + blocks: input.blocks, + idempotencyKey: input.idempotencyKey, }); + }; + + enriched.react = (async ( + arg1: string | RelayReactInput, + arg2?: string + ): Promise => { + if (typeof arg1 === 'string') { + return base.react(arg1, arg2 as string); + } + const messageId = typeof arg1.message === 'string' ? arg1.message : arg1.message.id; + const messages = resolveFrom(arg1.agent); + return messages.react(messageId, arg1.emoji); }) as EnrichedMessages['react']; - enriched.dm = (input: RelayDirectInput) => - withSdkMethodTelemetry('messages.direct', async () => { - const messages = resolveFrom(input.from); - return messages.direct({ - to: resolveAgentName(input.to), - text: input.text ?? input.msg ?? '', - attachments: input.attachments, - mode: input.mode, - idempotencyKey: input.idempotencyKey, - }); + enriched.dm = async (input: RelayDirectInput) => { + const messages = resolveFrom(input.from); + return messages.direct({ + to: resolveAgentName(input.to), + text: input.text ?? input.msg ?? '', + attachments: input.attachments, + mode: input.mode, + idempotencyKey: input.idempotencyKey, }); + }; return enriched; } export function createWorkspaceFacade(messaging: RelayMessaging, deps?: WorkspaceFacadeDeps): RelayWorkspace { - const register = async (agents: AgentLike | AgentLike[]): Promise => - withSdkMethodTelemetry('workspace.register', async () => { - if (!deps) { - throw new Error('register() is only available on the workspace client.'); - } - const list = Array.isArray(agents) ? agents : [agents]; - const inputs = list.map((agent) => - typeof agent === 'string' - ? { name: stripSigil(agent) } - : { - name: resolveAgentName(agent), - type: agent.type, - persona: agent.persona, - metadata: agent.metadata, - } - ); - - // Fail fast on in-batch duplicates so a batch can't partially register - // before the relay rejects a later duplicate name. - const seen = new Set(); - for (const { name } of inputs) { - if (seen.has(name)) { - throw new Error(`Duplicate agent name in register(): "${name}".`); - } - seen.add(name); - } + const register = async ( + agents: AgentLike | AgentLike[] + ): Promise => { + if (!deps) { + throw new Error('register() is only available on the workspace client.'); + } + const list = Array.isArray(agents) ? agents : [agents]; + const inputs = list.map((agent) => + typeof agent === 'string' + ? { name: stripSigil(agent) } + : { + name: resolveAgentName(agent), + type: agent.type, + persona: agent.persona, + metadata: agent.metadata, + } + ); - const clients: RelayAgentClient[] = []; - for (const input of inputs) { - clients.push(deps.buildAgentClient(await messaging.agents.register(input))); + // Fail fast on in-batch duplicates so a batch can't partially register + // before the relay rejects a later duplicate name. + const seen = new Set(); + for (const { name } of inputs) { + if (seen.has(name)) { + throw new Error(`Duplicate agent name in register(): "${name}".`); } - return Array.isArray(agents) ? clients : clients[0]; - }); + seen.add(name); + } + + const clients: RelayAgentClient[] = []; + for (const input of inputs) { + clients.push(deps.buildAgentClient(await messaging.agents.register(input))); + } + return Array.isArray(agents) ? clients : clients[0]; + }; return { - info: () => withSdkMethodTelemetry('workspace.info', () => messaging.workspace.info()), + info: () => messaging.workspace.info(), register: register as RelayWorkspace['register'], - reconnect: ({ apiToken }) => - withSdkMethodTelemetry('workspace.reconnect', async () => { - if (!deps) { - throw new Error('reconnect() is only available on the workspace client.'); - } - return deps.reconnectAgent(apiToken); - }), + reconnect: async ({ apiToken }) => { + if (!deps) { + throw new Error('reconnect() is only available on the workspace client.'); + } + return deps.reconnectAgent(apiToken); + }, }; } diff --git a/packages/sdk/src/telemetry.ts b/packages/sdk/src/telemetry.ts deleted file mode 100644 index 316a1bc2f..000000000 --- a/packages/sdk/src/telemetry.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - detectOrchestratorHarness, - initTelemetry, - track, - type CommonProperties, -} from '@agent-relay/telemetry'; - -let initialized = false; -let cachedHarness: string | undefined; - -function resolveSdkVersion(): string | undefined { - const version = process.env.AGENT_RELAY_SDK_VERSION?.trim(); - return version || undefined; -} - -function orchestratorHarness(): string { - cachedHarness ??= detectOrchestratorHarness(); - return cachedHarness; -} - -function errorClassName(error: unknown): string | undefined { - if (error instanceof Error) return error.constructor.name; - if (error && typeof error === 'object') { - const ctor = (error as { constructor?: { name?: string } }).constructor; - return ctor?.name || 'Object'; - } - return typeof error; -} - -export function initSdkTelemetry(): void { - if (initialized) return; - initialized = true; - initTelemetry({ - showNotice: false, - sdkVersion: resolveSdkVersion(), - app: 'sdk', - surface: 'sdk', - orchestratorHarness: orchestratorHarness(), - }); -} - -function sdkCommonOverrides(): Partial { - return { - app: 'sdk', - surface: 'sdk', - orchestrator_harness: orchestratorHarness(), - }; -} - -export function trackSdkMethodCall(input: { - method: string; - success: boolean; - durationMs: number; - error?: unknown; -}): void { - initSdkTelemetry(); - const errorClass = input.error === undefined ? undefined : errorClassName(input.error); - track('sdk_method_call', { - method: input.method, - success: input.success, - duration_ms: input.durationMs, - ...(errorClass ? { error_class: errorClass } : {}), - ...sdkCommonOverrides(), - }); -} - -export function trackSdkWorkflowRun(input: { - operation: string; - success: boolean; - durationMs: number; - error?: unknown; -}): void { - initSdkTelemetry(); - const errorClass = input.error === undefined ? undefined : errorClassName(input.error); - track('sdk_workflow_run', { - operation: input.operation, - success: input.success, - duration_ms: input.durationMs, - ...(errorClass ? { error_class: errorClass } : {}), - ...sdkCommonOverrides(), - }); -} - -export async function withSdkMethodTelemetry(method: string, fn: () => Promise): Promise { - const started = Date.now(); - try { - const result = await fn(); - trackSdkMethodCall({ method, success: true, durationMs: Date.now() - started }); - return result; - } catch (error) { - trackSdkMethodCall({ method, success: false, durationMs: Date.now() - started, error }); - throw error; - } -} - -export async function withSdkWorkflowTelemetry(operation: string, fn: () => Promise): Promise { - const started = Date.now(); - try { - const result = await fn(); - trackSdkWorkflowRun({ operation, success: true, durationMs: Date.now() - started }); - return result; - } catch (error) { - trackSdkWorkflowRun({ operation, success: false, durationMs: Date.now() - started, error }); - throw error; - } -} diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json index 7375988b1..0c970cfdd 100644 --- a/packages/sdk/tsconfig.build.json +++ b/packages/sdk/tsconfig.build.json @@ -8,9 +8,7 @@ "@agent-relay/config": ["../config/dist/index.d.ts"], "@agent-relay/config/*": ["../config/dist/*"], "@agent-relay/cloud": ["../cloud/dist/index.d.ts"], - "@agent-relay/cloud/*": ["../cloud/dist/*"], - "@agent-relay/telemetry": ["../telemetry/dist/index.d.ts"], - "@agent-relay/telemetry/*": ["../telemetry/dist/*"] + "@agent-relay/cloud/*": ["../cloud/dist/*"] }, "strict": true, "declaration": true, @@ -26,7 +24,6 @@ "src/agent-relay.ts", "src/capabilities.ts", "src/facade.ts", - "src/telemetry.ts", "src/relaycast-errors.ts", "src/messaging/**/*.ts", "src/delivery/**/*.ts", diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index bea136120..3ff6a392f 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -7,9 +7,7 @@ "@agent-relay/config": ["../config/src/index.ts"], "@agent-relay/config/*": ["../config/src/*"], "@agent-relay/cloud": ["../cloud/src/index.ts"], - "@agent-relay/cloud/*": ["../cloud/src/*"], - "@agent-relay/telemetry": ["../telemetry/src/index.ts"], - "@agent-relay/telemetry/*": ["../telemetry/src/*"] + "@agent-relay/cloud/*": ["../cloud/src/*"] }, "noEmit": true }, @@ -18,7 +16,6 @@ "src/agent-relay.ts", "src/capabilities.ts", "src/facade.ts", - "src/telemetry.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"] -} From 96c78fdf5402d7bba5da974998f6615133af1a11 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 11:10:58 +0000 Subject: [PATCH 5/7] style: auto-format with Prettier --- packages/sdk/src/facade.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/sdk/src/facade.ts b/packages/sdk/src/facade.ts index 79f41d898..8a5770aa0 100644 --- a/packages/sdk/src/facade.ts +++ b/packages/sdk/src/facade.ts @@ -263,10 +263,7 @@ export function createEnrichedMessages( }); }; - enriched.react = (async ( - 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); } From e27ef0192ae9f21bc3573324c3672df67b9b858a Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 3 Jun 2026 07:25:08 -0400 Subject: [PATCH 6/7] fix(telemetry): use hosted ingestion proxy --- CHANGELOG.md | 1 + .../src/cli/telemetry/posthog-config.test.ts | 38 +++++++++++++++++++ .../cli/src/cli/telemetry/posthog-config.ts | 5 ++- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/cli/telemetry/posthog-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1844f72d8..94ee91844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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, 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 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/cli/src/cli/telemetry/posthog-config.ts b/packages/cli/src/cli/telemetry/posthog-config.ts index eb5598c63..a2c4ce276 100644 --- a/packages/cli/src/cli/telemetry/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]; From 44221481e1a1e1cf90be90053e08fe917521211d Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 3 Jun 2026 09:53:16 -0400 Subject: [PATCH 7/7] Fix telemetry CI failures --- .github/workflows/package-validation.yml | 2 +- crates/broker/src/telemetry.rs | 2 +- packages/cli/src/cli/telemetry/config.test.ts | 95 +++++++++++++++++++ .../cli/src/cli/telemetry/machine-id.test.ts | 45 +++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/cli/telemetry/config.test.ts create mode 100644 packages/cli/src/cli/telemetry/machine-id.test.ts 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/crates/broker/src/telemetry.rs b/crates/broker/src/telemetry.rs index 221474256..3f43b187b 100644 --- a/crates/broker/src/telemetry.rs +++ b/crates/broker/src/telemetry.rs @@ -379,7 +379,7 @@ fn lookup_process_info(pid: i32) -> Option<(i32, String)> { return None; } let stdout = String::from_utf8(output.stdout).ok()?; - let mut parts = stdout.trim().split_whitespace(); + let mut parts = stdout.split_whitespace(); let ppid = parts.next()?.parse::().ok()?; let command = parts.collect::>().join(" "); if command.is_empty() { 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/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]+$`)); + }); +});