Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Trajectory: Rope telemetry through CLI SDK and hosted backend

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Typo in heading: "Rope" should be "Route" to match the PR title and all other references in the document.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .agentworkforce/trajectories/completed/2026-06/traj_6kef2z1redhb/summary.md, line 1:

<comment>Typo in heading: "Rope" should be "Route" to match the PR title and all other references in the document.</comment>

<file context>
@@ -0,0 +1,45 @@
+# Trajectory: Rope telemetry through CLI SDK and hosted backend
+
+> **Status:** ✅ Completed
</file context>
Suggested change
# Trajectory: Rope telemetry through CLI SDK and hosted backend
# Trajectory: Route 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
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion .github/workflows/package-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `@agent-relay/sdk` adds the `relay.webhooks` namespace: `createInbound({ channel })` returns `{ url, token }` for posting `{ message, author }` into a channel, and `subscribe({ url, events, secret, headers })` for outbound HMAC-signed event delivery.
- `@agent-relay/sdk` agent clients send via `sendMessage({ to })` (`#channel`, `@handle`, or an array of `@handle`s for a group DM), `reply({ messageId })`, and `react({ messageId, emoji })`; every message exposes `messageId`.
- `@agent-relay/harnesses` adds `createHuman({ relay, name })` (self-registers a human, returns the live client) and re-exports `defineHarness` plus the harness contract types.
- `agent-relay` forwards CLI origin, orchestrator harness, and distinct client identity context to hosted Relaycast so backend telemetry can distinguish CLI/SDK traffic from raw API calls.

### Changed

Expand All @@ -45,7 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `README.md` and `packages/sdk/README.md` now present Agent Relay around three public SDK categories: messaging, delivery, and actions.
- `@agent-relay/sdk` actions accept Zod-compatible `safeParse` schemas alongside JSON-schema-lite, and `DeliveryRunner` can deliver inbox items to session targets through `receiveMessage(...)`.
- `agent-relay` keeps default commands focused on messaging, MCP, diagnostics, setup, and telemetry; managed harness lifecycle now lives under `agent-relay driver ...`.
- Root builds now validate the simplified core package set: config, utils, telemetry, SDK, harness-driver, harnesses, and CLI.
- Root builds now validate the simplified core package set: config, utils, SDK, harness-driver, harnesses, and CLI.
- `@agent-relay/sdk` no longer emits client-side analytics or depends on `@agent-relay/telemetry`; SDK/API attribution uses Relaycast origin metadata instead.
- `agent-relay` CLI telemetry now posts through the hosted ingestion proxy at `https://i.agentrelay.com` by default.

### Deprecated

- `@agent-relay/telemetry` is deprecated as a public npm package; telemetry implementation is now internal to the `agent-relay` CLI.
- `agent-relay mcp`: Agent Relay now ships its own MCP stdio server with underscore tool names such as `post_message` and `add_reaction`, and generated MCP configs use `npx -y agent-relay mcp`.
- `agent-relay mcp`: renamed the bundled implementation and command override to Agent Relay MCP (`AGENT_RELAY_MCP_COMMAND`).
- `agent-relay up`: broker startup no longer writes external MCP entries to project `.mcp.json`; spawned agents receive the MCP server through launch-time configuration.
Expand Down
178 changes: 178 additions & 0 deletions crates/broker/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -297,6 +299,144 @@ fn env_nonempty(key: &str) -> Option<String> {
})
}

fn sanitize_orchestrator_harness(raw: &str) -> Option<String> {
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::<String>().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");
Comment on lines +362 to +363

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The broker's harness inference also classifies any command containing zed as Zed, which creates false positives for unrelated process names and pollutes telemetry dimensions. Match only normalized executable names (or path segments) instead of generic substring checks. [incorrect condition logic]

Severity Level: Major ⚠️
- ⚠️ Broker telemetry mislabels arbitrary parents as Zed harness.
- ⚠️ Telemetry dashboards overreport Zed-driven broker usage.
Steps of Reproduction ✅
1. Observe that the broker CLI entrypoint `run()` in `crates/broker/src/cli/mod.rs:11-20`
constructs a telemetry client via `let telemetry = TelemetryClient::new();` (line ~114 in
the file, as shown in the repo code graph).

2. In `TelemetryClient::new` ( `crates/broker/src/telemetry.rs:540-599` ), the struct
field `orchestrator_harness` is initialized by calling `detect_orchestrator_harness()`
(line 26 of that snippet).

3. `detect_orchestrator_harness()` ( `crates/broker/src/telemetry.rs:164-179` ) falls back
to `detect_process_orchestrator_harness()` when no explicit harness env var is set; on
Unix, `detect_process_orchestrator_harness()` walks parent PIDs and calls
`lookup_process_info()` / `infer_harness_from_command()` for each parent (lines 134-147).

4. Inside `infer_harness_from_command()` ( `crates/broker/src/telemetry.rs:60-107` ), the
`zed` branch is implemented as `if base == "zed" || lower.contains("zed") { return
Some("zed"); }` (lines 362-363 in the current file), so a parent process command like
`/usr/bin/analyzed` (base `analyzed`, but `lower` contains the substring `zed`) is
misclassified as the Zed harness; this value is then emitted on every broker telemetry
event via the `orchestrator_harness` property merged in `TelemetryClient::track` (lines
44-57 in the `540-599` snippet).

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** crates/broker/src/telemetry.rs
**Line:** 362:363
**Comment:**
	*Incorrect Condition Logic: The broker's harness inference also classifies any command containing `zed` as Zed, which creates false positives for unrelated process names and pollutes telemetry dimensions. Match only normalized executable names (or path segments) instead of generic substring checks.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

None
}

#[cfg(unix)]
fn lookup_process_info(pid: i32) -> Option<(i32, String)> {
if pid <= 0 {
return None;
}
let output = std::process::Command::new("ps")
.args(["-o", "ppid=", "-o", "comm=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let mut parts = stdout.split_whitespace();
let ppid = parts.next()?.parse::<i32>().ok()?;
let command = parts.collect::<Vec<_>>().join(" ");
if command.is_empty() {
None
} else {
Some((ppid, command))
}
}

#[cfg(unix)]
fn detect_process_orchestrator_harness() -> Option<String> {
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<String> {
None
}

fn detect_orchestrator_harness() -> String {
for key in [
Comment on lines +423 to +424

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the CLI, detect_orchestrator_harness() is executed unconditionally on broker startup, which synchronously spawns ps up to 8 times on Unix platforms. This introduces unnecessary latency on startup when telemetry is disabled.

We should check if telemetry is disabled via environment variables (AGENT_RELAY_TELEMETRY_DISABLED or DO_NOT_TRACK) at the very beginning of the function to return early and avoid the expensive process traversal.

fn detect_orchestrator_harness() -> String {
    if let Ok(val) = std::env::var("AGENT_RELAY_TELEMETRY_DISABLED").or_else(|_| std::env::var("DO_NOT_TRACK")) {
        if val == "1" || val.to_lowercase() == "true" {
            return UNKNOWN_ORCHESTRATOR_HARNESS.to_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.
Expand Down Expand Up @@ -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<String>,
/// Harness or agent CLI that appears to be driving Agent Relay.
orchestrator_harness: String,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -372,6 +514,7 @@ impl TelemetryClient {
cli_version: None,
sdk_version: None,
os_version: None,
orchestrator_harness: UNKNOWN_ORCHESTRATOR_HARNESS.to_string(),
}
}

Expand Down Expand Up @@ -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(),
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading