Skip to content

Commit 15f3696

Browse files
committed
refactor(telemetry): use Map for agent env vars, add process tree detection
- Convert AGENT_ENV_VARS array-of-objects to a flat Map<string, string> mapping each env var directly to its agent name (simpler lookups, one-line additions for new agents) - Add process tree walking as fallback detection: scan parent → grandparent → ... for known agent executables (up to 5 levels) - Linux: reads /proc/<pid>/status (in-memory, no subprocess overhead) - macOS: falls back to ps(1) via execFileSync - Injectable via setProcessInfoProvider() for testing (same pattern as setEnv) - 50 tests (up from 29) covering env vars, Map structure, process tree walking, depth limits, and real process info reads
1 parent fa2b875 commit 15f3696

File tree

8 files changed

+407
-41
lines changed

8 files changed

+407
-41
lines changed

plugins/sentry-cli/skills/sentry-cli/references/dashboard.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ View a dashboard
4242
- `-w, --web - Open in browser`
4343
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
4444
- `-r, --refresh <value> - Auto-refresh interval in seconds (default: 60, min: 10)`
45-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"`
45+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"`
4646

4747
**Examples:**
4848

@@ -86,6 +86,7 @@ Add a widget to a dashboard
8686
- `--y <value> - Grid row position (0-based)`
8787
- `--width <value> - Widget width in grid columns (1–6)`
8888
- `--height <value> - Widget height in grid rows (min 1)`
89+
- `-l, --layout <value> - Layout mode: sequential (append in order) or dense (fill gaps) - (default: "sequential")`
8990

9091
**Examples:**
9192

plugins/sentry-cli/skills/sentry-cli/references/event.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ List events for an issue
2828
- `-n, --limit <value> - Number of events (1-1000) - (default: "25")`
2929
- `-q, --query <value> - Search query (Sentry search syntax)`
3030
- `--full - Include full event body (stacktraces)`
31-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")`
31+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")`
3232
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
3333
- `-c, --cursor <value> - Navigate pages: "next", "prev", "first" (or raw cursor string)`
3434

plugins/sentry-cli/skills/sentry-cli/references/issue.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ List issues in a project
1919
- `-q, --query <value> - Search query (Sentry search syntax)`
2020
- `-n, --limit <value> - Maximum number of issues to list - (default: "25")`
2121
- `-s, --sort <value> - Sort by: date, new, freq, user - (default: "date")`
22-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "90d")`
22+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")`
2323
- `-c, --cursor <value> - Pagination cursor (use "next" for next page, "prev" for previous)`
2424
- `--compact - Single-line rows for compact output (auto-detects if omitted)`
2525
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
@@ -78,7 +78,7 @@ List events for a specific issue
7878
- `-n, --limit <value> - Number of events (1-1000) - (default: "25")`
7979
- `-q, --query <value> - Search query (Sentry search syntax)`
8080
- `--full - Include full event body (stacktraces)`
81-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")`
81+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")`
8282
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
8383
- `-c, --cursor <value> - Navigate pages: "next", "prev", "first" (or raw cursor string)`
8484

plugins/sentry-cli/skills/sentry-cli/references/log.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ List logs from a project
1919
- `-n, --limit <value> - Number of log entries (1-1000) - (default: "100")`
2020
- `-q, --query <value> - Filter query (Sentry search syntax)`
2121
- `-f, --follow <value> - Stream logs (optionally specify poll interval in seconds)`
22-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"`
22+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"`
2323
- `-s, --sort <value> - Sort order: "newest" (default) or "oldest" - (default: "newest")`
2424
- `--fresh - Bypass cache, re-detect projects, and fetch fresh data`
2525

plugins/sentry-cli/skills/sentry-cli/references/span.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ List spans in a project or trace
1919
- `-n, --limit <value> - Number of spans (<=1000) - (default: "25")`
2020
- `-q, --query <value> - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")`
2121
- `-s, --sort <value> - Sort order: date, duration - (default: "date")`
22-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")`
22+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")`
2323
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
2424
- `-c, --cursor <value> - Navigate pages: "next", "prev", "first" (or raw cursor string)`
2525

