Skip to content
Closed
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
4 changes: 4 additions & 0 deletions src/agent/use-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ToolDefinition } from "@intx/types/runtime";
import { type } from "arktype";

import { resolveSkillBody } from "../extensions/skills.js";
import { getTelemetry } from "../telemetry/singleton.js";

// Lazy skill loading: the available skills are listed by name + description in
// the system prompt, but their full instructions are pulled into context only
Expand Down Expand Up @@ -34,6 +35,9 @@ export function createUseSkillTool(cwd: string, skillDirs: string[] = []): Agent
if (name.length === 0) return "Error: use_skill requires a non-empty name.";
const body = await resolveSkillBody(cwd, name, skillDirs);
if (body === undefined) return `No skill named "${name}" is available.`;
// Only send the name once resolved against a real skill — never the
// raw, unvalidated model-supplied string.
getTelemetry().capture("skill_used", { skill_name: name });
return `Skill "${name}" — follow these instructions for this task:\n\n${body}`;
},
});
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ export async function handleFatal(kind: CrashKind, error: unknown): Promise<void
process.stderr.write("failed to write crash report\n");
}
await finalizeActiveRunOnCrash(error);
// kind is one of the two fixed process-level handler names, and error_class
// is the constructor name only — never the raw message/stack, which can
// carry local paths or interpolated secrets.
getTelemetry().capture("crash", {
error_class: error instanceof Error ? error.constructor.name : kind,
});
await getTelemetry().flush();
process.exit(1);
}

Expand Down
15 changes: 15 additions & 0 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ import {
import type { MCPClient } from "../mcp/client.js";
import { end, start } from "../perf/index.js";
import { currentTurnId } from "../perf/reactor-spans.js";
import { getTelemetry } from "../telemetry/singleton.js";

// request.tool is drawn from the fixed set of built-in/registered tool ids
// (never free text), so it doubles as the permission_kind enum.
function permissionDecision(outcome: ApprovalOutcome | undefined): "allow" | "deny" {
return outcome !== undefined && outcome.allow ? "allow" : "deny";
}

export type GateVerdict = { allowed: true } | { allowed: false; reason: string };

Expand Down Expand Up @@ -469,6 +476,10 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
? { decision: outcome.allow ? "allow" : "deny" }
: undefined,
);
getTelemetry().capture("permission_prompt", {
decision: permissionDecision(outcome),
permission_kind: request.tool,
});
}
if (outcome === undefined || !outcome.allow) {
const suffix =
Expand Down Expand Up @@ -522,6 +533,10 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
? { decision: outcome.allow ? "allow" : "deny" }
: undefined,
);
getTelemetry().capture("permission_prompt", {
decision: permissionDecision(outcome),
permission_kind: request.tool,
});
}
if (outcome === undefined || !outcome.allow) {
const suffix =
Expand Down
7 changes: 7 additions & 0 deletions src/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CommandPlugin } from "../tui/commands/registry.js";
import { pathIsInsideOrEqual } from "../util/path-contain.js";
import { parsePluginManifest, type PluginManifest } from "./manifest.js";
import { loadDataOnlyPlugin } from "./data-only.js";
import { getTelemetry } from "../telemetry/singleton.js";
import {
resolvePluginWarningHandler,
stderrPluginWarning,
Expand Down Expand Up @@ -163,6 +164,9 @@ export async function loadPluginEntry(
mod.origin = origin;
mod.pluginPath = resolve(entryPath);
}
if (origin !== undefined && dataOnly.manifest.id.length > 0) {
getTelemetry().capture("plugin_loaded", { plugin_id: dataOnly.manifest.id, origin });
}
return mod;
}
return null;
Expand Down Expand Up @@ -209,6 +213,9 @@ export async function loadPluginEntry(
result.origin = origin;
result.pluginPath = resolve(pluginDir);
}
if (origin !== undefined && manifest !== null && manifest.id.length > 0) {
getTelemetry().capture("plugin_loaded", { plugin_id: manifest.id, origin });
}
return result;
} catch (err) {
// Route through the same sink as skill/load warnings so a diagnostics
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { PluginModule } from "./loader.js";
import type { PluginConfig } from "../config/settings.js";
import { registerCommandPlugin } from "../tui/commands/registry.js";
import { registerWorkflowPlugin } from "../workflows/index.js";
import { getTelemetry } from "../telemetry/singleton.js";

export function isPluginEnabled(config: Record<string, PluginConfig>, id: string): boolean {
return config[id]?.enabled === true;
Expand Down Expand Up @@ -47,6 +48,7 @@ export function registerCommandPlugins(
if (!isEnabledCommandPlugin(mod, config)) continue;
registerCommandPlugin(mod.commandPlugin!);
registered.push(mod.manifest!.id);
getTelemetry().capture("plugin_used", { plugin_id: mod.manifest!.id });
}
return registered;
}
Expand All @@ -60,6 +62,7 @@ export function registerWorkflowPlugins(
if (!isEnabledWorkflowPlugin(mod, config)) continue;
registerWorkflowPlugin(mod.workflowPlugin!);
registered.push(mod.manifest!.id);
getTelemetry().capture("plugin_used", { plugin_id: mod.manifest!.id });
}
return registered;
}
22 changes: 21 additions & 1 deletion src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type { Approval, GrantScope } from "../permission/types.js";
import type { ReasoningEffort } from "../provider/reasoning-effort.js";
import type { SubAgentProvider } from "../subagent/index.js";
import { COMPACTOR_KEEP_RECENT_TURNS, createPruningCompactor } from "./compactor.js";
import { getTelemetry } from "../telemetry/singleton.js";

