diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index a421757654..e94050238b 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -84,6 +84,7 @@ We collect the following categories of events: | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | | `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it (`organization`, `you`, `workspace` or `default`). | | `rules_command` | `/rules` opened in a client (`view`), or a `learn` subcommand run (its name only, never its arguments). | +| `lineage_report` | What a person did with the Claude Code plugin's lineage report, sent by the plugin through `altimate-code tool report-event`: the end-of-task summary was shown, **Open report** was pressed, `/lineage-report` was run, or the report was built, failed to build or found no browser to open in. Its payload is the action and the Claude Code session id, beside the fields every event carries (below): no model, column, file or project names. The report page itself makes no network requests. | Each event includes a timestamp, session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and a machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and not derived from your hardware or identity). When the machine is signed in to Altimate, each event also carries `user_id` and `tenant`, described under [User Identification](#new-user-identification). diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 750f641264..65523c2fe5 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -918,6 +918,16 @@ export namespace Telemetry { command: string } // altimate_change end + // altimate_change start — the Claude Code plugin's lineage report, sent through `tool report-event` + | { + /** What a person did with the Claude Code plugin's lineage report. No model, column, file or project names. */ + type: "lineage_report" + timestamp: number + /** The host agent's session: `claude:`. */ + session_id: string + action: "summary_shown" | "open_pressed" | "command_run" | "built" | "build_failed" | "no_browser" + } + // altimate_change end | { // Sent before connecting, so an attempt that hangs or ends the process is still counted. type: "warehouse_connect_started" diff --git a/packages/opencode/src/altimate/tool-cli/report-event.ts b/packages/opencode/src/altimate/tool-cli/report-event.ts new file mode 100644 index 0000000000..2e31df7461 --- /dev/null +++ b/packages/opencode/src/altimate/tool-cli/report-event.ts @@ -0,0 +1,21 @@ +// altimate_change - new file +// +// `tool report-event`: the Claude Code plugin records what a person did with its lineage report, as one +// usage-telemetry event. Only the action and the host session are sent: no model, column, file or project +// names. The plugin's page itself stays offline; this runs in the CLI, under its telemetry settings, so the +// usual opt-out turns it off. + +export const REPORT_ACTIONS = ["summary_shown", "open_pressed", "command_run", "built", "build_failed", "no_browser"] as const +export type ReportAction = (typeof REPORT_ACTIONS)[number] + +/** A Claude Code session id as the plugin passes it: `claude:` and the host's own id. */ +const HOST_SESSION = /^claude:[A-Za-z0-9_-]{1,128}$/ + +export type ReportEvent = { type: "lineage_report"; timestamp: number; session_id: string; action: ReportAction } + +/** The event for an action and a host session, or why there is none. */ +export function reportEvent(action: unknown, session: unknown, now = Date.now()): ReportEvent | { error: string } { + if (!REPORT_ACTIONS.includes(action as ReportAction)) return { error: `the action must be one of ${REPORT_ACTIONS.join(", ")}` } + if (typeof session !== "string" || !HOST_SESSION.test(session)) return { error: "--session takes a Claude Code session id: claude:" } + return { type: "lineage_report", timestamp: now, session_id: session, action: action as ReportAction } +} diff --git a/packages/opencode/src/cli/cmd/tool.ts b/packages/opencode/src/cli/cmd/tool.ts index 03c35712e1..5ba4c2faf6 100644 --- a/packages/opencode/src/cli/cmd/tool.ts +++ b/packages/opencode/src/cli/cmd/tool.ts @@ -330,12 +330,33 @@ function writeAll(text: string) { } } +const ToolReportEventCommand = cmd({ + command: "report-event ", + describe: "record what a person did with the Claude Code plugin's lineage report (usage telemetry; nothing is sent when telemetry is off)", + builder: (yargs: Argv) => + yargs + .positional("action", { type: "string", describe: "what happened to the report", demandOption: true }) + .option("session", { type: "string", describe: "the Claude Code session: claude:", demandOption: true }), + handler: async (args) => { + const { reportEvent } = await import("../../altimate/tool-cli/report-event") + const event = reportEvent(args.action, args.session) + if ("error" in event) { + console.error(event.error) + process.exitCode = 1 + return + } + // Flushed, or dropped under the opt-out, when the CLI exits. + const { Telemetry } = await import("../../altimate/telemetry") + Telemetry.track(event) + }, +}) + /** How long `writeAll` waits for a reader that has stopped taking output before it gives up. */ const WRITE_STALL_MS = 60_000 export const ToolCommand = cmd({ command: "tool", describe: "run altimate-code data tools directly, without a model", - builder: (yargs: Argv) => yargs.command(ToolListCommand).command(ToolContextCommand).command(ToolSchemaCommand).command(ToolRunCommand).command(ToolDaemonCommand).demandCommand(), + builder: (yargs: Argv) => yargs.command(ToolListCommand).command(ToolContextCommand).command(ToolSchemaCommand).command(ToolRunCommand).command(ToolDaemonCommand).command(ToolReportEventCommand).demandCommand(), async handler() {}, }) diff --git a/packages/opencode/test/altimate/tool-cli/report-event.test.ts b/packages/opencode/test/altimate/tool-cli/report-event.test.ts new file mode 100644 index 0000000000..556d77cc46 --- /dev/null +++ b/packages/opencode/test/altimate/tool-cli/report-event.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { REPORT_ACTIONS, reportEvent } from "../../../src/altimate/tool-cli/report-event" + +describe("tool report-event", () => { + test("an action and a Claude Code session make one event with nothing else in it", () => { + expect(REPORT_ACTIONS).toEqual(["summary_shown", "open_pressed", "command_run", "built", "build_failed", "no_browser"]) + for (const action of REPORT_ACTIONS) { + expect(reportEvent(action, "claude:3f6c2a10-7b2e-4c1d-9a55-0e1f2a3b4c5d", 1000)).toEqual({ + type: "lineage_report", + timestamp: 1000, + session_id: "claude:3f6c2a10-7b2e-4c1d-9a55-0e1f2a3b4c5d", + action, + }) + } + }) + + test("an action outside the list is refused", () => { + for (const action of ["clicked_column", "", undefined, "built; rm -rf /"]) expect(reportEvent(action, "claude:abc")).toHaveProperty("error") + }) + + test("a session that is not claude: is refused, so no path or name can ride along", () => { + for (const session of ["abc", "codex:abc", "claude:", "claude:/home/someone/project", "claude:a b", `claude:${"x".repeat(129)}`, undefined]) + expect(reportEvent("built", session)).toHaveProperty("error") + }) +}) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index eeb9702758..5a59f74b6b 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -82,7 +82,6 @@ Options: [string]" `; -// altimate_change start — document the committed-answer echo allowance in run help exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` "altimate-code run [message..] @@ -144,7 +143,6 @@ Options: --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; -// altimate_change end exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` "altimate-code debug @@ -531,13 +529,16 @@ exports[`opencode CLI help-text snapshots every documented command emits stable run altimate-code data tools directly, without a model Commands: - altimate-code tool list list the data tools available to \`tool run\` - altimate-code tool context print the analyst method and the connections known at startup - (what \`mcp serve\` sends as instructions) - altimate-code tool schema print one tool's description and JSON input schema - altimate-code tool run run one data tool without a model and print its result - altimate-code tool daemon [action] run, inspect or stop the long-running tool process for a - project + altimate-code tool list list the data tools available to \`tool run\` + altimate-code tool context print the analyst method and the connections known at + startup (what \`mcp serve\` sends as instructions) + altimate-code tool schema print one tool's description and JSON input schema + altimate-code tool run run one data tool without a model and print its result + altimate-code tool daemon [action] run, inspect or stop the long-running tool process for a + project + altimate-code tool report-event record what a person did with the Claude Code plugin's + lineage report (usage telemetry; nothing is sent when + telemetry is off) Options: -h, --help show help [boolean]