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
12 changes: 12 additions & 0 deletions .changeset/cli-sentry-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@tigrisdata/cli": minor
---

Add Sentry error telemetry to the CLI. Crashes (uncaught exceptions and
unhandled rejections) are reported and flushed reliably; unexpected "general"
and network errors on the handled path are captured best-effort. Events are
enriched with the command, error category, exit code, CLI version, and platform.
Secrets (access keys, tokens, credential flags) and the machine hostname are
scrubbed before any event is sent. Telemetry is off in dev/test and when no DSN
is configured, and can be disabled with `TIGRIS_NO_TELEMETRY=1` or the standard
`DO_NOT_TRACK=1`.
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-kit/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ describe.skipIf(skipTests)('checkpoint / restore / listCheckpoints', () => {
const bucketsToCleanup: string[] = [];

afterEach(async () => {
for (const bucket of bucketsToCleanup) {
// Delete in reverse (LIFO): a restore creates a fork of its source, and the
// fork is pushed after the source. A source cannot be removed while a
// dependent fork exists, so deleting source-first fails and leaks it.
for (const bucket of [...bucketsToCleanup].reverse()) {
await removeBucket(bucket, { force: true });
}
bucketsToCleanup.length = 0;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"license": "MIT",
"dependencies": {
"@aws-sdk/credential-providers": "^3.1038.0",
"@sentry/node": "^10.66.0",
"@smithy/shared-ini-file-loader": "^4.4.9",
"@tigrisdata/iam": "workspace:^",
"@tigrisdata/storage": "workspace:^",
Expand Down
36 changes: 34 additions & 2 deletions packages/cli/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
* Shared CLI core functionality used by both cli.ts (npm) and cli-binary.ts (binary)
*/

import { classifyError } from '@utils/errors.js';
import { exitWithError } from '@utils/exit.js';
import { printDeprecated } from '@utils/messages.js';
import {
captureError,
flushTelemetry,
initTelemetry,
} from '@utils/telemetry.js';
import { Command as CommanderCommand, Option } from 'commander';

import type { Argument, CommandSpec, Specs } from './types.js';
Expand Down Expand Up @@ -80,16 +86,42 @@ export interface CLIConfig {
* Setup global error handlers
*/
export function setupErrorHandlers() {
initTelemetry();

// Crash path: capture and flush before exiting. These handlers run at the top
// of the stack, so unlike the synchronous exitWithError() used by commands
// they can afford to await the flush. skipCapture avoids a double report.
let handlingCrash = false;
const reportCrashAndExit = async (error: unknown) => {
// Re-entrancy guard: if reporting or exiting itself throws (e.g.
// console.error hitting EPIPE on a closed stderr), the rejection would
// re-enter this handler via unhandledRejection and loop forever without
// exiting. On the second entry, exit hard instead. captureError and
// flushTelemetry swallow their own errors, so the only re-entry source is
// exitWithError below.
if (handlingCrash) {
process.exit(1);
}
handlingCrash = true;

captureError(error, {
crash: true,
exitCode: classifyError(error).exitCode,
});
await flushTelemetry();
exitWithError(error, undefined, { skipCapture: true });
};

process.on('unhandledRejection', (reason) => {
if (reason === '' || reason === undefined) {
console.error('\nOperation cancelled');
process.exit(1);
}
exitWithError(reason);
void reportCrashAndExit(reason);
});

process.on('uncaughtException', (error) => {
exitWithError(error);
void reportCrashAndExit(error);
Comment thread
cursor[bot] marked this conversation as resolved.
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ export const NPM_REGISTRY_URL =
'https://registry.npmjs.org/@tigrisdata/cli/latest';
export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // Check for updates every 6 hours
export const UPDATE_NOTIFY_INTERVAL_MS = 1 * 60 * 60 * 1000; // Show update notification every 1 hour

// Sentry DSN for CLI error telemetry, embedded at build time. A DSN is not a
// secret (it only permits sending events), so shipping it in the published CLI
// is expected. Overridable via TIGRIS_SENTRY_DSN. Empty keeps telemetry inert.
export const SENTRY_DSN =
'https://c3a84c6a2811c557d70e42412cda4ffa@o4507410155896832.ingest.us.sentry.io/4511767771545600';
20 changes: 19 additions & 1 deletion packages/cli/src/utils/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { classifyError } from './errors.js';
import type { MessageContext, MessageVariables } from './messages.js';
import { interpolate, printFailure } from './messages.js';
import { getCommandSpec } from './specs.js';
import { captureError } from './telemetry.js';

function isJsonMode(): boolean {
return globalThis.__TIGRIS_JSON_MODE === true;
Expand All @@ -24,7 +25,11 @@ function isStdoutTTY(): boolean {
* - TTY mode: prints "Next steps:" hints to stderr
* - Always exits with the classified exit code
*/
export function exitWithError(error: unknown, context?: MessageContext): never {
export function exitWithError(
error: unknown,
context?: MessageContext,
opts?: { skipCapture?: boolean }
): never {
const classified = classifyError(error);

if (isJsonMode()) {
Expand All @@ -51,6 +56,19 @@ export function exitWithError(error: unknown, context?: MessageContext): never {
}
}

// Best-effort capture on the synchronous command path: the exit below halts
// immediately (callers rely on this `never` contract), so the event may not
// flush in time. Crashes are captured and flushed reliably by the global
// handlers in setupErrorHandlers(), which pass skipCapture to avoid a double
// report here.
if (!opts?.skipCapture) {
captureError(error, {
category: classified.category,
command: context?.command,
exitCode: classified.exitCode,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

process.exit(classified.exitCode);
}

Expand Down
Loading