Repository navigation
feat(cli): record command outcomes and first-touch surface in telemetry - #372
Conversation
[Medium impact] The latest changes appear safe to merge and fix the previous JSON-mode finding. SummaryThe PR records command outcomes and first-touch person fields, then hands queued analytics and error reports to a detached sender.
Reviews (5) · Last reviewed commit: "fix(cli): set json mode before tracking ..." · Reviewed by Greptile |
be151c6 to
4636d21
Compare
cliCommand is now captured after the command finishes with success, exit_code, duration_ms and, on failure, error_category / error_code, so funnels can count outcomes rather than invocations. $identify sets first_interface / first_cli_install_method / first_cli_version once. Assisted-by: Claude Opus 5.5 via Claude Code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqe2uWQDRZXDwo7DZEu5En
…g at exit Replace the worker-thread sender with a detached child process that posts the cli_command event and any Sentry envelopes after the parent exits, so commands no longer pay network latency on completion. Sentry uses a transport that hands envelopes to the same child. Assisted-by: Claude Opus 5.5 via Claude Code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqe2uWQDRZXDwo7DZEu5En
When a command with its own SIGINT handler (buckets migrate) is cancelled and then exits non-zero, the outcome now reads error_category=interrupted rather than general. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqe2uWQDRZXDwo7DZEu5En
Node's request timeout is socket inactivity, so a stalled connect or a trickling response could keep the sender child, or the in-process fallback, alive past the promised cap. post() now destroys the request at an elapsed-time deadline as well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqe2uWQDRZXDwo7DZEu5En
- Spawn the detached sender with its cwd in the temp dir. It outlives the command, and on Windows a directory that is any process's cwd cannot be deleted, so a script running the CLI in a temp folder could not remove it. - Replace the SIGINT listener-count heuristic with an explicit commandHandlesSigint() that `buckets migrate` calls. Any other listener (the prompt spinner) no longer stops Ctrl-C from ending the run. - Re-raise SIGINT on the next turn of the loop. Delivered synchronously, a second listener removing itself in the same event made Node drop the pending signal, and the process kept running. - On Windows exit 130 instead of process.kill(pid, 'SIGINT'), which is TerminateProcess with exit code 1 there. Also applies the Biome 2.6 formatting now on main to the changeset and README. Assisted-by: Claude Fable 5.1 via Claude Code Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7ee5ba8 to
6db0693
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6db0693. Configure here.
…path - JSON mode was entered inside loadAndExecuteCommand, after trackCommand had run and after local validation could exit. The first-run notice could print human text under --json, and events for invocations rejected by validation reported json_mode: false. Apply it from the parsed options at the top of both action paths. - Resolve argv[1] before handing it to the sender child, which now runs from the temp dir. Node and Bun already report an absolute path; this removes the dependency on that. Assisted-by: Claude Fable 5.1 via Claude Code Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Summary
cli_commandused to be sent when a command started, so a failed command was counted the same as a successful one. It is now sent when the command ends, carrying the outcome. The event name and every existing property stay the same.$identify(andcli_command's person update) also sets the first-touch surface once with$set_once.Neither PostHog nor Sentry does network I/O in the CLI process any more. Both queue ready-made requests, and when the command ends the queue goes to a detached background copy of the CLI that posts it after the command has exited. This also removes a pre-existing ~300ms Sentry delay on every naturally exiting command.
Properties
New on every
cli_command:success: boolean,exit_code === 0exit_code: integer process exit code (130for Ctrl-C)duration_ms: from dispatch to exit (excludes CLI startup)Only when
successis false:error_category: the Sentry categories fromutils/errors.ts(auth,permission,not_found,rate_limit,network,general) plususage(rejected by local validation before any work) andinterrupted(Ctrl-C or a cancelled prompt)error_code: only when the error carries a machine code: AWSCode/name(e.g.NoSuchKey), Node errno, commander code (commander.unknownOption), or a CLI code (unknown_command,missing_required_argument,removed_command,removed_argument,prompt_cancelled,command_load_failed). Never derived from the message.Person properties,
$set_once:first_interface: 'cli',first_cli_install_method,first_cli_version, on$identifyand oncli_command. The existing$setis unchanged.Delivery: a detached sender (
utils/telemetry-sender.ts)$identifyis queued too, so login no longer waits on it.os.tmpdir()/tigris-telemetry-<pid>-<random>.json(mode0600,wx), and the CLI spawns itself withspawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref(), then exits without waiting. Under Node,exeisprocess.execPathwith the entry script (argv[1]); the compiled binary runs itself.tigris __telemetry-send <file>) reads and deletes the file (in afinally), POSTs each request with a 5s timeout, prints nothing, swallows every error, and exits. It only accepts atigris-telemetry-*file directly in the temp dir. The entry points branch on it before the commander program exists, so it never appears in help and never initializes telemetry. Incli.tsthe rest of the CLI is now imported lazily, so the child loads only the sender module (~40ms of CPU instead of ~200ms).error, unwritable temp dir), the file is removed and the queue is posted in-process instead with a 1s cap. That survives a command that returns normally, but notprocess.exit(), so restricted sandboxes can still lose events.Anything queued after the command's event (an error report with no tracked command, a late
$identify) is sent by aprocess.on('exit')listener through the same path. With telemetry off, nothing is queued, so no file is written and no child is spawned.The payload file holds the same scrubbed bodies that were sent before: arguments go through
scrubArgv, Sentry events throughbeforeSend. No access key or secret appeared in any payload in the runs below.Sentry
The ~300ms came from the
ProcessSessionintegration: onbeforeExitit ended the release-health session and sent it, so the SDK's transport held every naturally exiting command open for a TLS round trip. Changes inutils/telemetry.ts:transport(makeQueuedTransport) built onSentry.createTransport: each serialized envelope is queued for the child, posted to the DSN's envelope endpoint with anX-Sentry-Authheader.beforeSendscrubbing is unchanged.ProcessSession(the delay above),ContextandContextLinesjoin main'sDISABLED_INTEGRATIONS.Context/ContextLinesare asynchronous, so an error captured just beforeprocess.exit()never reached the transport; without them capture is synchronous, and the OS release is now in theclicontext.enableRuntimeChannelInjection: false(Sentry 11's successor toregisterEsmLoaderHooks): the module hooks only serve instrumenting third-party packages. Measured on 11.9.0, they add ~56ms toinit.sendClientReports: false, so a clean run sends no envelope at all.A side effect: handled
general/networkfailures fromexitWithErrornow actually reach Sentry. Before, they were captured but the process exited before the event was processed.Rebased onto Sentry v11
main moved
@sentry/nodeto v11 (#378, #390) and rewroteutils/telemetry.ts: the SDK is imported lazily only when telemetry is on, with aloading/on/failedstate machine, a boundedpendingqueue replayed once the SDK is up,dataCollection: DATA_COLLECTION, and default integrations filtered throughDISABLED_INTEGRATIONS. The rebase keeps all of that and re-applies this PR on top.configure()passes the queued transport (makeQueuedTransport(sdk.createTransport), so the SDK stays out of the static imports),sendClientReports: falseandenableRuntimeChannelInjection: false. The three extra integrations go intoDISABLED_INTEGRATIONS, and the name-pinning test now guards them too. I dropped the old explicit allow-list: the filter costs ~10ms more ininit(12ms vs 2ms on 11.9.0), but that runs in the background while the command works.flushTelemetrywaits forloading(capped at its timeout) and then flushes into the queue, with no network. So a crash reported while the SDK was still loading is replayed into the transport beforeexitWithError→process.exit→finishCommandspawns the sender. Tests now cover that replay.Remaining gap, inherited from main's lazy load: a handled
general/networkerror that exits throughexitWithErrorbefore the SDK has loaded (~120ms import) is queued inpendingand lost, because that path cannot await. Commands that reach the network take longer than that, so in practice this only affects very early failures.How it hooks every exit
finishCommand(process.exitCode)inloadAndExecuteCommandexitWithError/failWithError(~370 sites)recordCommandError(error)in the helper, then theprocess.exitwrapper sendsexitWithError→ same as aboveprocess.exit(0/1)in commands and the dispatcherprocess.exitwrapper (installed only while a command is tracked)exitOverridecallback records, then commander's ownprocess.exitis wrappedexit_code: 130, then re-raises SIGINT so the shell still sees a signal death. It stands aside when a command has its own SIGINT handler (buckets migrate), whoseprocess.exit(130)is wrappedtelemetrycommands stay untracked. The browser build's no-op shim gains the two new exports.Not covered
process.exit()cuts off.auth_method: 'unknown'; PostHog still puts them on the right person once$identifyhas linked that id.Error(message), so SDK failures usually have anerror_categorybut noerror_code.tsconfig.binary.json) but not built or run here (no Bun on this machine). It relies on Bun honoringdetachedinchild_process.spawn.Cost
tigris buckets list, built CLI, macOS, realph.tigrisdata.comand Sentry, 7 interleaved runs each, median wall time:TIGRIS_NO_TELEMETRY=1)With telemetry on, the rebase changes nothing (604 vs 620ms, within jitter). On and off are no longer equal because main made off cheaper: an opted-out run never loads the SDK, and that import costs ~120ms of CPU. With telemetry on, that import overlaps the command's network wait. A separate 5-run set had
buckets listat 667 vs 581ms andwhoamiat 501 vs 422ms (on vs off).frobnicate, which exits before the SDK finishes loading, took 112 vs 113ms. Before the rebase, d9552c6 (worker + Atomics.wait) took 1035ms with telemetry on. The parent still spends ~2ms writing the file and spawning.Verification
pnpm --filter @tigrisdata/cli test(vitest 5, after the rebase): 52 files, 1332 passed (225 integration tests skipped as before).analytics.test.tsmockschild_process.spawn: it checks the spawn command and options, the payload file's name, location and0600mode, the binary runtime launching itself, the spawn-failure fallback (event still sent, no file left behind), and that nothing is spawned or written with telemetry off. All the outcome tests from the first commit are kept. The newtelemetry-sender.test.tsruns the__telemetry-sendhandler in-process against a local HTTP server (posts, then deletes the file; malformed payloads; refuses files it did not write), checks the Sentry transport moves auth intoX-Sentry-Auth, and checks a realSentry.initqueues a scrubbed envelope synchronously on capture and nothing on a clean run.pnpm --filter @tigrisdata/cli buildpasses.biome check packages/cli/src packages/cli/testis clean apart fromspecs.yaml. Rootpnpm checkfails on main itself after the biome 2.6.3 bump (chore(deps): update dependency @biomejs/biome to v2.6.3 #389): it now formats Markdown and YAML, and 18 files fail, includingspecs.yaml. On this branch the changeset's frontmatter is a 19th file. I left those files alone because they are outside this PR.HOME, PostHog and the Sentry DSN pointed at a local sink:Envelopes arrived at
/api/42/envelope/withX-Sentry-Auth: Sentry sentry_version=7, sentry_key=…, sentry_client=tigris-cli/3.17.0. Notigris-telemetry-*files were left in the temp dir afterwards.After the rebase (Sentry 11), the same sink run gave: login credentials success (480ms), buckets list success (576ms, no envelope), buckets create
generalexit 1 (727ms, one envelope), frobnicateusage/unknown_command(no envelope), and uncaught throw mid-uploadgeneralexit 1 (1456ms, one envelope). Each envelope reached the sink in the same child POST batch as itscli_command. No payload files were left behind.buckets list(success) andbuckets create(failure) at 2026-10-14T18:35:12Z, distinct_id7f6041da-d92e-461f-81b0-71ffcb0e4f1d(anonymous, credentials login). Both POSTs from the child got200fromph.tigrisdata.com/i/v0/e/. After the rebase:buckets listat 2026-10-30T20:08:50Z and failingbuckets createat 2026-10-30T20:08:51Z, distinct_id349bfcaa-17c5-44bb-97cd-5350c8141128.Depends on #392
pnpm checkfails onmainwith biome 2.6.3 (it now formats Markdown/YAML), and this branch adds one more hit on its changeset file. #392 turns those formatters off and should merge first; after thatpnpm checkand the pre-commit hook pass here withoutHUSKY=0.🤖 Generated with Claude Code
https://claude.ai/code/session_01Hqe2uWQDRZXDwo7DZEu5En
Note
Medium Risk
Wraps process.exit and SIGINT, spawns detached CLI children, and changes when telemetry fires—designed not to block exits, but exit-hook and spawn behavior is easy to get wrong in edge environments.
Overview
cli_commandis emitted when a command finishes, not at start, withsuccess,exit_code,duration_ms, and on failureerror_category/error_code(machine codes only, never messages).$set_oncerecords first CLI touch (first_interface, install method, version) on identify and command events.PostHog and Sentry no longer do HTTP in the user-facing process. Events queue to a temp file and a detached
tigris __telemetry-sendchild posts after exit;cli.tslazy-loads the main CLI so the sender stays light.process.exitis wrapped (while tracking),finishCommand/recordCommandErrorhook returns,exitWithError, commander usage errors, validation failures, and Ctrl-C (withcommandHandlesSigintfor migrate). Sentry uses a queued transport, dropsProcessSession(fixes ~300msbeforeExitdelay) and async context integrations, andexitWithErrorreports attach to analytics before exit.Docs and
telemetry statuscopy reflect outcomes and background delivery; browser shim stubs the new exports.Reviewed by Cursor Bugbot for commit a5cf701. Bugbot is set up for automated code reviews on this repo. Configure here.