// ---------------------------------------------------------------------------
// 1. Sub-agent provider literal
Expand Down Expand Up @@ -252,9 +253,28 @@ export type SessionPruningCompactorArgs = {
export function createSessionPruningCompactor(
args: SessionPruningCompactorArgs,
): Compactor {
return createPruningCompactor({
const compactor = createPruningCompactor({
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
...(args.compactionMode !== "pruning" ? { summarize: args.summarize } : {}),
});
return {
...compactor,
async apply(turns, ctx) {
const turnsBefore = turns.length;
const startedAt = Date.now();
const result = await compactor.apply(turns, ctx);
// "no compaction needed" is a distinct trigger from an actual prune —
// both are captured so the event reflects compactor activity, not just
// successful compactions.
const trigger = result.record.decisions.summarizedTurnCount !== undefined ? "pruning" : "no_op";
getTelemetry().capture("compaction", {
trigger,
duration_ms: Date.now() - startedAt,
turns_before: turnsBefore,
turns_after: result.output.length,
});
return result;
},
};
}
19 changes: 19 additions & 0 deletions src/subagent/task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { cleanupSubAgentWorktree, createSubAgentWorktree, WorktreeError } from "
import { generateSessionId } from "../session/index.js";
import { end, start } from "../perf/index.js";
import { currentTurnId } from "../perf/reactor-spans.js";
import { getSessionId } from "../telemetry/index.js";
import { getTelemetry } from "../telemetry/singleton.js";
import { join } from "node:path";
import type {
NestedDispatchDeps,
Expand Down Expand Up @@ -484,6 +486,14 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
...(turnId !== null && turnId.length > 0 ? { turn_id: turnId } : {}),
},
});
const subagentStartedAt = Date.now();
// agentLabel is either a resolved, known profile id or the fixed
// "worker" fallback — never raw free text.
getTelemetry().capture("subagent_start", {
agent_name: agentLabel,
parent_session_id: getSessionId(),
});
let subagentStatus: "completed" | "cancelled" | "failed" = "completed";
try {
if (deps.useWorktree === true) {
const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId());
Expand Down Expand Up @@ -562,6 +572,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
turnBudgetStopAfterDispatches: TURN_BUDGET_STOP_AFTER_DISPATCHES,
};
if (wasCancelled) {
subagentStatus = "cancelled";
if (
session !== undefined &&
deps.sessions?.get(session.id)?.status === "running"
Expand All @@ -585,6 +596,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
(session !== undefined &&
deps.sessions?.get(session.id)?.status === "cancelled")
) {
subagentStatus = "cancelled";
briefLedger.recordOutcome(fingerprint, "cancelled");
if (
session !== undefined &&
Expand All @@ -594,6 +606,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
}
return await finishWithWorktree(taskToolResult(call.id, cancelledSubAgentMessage(description)));
}
subagentStatus = "failed";
// Run never produced a body — undo the admit so turn-budget retry budget
// is not burned by auth/provider crashes.
briefLedger.release(fingerprint);
Expand All @@ -613,6 +626,12 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {

} finally {
end(subagentSpanId);
getTelemetry().capture("subagent_end", {
agent_name: agentLabel,
parent_session_id: getSessionId(),
status: subagentStatus,
duration_ms: Date.now() - subagentStartedAt,
});
}
},
});
Expand Down
52 changes: 48 additions & 4 deletions src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,35 @@ const FLUSH_DEADLINE_MS = 500;
export const TELEMETRY_NOTICE =
"Anonymous usage telemetry is enabled (no prompts, code, or paths collected). Disable in /settings > Telemetry. Docs: docs/TELEMETRY.md";

