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
17 changes: 17 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,23 @@ reads, SQLite, no network); ones that need a parser (`arg-parsing.ts`, the
search grammar) import it when called, so the fast path only loads it for
the arguments that need it.

The fast path runs on every TAB press, so it loads only `src/lib/complete.ts`
and what that module imports. `src/bin.ts` and the npm `dist/bin.cjs` check
for `__complete` first and call `runCompletion` without loading `cli.ts`;
the npm package ships it as the separate `dist/complete.cjs` bundle. That
graph must stay free of `@sentry/node`, telemetry, the API client, and the
command tree: the database modules get spans and cache metrics through
`withDbSpan` and `recordCacheHit` from `src/lib/db/index.ts`, which require
telemetry lazily and skip it when telemetry is off (`runCompletion` turns it
off; `dist/complete.cjs` is built with `SENTRY_CLI_COMPLETION_BUILD` set to
`true`, so the bundle leaves telemetry out), and `logger.ts` takes the Sentry
reporter from `initSentry`.
`test/script/cli-startup.test.ts` bundles the graph and fails on any package
not in its `COMPLETION_PACKAGES` list, naming the importer, or on more source
modules than `COMPLETION_MODULE_BUDGET`. Defer a heavy dependency with a
dynamic `import()` in the completer that needs it rather than adding it to
the list.

### Multiple Values in Arguments

Split by argument type — do not mix the conventions:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"files": [
"dist/bin.cjs",
"dist/complete.cjs",
"dist/index.cjs",
"dist/index.mjs",
"dist/index.d.cts",
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/script/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ const commonDefine: Record<string, string> = {
// Tree-shakes the SDK's runtime module hooks, which can't instrument a
// bundle and would hook the host app in library mode.
__SENTRY_CHANNEL_INJECTION__: "false",
// `true` only in `dist/complete.cjs`: drops the lazy telemetry `require`
// (and `@sentry/node`) from `src/lib/db/index.ts` there.
SENTRY_CLI_COMPLETION_BUILD: "false",
};

// In an ESM bundle, `require`, `__dirname`, and `__filename` don't exist, but
Expand Down Expand Up @@ -343,6 +346,21 @@ const result = await build({
},
});

// `sentry __complete` runs on every TAB press, so `dist/bin.cjs` loads this
// small bundle of `runCompletion` instead of the whole CLI in `index.cjs`.
const completeResult = await build({
...commonBuildOptions,
entryPoints: ["./src/lib/complete.ts"],
format: "cjs",
outfile: "./dist/complete.cjs",
inject: ["./script/import-meta-url.js"],
define: {
...commonDefine,
SENTRY_CLI_COMPLETION_BUILD: "true",
"import.meta.url": "import_meta_url",
},
});