plugins/sentry-cli/skills/sentry-cli/references/trace.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ List recent traces in a project
1919
- `-n, --limit <value> - Number of traces (1-1000) - (default: "25")`
2020
- `-q, --query <value> - Search query (Sentry search syntax)`
2121
- `-s, --sort <value> - Sort by: date, duration - (default: "date")`
22-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")`
22+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")`
2323
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
2424
- `-c, --cursor <value> - Navigate pages: "next", "prev", "first" (or raw cursor string)`
2525

@@ -78,7 +78,7 @@ View logs associated with a trace
7878

7979
**Flags:**
8080
- `-w, --web - Open trace in browser`
81-
- `-t, --period <value> - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "14d")`
81+
- `-t, --period <value> - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")`
8282
- `-n, --limit <value> - Number of log entries (<=1000) - (default: "100")`
8383
- `-q, --query <value> - Additional filter query (Sentry search syntax)`
8484
- `-s, --sort <value> - Sort order: "newest" (default) or "oldest" - (default: "newest")`

src/lib/detect-agent.ts

Lines changed: 185 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,66 +2,220 @@
22
* AI agent detection — determines whether the CLI is being driven by
33
* a specific AI coding agent.
44
*
5-
* Detection is based on environment variables that agents inject into
6-
* child processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0).
5+
* Detection uses two strategies:
6+
* 1. **Environment variables** that agents inject into child processes
7+
* (adapted from Vercel's @vercel/detect-agent, Apache-2.0)
8+
* 2. **Process tree walking** — scan parent/grandparent process names
9+
* for known agent executables (fallback when env vars are absent)
710
*
8-
* To add a new agent, append an entry to {@link AGENT_ENV_VARS}.
11+
* To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or
12+
* {@link PROCESS_NAME_AGENTS}.
913
*/
1014

15+
import { execFileSync } from "node:child_process";
16+
import { readFileSync } from "node:fs";
17+
import { basename } from "node:path";
18+
1119
import { getEnv } from "./env.js";
1220

1321
/**
14-
* Agent detection table. Checked in order — first match wins.
15-
* Each entry maps one or more env vars to an agent name.
22+
* Env var → agent name. Checked in insertion order — first match wins.
23+
* Each env var maps directly to the agent that sets it.
24+
*/
25+
export const ENV_VAR_AGENTS = new Map<string, string>([
26+
// Cursor
27+
["CURSOR_TRACE_ID", "cursor"],
28+
["CURSOR_AGENT", "cursor"],
29+
// Gemini CLI
30+
["GEMINI_CLI", "gemini"],
31+
// OpenAI Codex
32+
["CODEX_SANDBOX", "codex"],
33+
["CODEX_CI", "codex"],
34+
["CODEX_THREAD_ID", "codex"],
35+
// Antigravity
36+
["ANTIGRAVITY_AGENT", "antigravity"],
37+
// Augment
38+
["AUGMENT_AGENT", "augment"],
39+
// OpenCode
40+
["OPENCODE_CLIENT", "opencode"],
41+
// Replit
42+
["REPL_ID", "replit"],
43+
// GitHub Copilot
44+
["COPILOT_MODEL", "github-copilot"],
45+
["COPILOT_ALLOW_ALL", "github-copilot"],
46+
["COPILOT_GITHUB_TOKEN", "github-copilot"],
47+
// Goose
48+
["GOOSE_TERMINAL", "goose"],
49+
// Amp
50+
["AMP_THREAD_ID", "amp"],
51+
]);
52+
53+
/**
54+
* Process executable basename (lowercase) → agent name.
55+
* Used when scanning the parent process tree as a fallback.
1656
*/
17-
const AGENT_ENV_VARS: ReadonlyArray<{
18-
envVars: readonly string[];
19-
agent: string;
20-
}> = [
21-
{ envVars: ["CURSOR_TRACE_ID", "CURSOR_AGENT"], agent: "cursor" },
22-
{ envVars: ["GEMINI_CLI"], agent: "gemini" },
23-
{ envVars: ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"], agent: "codex" },
24-
{ envVars: ["ANTIGRAVITY_AGENT"], agent: "antigravity" },
25-
{ envVars: ["AUGMENT_AGENT"], agent: "augment" },
26-
{ envVars: ["OPENCODE_CLIENT"], agent: "opencode" },
27-
{ envVars: ["REPL_ID"], agent: "replit" },
28-
{
29-
envVars: ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"],
30-
agent: "github-copilot",
31-
},
32-
{ envVars: ["GOOSE_TERMINAL"], agent: "goose" },
33-
{ envVars: ["AMP_THREAD_ID"], agent: "amp" },
34-
];
57+
export const PROCESS_NAME_AGENTS = new Map<string, string>([
58+
["cursor", "cursor"],
59+
["claude", "claude"],
60+
["goose", "goose"],
61+
["windsurf", "windsurf"],
62+
["amp", "amp"],
63+
["codex", "codex"],
64+
["augment", "augment"],
65+
["opencode", "opencode"],
66+
["gemini", "gemini"],
67+
]);
68+
69+
/** Max levels to walk up the process tree before giving up. */
70+
const MAX_ANCESTOR_DEPTH = 5;
71+
72+
/** Pattern to extract `Name:` from `/proc/<pid>/status`. */
73+
const PROC_STATUS_NAME_RE = /^Name:\s+(.+)$/m;
74+
75+
/** Pattern to extract `PPid:` from `/proc/<pid>/status`. */
76+
const PROC_STATUS_PPID_RE = /^PPid:\s+(\d+)$/m;
77+
78+
/** Pattern to parse `ps -o ppid=,comm=` output: " <ppid> <comm>". */
79+
const PS_PPID_COMM_RE = /^(\d+)\s+(.+)$/;
80+
81+
/** Name + parent PID of a process. */
82+
type ProcessInfo = {
83+
/** Basename of the executable (e.g. "cursor", "bash"). */
84+
name: string;
85+
/** Parent process ID, or 0 if unavailable. */
86+
ppid: number;
87+
};
88+
89+
/**
90+
* Process info provider signature. Default reads from `/proc/` or `ps(1)`.
91+
* Override via {@link setProcessInfoProvider} for testing.
92+
*/
93+
type ProcessInfoProvider = (pid: number) => ProcessInfo | undefined;
94+
95+
let _getProcessInfo: ProcessInfoProvider = getProcessInfoFromOS;
96+
97+
/**
98+
* Override the process info provider. Follows the same pattern as
99+
* {@link setEnv} — call with a mock in tests, reset in `afterEach`.
100+
*
101+
* Pass `getProcessInfoFromOS` to restore the real implementation.
102+
*/
103+
export function setProcessInfoProvider(provider: ProcessInfoProvider): void {
104+
_getProcessInfo = provider;
105+
}
35106

36107
/**
37108
* Detect which AI agent (if any) is invoking the CLI.
38109
*
39-
* Priority: `AI_AGENT` override > specific agent env vars >
40-
* Claude Code (with cowork variant) > `AGENT` generic fallback.
110+
* Priority:
111+
* 1. `AI_AGENT` env var — explicit override, any agent can self-identify
112+
* 2. Agent-specific env vars from {@link ENV_VAR_AGENTS}
113+
* 3. Claude Code with Cowork variant (conditional, can't be in the map)
114+
* 4. Parent process tree — walk ancestors looking for known executables
115+
* 5. `AGENT` env var — generic fallback set by Goose, Amp, and others
41116
*
42117
* Returns the agent name string, or `undefined` if no agent is detected.
43118
*/
44119
export function detectAgent(): string | undefined {
45120
const env = getEnv();
46121

47-
// Highest priority: generic override — any agent can self-identify
122+
// 1. Highest priority: explicit override — any agent can self-identify
48123
const aiAgent = env.AI_AGENT?.trim();
49124
if (aiAgent) {
50125
return aiAgent;
51126
}
52127

53-
// Table-driven check for known agents
54-
for (const { envVars, agent } of AGENT_ENV_VARS) {
55-
if (envVars.some((v) => env[v])) {
128+
// 2. Table-driven env var check (Map iteration preserves insertion order)
129+
for (const [envVar, agent] of ENV_VAR_AGENTS) {
130+
if (env[envVar]) {
56131
return agent;
57132
}
58133
}
59134

60-
// Claude Code / Cowork (needs branching logic, so not in the table)
135+
// 3. Claude Code / Cowork — requires branching logic, so not in the map
61136
if (env.CLAUDECODE || env.CLAUDE_CODE) {
62137
return env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude";
63138
}
64139

65-
// Lowest priority: generic AGENT fallback (set by Goose, Amp, and others)
140+
// 4. Process tree: walk parent → grandparent → ... looking for known agents
141+
const processAgent = detectAgentFromProcessTree();
142+
if (processAgent) {
143+
return processAgent;
144+
}
145+
146+
// 5. Lowest priority: generic AGENT fallback
66147
return env.AGENT?.trim() || undefined;
67148
}
149+
150+
/**
151+
* Walk the ancestor process tree looking for known agent executables.
152+
*
153+
* Starts at the direct parent (`process.ppid`) and walks up to
154+
* {@link MAX_ANCESTOR_DEPTH} levels. Stops at PID 1 (init/launchd)
155+
* or on any read error (process exited, permission denied).
156+
*
157+
* On Linux, reads `/proc/<pid>/status` (in-memory, fast).
158+
* On macOS, falls back to `ps(1)`.
159+
*/
160+
export function detectAgentFromProcessTree(): string | undefined {
161+
let pid = process.ppid;
162+
163+
for (let depth = 0; depth < MAX_ANCESTOR_DEPTH && pid > 1; depth++) {
164+
const info = _getProcessInfo(pid);
165+
if (!info) {
166+
break;
167+
}
168+
169+
const agent = PROCESS_NAME_AGENTS.get(info.name.toLowerCase());
170+
if (agent) {
171+
return agent;
172+
}
173+
174+
pid = info.ppid;
175+
}
176+
177+
return;
178+
}
179+
180+
/**
181+
* Read process name and parent PID for a given PID.
182+
*
183+
* Tries `/proc/<pid>/status` first (Linux, no subprocess overhead),
184+
* falls back to `ps(1)` (macOS and other Unix systems).
185+
*
186+
* Returns `undefined` if the process doesn't exist or can't be read.
187+
*/
188+
export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined {
189+
// Linux: /proc is an in-memory filesystem — no subprocess needed
190+
try {
191+
const status = readFileSync(`/proc/${pid}/status`, "utf-8");
192+
const nameMatch = status.match(PROC_STATUS_NAME_RE);
193+
const ppidMatch = status.match(PROC_STATUS_PPID_RE);
194+
if (nameMatch?.[1] && ppidMatch?.[1]) {
195+
return { name: nameMatch[1].trim(), ppid: Number(ppidMatch[1]) };
196+
}
197+
} catch {
198+
// Not Linux or process is gone — fall through to ps
199+
}
200+
201+
// macOS / other Unix: use ps(1)
202+
if (process.platform !== "win32") {
203+
try {
204+
const result = execFileSync(
205+
"ps",
206+
["-p", String(pid), "-o", "ppid=,comm="],
207+
{
208+
encoding: "utf-8",
209+
stdio: ["pipe", "pipe", "ignore"],
210+
}
211+
);
212+
// Output: " 1234 /Applications/Cursor.app/Contents/MacOS/Cursor"
213+
const match = result.trim().match(PS_PPID_COMM_RE);
214+
if (match?.[1] && match?.[2]) {
215+
return { name: basename(match[2].trim()), ppid: Number(match[1]) };
216+
}
217+
} catch {
218+
// Process gone or ps not available
219+
}
220+
}
221+
}

0 commit comments

Comments
 (0)