export type TelemetryEvent = "cli_start" | "session_end" | "inference_turn";
export type TelemetryEvent =
| "cli_start"
| "session_end"
| "inference_turn"
| "slash_command"
| "skill_used"
| "plugin_loaded"
| "plugin_used"
| "subagent_start"
| "subagent_end"
| "permission_prompt"
| "compaction"
| "crash"
| "auth_failure";

// A per-process identifier, minted once and reused for every capture() call
// in this process (see CL-5727). It links events from the same run without
// ever carrying identity: it is not persisted and not derived from anything
// user-controlled.
const sessionId = crypto.randomUUID();

export function getSessionId(): string {
return sessionId;
}

// Per-event property allowlist. Anything not listed here is stripped before
// the payload leaves the process. Together with the fixed common properties
// capture() appends (service_version, os_type, os_arch, schema_version),
// this bounds everything telemetry can ever contain.
// capture() appends (service_version, os_type, os_arch, schema_version,
// session_id), this bounds everything telemetry can ever contain.
const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
cli_start: [],
session_end: ["status", "turn_count", "duration_ms", "session_mode", "exit_reason"],
Expand All @@ -43,8 +66,28 @@ const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
"thinking_tokens",
"duration_ms",
],
// command_name is the canonical registry name only (e.g. "feedback") —
// never the raw command line or any argument text typed after it.
slash_command: ["command_name"],
// skill_name is the resolved, known-skill identifier only, and only sent
// when resolution succeeded — never a filesystem path.
skill_used: ["skill_name"],
// plugin_id is the manifest-declared id, never the plugin's local path.
plugin_loaded: ["plugin_id", "origin"],
plugin_used: ["plugin_id"],
subagent_start: ["agent_name", "parent_session_id"],
subagent_end: ["agent_name", "parent_session_id", "status", "duration_ms"],
// decision and permission_kind are fixed enums — never the literal
// command, tool args, or file path being approved.
permission_prompt: ["decision", "permission_kind"],
compaction: ["trigger", "duration_ms", "turns_before", "turns_after"],
// error_class is a fixed/allowlisted code — never a raw error message.
crash: ["error_class"],
auth_failure: ["error_class"],
};

const KNOWN_EVENTS = new Set<TelemetryEvent>(Object.keys(EVENT_PROPERTY_ALLOWLIST) as TelemetryEvent[]);

const FALSY_ENV_FLAG_VALUES = new Set(["", "0", "false", "off", "no"]);

// Trimmed so .env files and shell scripts that produce " 0" or "false\n"
Expand Down Expand Up @@ -128,7 +171,7 @@ export function createTelemetry(options: CreateTelemetryOptions): Telemetry {

function capture(event: TelemetryEvent, properties?: Record<string, unknown>): void {
if (!enabled) return;
if (!(event === "cli_start" || event === "session_end" || event === "inference_turn")) return;
if (!KNOWN_EVENTS.has(event)) return;

const body = {
api_key: apiKey,
Expand All @@ -140,6 +183,7 @@ export function createTelemetry(options: CreateTelemetryOptions): Telemetry {
os_type: process.platform,
os_arch: process.arch,
schema_version: 1,
session_id: sessionId,
},
};

Expand Down
4 changes: 4 additions & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1852,6 +1852,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
isCodexAuthError,
isXaiAuthError,
);
if (kind === "codex_auth" || kind === "xai_auth") {
getTelemetry().capture("auth_failure", { error_class: kind });
}
if (!shouldSettleUiAfterSendFailure(kind)) return;
recordRunError(err);
systemRow(err instanceof Error ? err.message : String(err));
Expand Down Expand Up @@ -1961,6 +1964,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
systemRow(`Unknown command: ${name}`);
return;
}
getTelemetry().capture("slash_command", { command_name: command.name });
applyCommandResult(command.handler(args, commandContext));
};

Expand Down
Loading
Loading