const esmResult = await build({
...commonBuildOptions,
format: "esm",
Expand All @@ -360,7 +378,7 @@ const esmResult = await build({
const BIN_WRAPPER = `#!/usr/bin/env node
{let v=process.versions.node.split(".").map(Number);if(v[0]<18){console.error("Error: sentry requires Node.js 18 or later (found "+process.version+").\\n\\nEither upgrade Node.js, or install the standalone binary instead:\\n curl -fsSL https://cli.sentry.dev/install | bash\\n");process.exit(1)}}
{let e=process.emit;process.emit=function(n,...a){return n==="warning"?!1:e.apply(this,[n,...a])}}
require('./index.cjs')._cli().catch(()=>{process.exitCode=1});
process.argv[2]==="__complete"?require('./complete.cjs').runCompletion(process.argv.slice(3)).catch(()=>{process.exitCode=0}):require('./index.cjs')._cli().catch(()=>{process.exitCode=1});
`;
await writeFile("./dist/bin.cjs", BIN_WRAPPER);

Expand Down Expand Up @@ -440,11 +458,15 @@ console.log(" -> dist/node-sqlite3-wasm.wasm (WASM SQLite fallback)");
const bundleSizeKB = (
(result.metafile?.outputs["dist/index.cjs"]?.bytes ?? 0) / 1024
).toFixed(1);
const completeBundleSizeKB = (
(completeResult.metafile?.outputs["dist/complete.cjs"]?.bytes ?? 0) / 1024
).toFixed(1);
const esmBundleSizeKB = (
(esmResult.metafile?.outputs["dist/index.mjs"]?.bytes ?? 0) / 1024
).toFixed(1);

console.log(`\n -> dist/index.cjs (${bundleSizeKB} KB)`);
console.log(` -> dist/index.mjs (${esmBundleSizeKB} KB)`);
console.log(` -> dist/complete.cjs (${completeBundleSizeKB} KB)`);
console.log(`\n${"=".repeat(40)}`);
console.log("Bundle complete!");
22 changes: 17 additions & 5 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ process.emit = ((event: string, ...args: unknown[]) => {
return _origEmit(event, ...args);
}) as typeof process.emit;

import { startCli } from "./cli.js";
import { wrapCall } from "./lib/react-native/wrap-call.js";

// React Native Xcode build wrapper: when the RN build script invokes us in
Expand Down Expand Up @@ -50,7 +49,20 @@ function handleStreamError(err: NodeJS.ErrnoException): void {
process.stdout.on("error", handleStreamError);
process.stderr.on("error", handleStreamError);

// startCli handles its own errors — this is a safety net for truly unexpected rejections
startCli().catch(() => {
process.exitCode = 1;
});
// Shell completion runs on every TAB press: load only the completion engine,
// never the full CLI (`@sentry/node`, the command tree).
if (process.argv[2] === "__complete") {
import("./lib/complete.js")
.then(({ runCompletion }) => runCompletion(process.argv.slice(3)))
.catch(() => {
// Completions should never crash — silently return no results
process.exitCode = 0;
});
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
} else {
// startCli handles its own errors — this is a safety net for truly unexpected rejections
import("./cli.js")
.then(({ startCli }) => startCli())
.catch(() => {
process.exitCode = 1;
});
}
39 changes: 5 additions & 34 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
/**
* CLI runner with fast-path dispatch.
*
* Shell completion (`__complete`) is dispatched before any heavy imports
* to avoid loading `@sentry/node` (~100ms). All other commands go through
* the full CLI with telemetry, middleware, and error recovery.
* CLI runner: every command goes through the full CLI with telemetry,
* middleware, and error recovery. Shell completion (`__complete`) never gets
* here: the bin entry points run `lib/complete.ts` without loading this module.
*
* Extracted from `bin.ts` so the logic is testable and reusable without
* top-level side effects. `bin.ts` remains a thin wrapper that registers
Expand All @@ -26,11 +24,9 @@ async function preloadProjectContext(cwd: string): Promise<void> {
// Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL
// (the .sentryclirc shim or the default-URL fallback below). Pins the
// env-token's trust scope to the user's shell, not a repo-local file.
// Dynamic import: env-token-host chains into db/auth → telemetry → @sentry/node
const { captureEnvTokenHost } = await import("./lib/env-token-host.js");
captureEnvTokenHost();

// Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path
const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([
import("./lib/dsn/project-root.js"),
import("./lib/db/project-root-cache.js"),
Expand Down Expand Up @@ -67,20 +63,6 @@ async function preloadProjectContext(cwd: string): Promise<void> {
}
}

/**
* Fast-path: shell completion.
*
* Dispatched before importing the full CLI to avoid loading @sentry/node,
* @stricli/core, and other heavy dependencies. Only loads the lightweight
* completion engine and SQLite cache modules.
*/
export async function runCompletion(completionArgs: string[]): Promise<void> {
// Disable telemetry so db/index.ts skips the @sentry/node lazy-require (~100ms)
getEnv().SENTRY_CLI_NO_TELEMETRY = "1";
const { handleComplete } = await import("./lib/complete.js");
await handleComplete(completionArgs);
}

/**
* Flags whose values must never be sent to telemetry.
* Superset of `SENSITIVE_FLAGS` in `telemetry.ts` — includes `auth-token`
Expand Down Expand Up @@ -310,9 +292,6 @@ export async function runMcpCommand(cliArgs: string[]): Promise<boolean> {

/**
* Full CLI execution with telemetry, middleware, and error recovery.
*
* All heavy imports are loaded here (not at module top level) so the
* `__complete` fast-path can skip them entirely.
*/
export async function runCli(cliArgs: string[]): Promise<void> {
if (await runMcpCommand(cliArgs)) {
Expand Down Expand Up @@ -748,8 +727,8 @@ export async function runCli(cliArgs: string[]): Promise<void> {
/**
* Top-level CLI dispatch.
*
* Reads `process.argv`, dispatches to the completion fast-path or the full
* CLI runner, and handles fatal errors. Called from `bin.ts`.
* Reads `process.argv`, runs the full CLI, and handles fatal errors.
* Called from `bin.ts`.
*/
export async function startCli(): Promise<void> {
const args = process.argv.slice(2);
Expand All @@ -759,14 +738,6 @@ export async function startCli(): Promise<void> {
// timestamps (and all other Date output) appear in the wrong timezone.
initTimezone();

// Completions are a fast-path (~1ms) — skip .sentryclirc I/O.
if (args[0] === "__complete") {
return await runCompletion(args.slice(1)).catch(() => {
// Completions should never crash — silently return no results
process.exitCode = 0;
});
}

// Walk up from CWD once to find project root AND .sentryclirc config.
// Caches both so later findProjectRoot / loadSentryCliRc calls are hits.
// Most failures here are non-fatal (unreadable rc file, missing project
Expand Down
24 changes: 19 additions & 5 deletions packages/cli/src/lib/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { queueCompletionTelemetry } from "./db/completion-telemetry.js";
import { getProjectAliases } from "./db/project-aliases.js";
import { getCachedProjectsForOrg } from "./db/project-cache.js";
import { getCachedOrganizations } from "./db/regions.js";
import { getEnv } from "./env.js";
import { ValidationError } from "./errors.js";
import { fuzzyMatch } from "./fuzzy.js";
import type { CommandCompletions } from "./introspect.js";
Expand Down Expand Up @@ -65,15 +66,28 @@ type Completion = {
};

/**
* Main entry point for `sentry __complete`.
* Run `sentry __complete`.
*
* Called from the cli.ts fast-path. Parses the shell words to determine
* what kind of completion is needed, queries the cache, and writes
* results to stdout.
* `src/bin.ts` and the npm package's `dist/bin.cjs` (`dist/complete.cjs`)
* call this before anything else of the CLI loads, so a TAB press loads only
* this module's imports. `test/script/cli-startup.test.ts` keeps them free of
* `@sentry/node`, the API client, and the command tree.
*
* @param args - The words after `__complete`
*/
export async function runCompletion(args: string[]): Promise<void> {
// Keeps the database modules from requiring telemetry and `@sentry/node`
getEnv().SENTRY_CLI_NO_TELEMETRY = "1";
await handleComplete(args);
}

/**
* Complete the shell words in `args`: parses them to determine what kind of
* completion is needed, queries the cache, and writes results to stdout.
*
* @param args - The words after `__complete` (COMP_WORDS[1:] from the shell)
*/
export async function handleComplete(args: string[]): Promise<void> {
async function handleComplete(args: string[]): Promise<void> {
const startMs = performance.now();

// The last word is the partial being completed (may be empty)
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import { AuthError, ConfigError } from "../errors.js";
import { logger } from "../logger.js";
import { parseBoolValue } from "../parse-bool.js";
import { normalizeHttpOrigin } from "../sentry-urls.js";
import { withDbSpan } from "../telemetry.js";
import {
type AccountRow,
accountIdentity,
Expand All @@ -64,7 +63,7 @@ import {
} from "./accounts.js";
import { getAccountScope, resetAccountScopes } from "./account-scope.js";
import { hashIdentity } from "./identity-hash.js";
import { getDatabase } from "./index.js";
import { getDatabase, withDbSpan } from "./index.js";
import { clearCompletionCache } from "./completion-cache.js";
import { clearAllIssueOrgCache } from "./issue-org-cache.js";
import { clearTrustedHostState } from "./regions.js";
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/dsn-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import type {
DetectedDsn,
ResolvedProjectInfo,
} from "../dsn/types.js";
import { recordCacheHit } from "../telemetry.js";
import { getDatabase, maybeCleanupCaches } from "./index.js";
import { getDatabase, maybeCleanupCaches, recordCacheHit } from "./index.js";
import { safeParseJson } from "./json.js";
import { runUpsert, touchCacheEntry } from "./utils.js";

Expand Down
45 changes: 35 additions & 10 deletions packages/cli/src/lib/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,7 @@ export function getDatabase(): Database {
}

// Wrap with tracing proxy for automatic query instrumentation.
// Lazy-require telemetry to avoid top-level import of @sentry/node (~100ms).
// Shell completions set SENTRY_CLI_NO_TELEMETRY=1 to skip this entirely.
if (getEnv().SENTRY_CLI_NO_TELEMETRY === "1") {
db = rawDb;
} else {
const { createTracedDatabase } = _require("../telemetry.js") as {
createTracedDatabase: (d: Database) => Database;
};
db = createTracedDatabase(rawDb);
}
db = lazyTelemetry()?.createTracedDatabase(rawDb) ?? rawDb;
dbOpenedPath = dbPath;
registerExitHandler();

Expand All @@ -246,6 +237,40 @@ export function getDatabase(): Database {
}
}

/**
* `true` in the npm package's `dist/complete.cjs` (defined in
* `script/bundle.ts`), so esbuild drops the telemetry `require` below from
* that bundle. Undefined when running from source.
*/
declare const SENTRY_CLI_COMPLETION_BUILD: boolean | undefined;

/**
* The telemetry module, required lazily so the database modules never import
* `@sentry/node` (~100ms). `undefined` when telemetry is off: the `__complete`
* fast path sets `SENTRY_CLI_NO_TELEMETRY=1` and never loads it.
*/
function lazyTelemetry(): typeof import("../telemetry.js") | undefined {
if (
(typeof SENTRY_CLI_COMPLETION_BUILD !== "undefined" &&
SENTRY_CLI_COMPLETION_BUILD) ||
getEnv().SENTRY_CLI_NO_TELEMETRY === "1"
) {
return;
}
return _require("../telemetry.js") as typeof import("../telemetry.js");
}

/** Run a database operation in a `db.operation` span (telemetry's `withDbSpan`). */
export function withDbSpan<T>(operation: string, fn: () => T): T {
const telemetry = lazyTelemetry();
return telemetry ? telemetry.withDbSpan(operation, fn) : fn();
}

/** Record a cache hit or miss (telemetry's `recordCacheHit`). */
export function recordCacheHit(cacheName: string, hit: boolean): void {
lazyTelemetry()?.recordCacheHit(cacheName, hit);
}

/** Close the database connection (used for testing). */
export function closeDatabase(): void {
if (db) {
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/issue-org-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@
* deletion, which we already handle via 404 eviction.
*/

import { recordCacheHit } from "../telemetry.js";
import { getDatabase } from "./index.js";
import { getDatabase, recordCacheHit } from "./index.js";
import { runUpsert } from "./utils.js";

type IssueOrgRow = {
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/project-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
*/

import type { CachedProject } from "../../types/index.js";
import { recordCacheHit } from "../telemetry.js";
import { getDatabase, maybeCleanupCaches } from "./index.js";
import { getDatabase, maybeCleanupCaches, recordCacheHit } from "./index.js";
import { runUpsert, touchCacheEntry } from "./utils.js";

type ProjectCacheRow = {
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/project-root-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@

import { stat } from "node:fs/promises";
import type { ProjectRootReason } from "../dsn/project-root.js";
import { recordCacheHit } from "../telemetry.js";
import { getDatabase, maybeCleanupCaches } from "./index.js";
import { getDatabase, maybeCleanupCaches, recordCacheHit } from "./index.js";
import { runUpsert } from "./utils.js";

/** Cache TTL in milliseconds (24 hours) */
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@
import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js";
import { logger } from "../logger.js";
import { normalizeHttpOrigin } from "../sentry-urls.js";
import { recordCacheHit } from "../telemetry.js";
import {
getAccountIdForIdentity,
getCredentialContext,
getIdentityFingerprint,
} from "./auth.js";
import { getDatabase } from "./index.js";
import { getDatabase, recordCacheHit } from "./index.js";
import { runUpsert } from "./utils.js";

const log = logger.withTag("db.regions");
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/db/repo-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
*/

import type { SentryRepository } from "../../types/index.js";
import { recordCacheHit } from "../telemetry.js";
import { getDatabase } from "./index.js";
import { getDatabase, recordCacheHit } from "./index.js";
import { runUpsert } from "./utils.js";

/**
Expand Down
Loading
Loading