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
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
],
"rules": {
"sentry-cli/no-silent-catch": "error",
"sentry-cli/no-raw-capture-exception": "error",
"sentry-cli/no-process-exit": "error",
"sentry-cli/no-stdout-write-in-commands": "error",
"sentry-cli/no-process-stdout-in-commands": "error",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,12 @@ separate script or baseline file to maintain. Fix a grandfathered catch by
adding logging/re-throwing and deleting its suppression line; only add a new
suppression for a genuinely intentional silent catch, with a real reason.

Report errors to Sentry with `reportCliError(error, { tags, extra, level })`
from `src/lib/error-reporting.ts`, never `captureException` directly. A raw
capture skips the 4xx/network silencing and the `api_error` context, so user
errors pile into one issue with no detail (CLI-10). The
`no-raw-capture-exception` CLI Oxlint rule enforces this.

### Process Exit

Do not end the CLI with `process.exit()`, including forced-exit timers. A
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,26 @@ const rules = {
}
: {},
),
// A raw capture skips the 4xx silencing and `api_error` context that
// reportCliError() applies, so user errors pile into one issue (CLI-10).
"no-raw-capture-exception": rule(
"Report errors with reportCliError() from lib/error-reporting.js. A raw captureException skips the 4xx silencing and API error context.",
(_context, file, report) =>
file.includes("/src/") && !file.endsWith("/src/lib/error-reporting.ts")
? {
ImportDeclaration(node) {
if (!String(node.source.value).startsWith("@sentry/")) return;
for (const specifier of node.specifiers) {
if (specifier.imported?.name === "captureException")
report(specifier);
}
},
CallExpression(node) {
if (call(node, "captureException")) report(node);
},
}
: {},
),
"no-process-exit": rule(
"Do not force the process to exit: it drops pending telemetry and piped " +
"output. Release the handle that keeps the event loop alive (see " +
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { existsSync, unlinkSync } from "node:fs";
import { chmod, copyFile, mkdir, rename, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { isatty } from "node:tty";
import { captureException } from "@sentry/node";
import type { SentryContext } from "../../context.js";
import { installAgentSkills } from "../../lib/agent-skills.js";
import { assertAutoLoginHostTrusted } from "../../lib/auto-auth.js";
Expand Down Expand Up @@ -67,6 +66,7 @@ import {
type ShellInfo,
} from "../../lib/shell.js";
import { addToWindowsUserPath } from "../../lib/windows-path.js";
import { reportCliError } from "../../lib/error-reporting.js";

type SetupFlags = {
readonly install: boolean;
Expand Down Expand Up @@ -507,7 +507,7 @@ async function bestEffort(
await fn();
} catch (error) {
warn(stepName, error);
captureException(error, {
reportCliError(error, {
level: "warning",
tags: { "setup.step": stepName },
});
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/log/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* Supports trace ID as a positional argument to filter logs by trace.
*/

// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import type { SentryContext } from "../../context.js";
import {
type LogSortDirection,
Expand Down Expand Up @@ -80,6 +78,7 @@ import {
} from "../../lib/trace-target.js";
import { getUpdateNotification } from "../../lib/version-check.js";
import { SentryLogSchema } from "../../generated/output-schemas/log.js";
import { reportCliError } from "../../lib/error-reporting.js";

type ListFlags = {
readonly limit: number;
Expand Down Expand Up @@ -412,7 +411,7 @@ async function fetchPoll<T extends LogLike>(
if (error instanceof ValidationError) {
throw error;
}
Sentry.captureException(error);
reportCliError(error);
const message = stringifyUnknown(error);
config.onDiagnostic(`Error fetching logs: ${message}\n`);
return;
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/agent-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
import { rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { captureException } from "@sentry/node";
import { SKILL_FILES } from "../generated/skill-content.js";
import { reportCliError } from "./error-reporting.js";

/** Where skills are installed */
export type AgentSkillLocation = {
Expand Down Expand Up @@ -87,7 +87,7 @@ async function atomicWriteFile(
await rename(tempPath, destPath);
} catch (error) {
await rm(tempPath, { force: true }).catch((cleanupError) => {
captureException(cleanupError, {
reportCliError(cleanupError, {
level: "debug",
tags: { "setup.step": "agent-skills-cleanup" },
});
Expand Down Expand Up @@ -128,7 +128,7 @@ async function writeSkillFiles(
referenceCount,
};
} catch (error) {
captureException(error, {
reportCliError(error, {
level: "warning",
tags: { "setup.step": "agent-skills" },
});
Expand Down
27 changes: 22 additions & 5 deletions packages/cli/src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import {
listOrganizationDashboards,
updateOrganizationDashboard,
} from "@sentry/api";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import { resolveEventsSort } from "@sentry/toolkit-core/events-sort";

import { array, safeParse } from "valibot";
Expand All @@ -37,7 +35,8 @@ import {
type TimeseriesResult,
type WidgetDataResult,
} from "../../types/dashboard.js";
import { stringifyUnknown } from "../errors.js";
import { reportCliError } from "../error-reporting.js";
import { ApiError, stringifyUnknown } from "../errors.js";

import { resolveOrgRegion } from "../region.js";

Expand Down Expand Up @@ -630,14 +629,32 @@ async function queryWidgetData(
reason: `Display type '${widget.displayType}' is not yet supported`,
};
} catch (error) {
Sentry.captureException(error);
// Best-effort catch: the other widgets still render. Report through the
// shared policy so 401–499 failures are silenced and 400s keep API context.
reportCliError(error);
return {
type: "error",
message: stringifyUnknown(error),
message: widgetErrorMessage(error),
} satisfies ErrorResult;
}
}

/**
* One-line widget failure message. Keeps the API's `detail`, which explains
* why the widget's query was rejected; the status line alone does not.
*/
function widgetErrorMessage(error: unknown): string {
const message = stringifyUnknown(error);
const detail =
error instanceof ApiError
? error.detail?.replace(/\s+/g, " ").trim()
: undefined;
// Bodyless responses fall back to the status text, already in the message.
return detail && !message.includes(detail)
? `${message}: ${detail}`
: message;
}

/**
* Query data for all widgets in a dashboard in parallel.
*
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/lib/api/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import {
listOrganizationTeams,
listProjectTeams as sdkListProjectTeams,
} from "@sentry/api";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";

import type { SentryTeam } from "../../types/index.js";

Expand All @@ -23,6 +21,7 @@ import {
unwrapPaginatedResult,
unwrapResult,
} from "./infrastructure.js";
import { reportCliError } from "../error-reporting.js";

/**
* List teams in an organization.
Expand Down Expand Up @@ -120,7 +119,7 @@ export async function createTeam(
try {
await addMemberToTeam(orgSlug, team.slug, "me");
} catch (error) {
Sentry.captureException(error, {
reportCliError(error, {
extra: { orgSlug, teamSlug: team.slug, context: "auto-add member" },
});
logger.warn(
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/lib/auth-hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
* is wrong, just a helpful breadcrumb).
*/

// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import {
getActiveEnvVarName,
getRawEnvToken,
Expand All @@ -34,6 +32,7 @@ import {
import { getUserInfo } from "./db/user.js";
import { logger } from "./logger.js";
import { getRcInjectedTokenSource } from "./sentryclirc.js";
import { reportCliError } from "./error-reporting.js";

const log = logger.withTag("auth");

Expand Down Expand Up @@ -73,7 +72,7 @@ export function maybeWarnEnvTokenIgnored(): void {
try {
hasStored = hasStoredAuthCredentials();
} catch (error) {
Sentry.captureException(error);
reportCliError(error);
}
if (!hasStored) {
return;
Expand Down Expand Up @@ -121,7 +120,7 @@ function resolveStoredUserLabel(): string {
// DB read failure for the user-info cache is non-fatal — fall back
// to the neutral label — but still surface the error to Sentry so
// we can diagnose persistent cache-read failures.
Sentry.captureException(error);
reportCliError(error);
return "stored OAuth user";
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/delta-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
import { CLI_VERSION } from "./constants.js";
import { customFetch } from "./custom-ca.js";
import { getConfigDir } from "./db/index.js";
import { reportCliError } from "./error-reporting.js";
import { extractInputUrl } from "./fetch-input.js";
import { formatBytes } from "./formatters/numbers.js";
import { logger } from "./logger.js";
Expand Down Expand Up @@ -669,7 +670,7 @@ export function attemptDeltaUpgrade(
span.setStatus({ code: 1 });
return result;
} catch (error) {
Sentry.captureException(error, {
reportCliError(error, {
level: "warning",
tags: {
"delta.from_version": CLI_VERSION,
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/lib/dsn/fs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
*/

import { stat } from "node:fs/promises";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import { reportCliError } from "../error-reporting.js";
import { logger } from "../logger.js";

const log = logger.withTag("fs");
Expand Down Expand Up @@ -72,7 +71,7 @@ export function handleFileError(
context: { operation: string; path?: string },
): void {
if (!isIgnorableFileError(error)) {
Sentry.captureException(error, {
reportCliError(error, {
tags: {
operation: context.operation,
},
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,13 @@ function setCliErrorContext(scope: Sentry.Scope, error: unknown): void {
// Public API
// ---------------------------------------------------------------------------

/** Call-site details added to a captured event. */
export type ReportContext = {
level?: Sentry.SeverityLevel;
tags?: Record<string, string>;
extra?: Record<string, unknown>;
};

/**
* Report an error to Sentry without rethrowing.
*
Expand All @@ -445,11 +452,15 @@ function setCliErrorContext(scope: Sentry.Scope, error: unknown): void {
* log is not a substitute: users do not run `--verbose`, so swallowed
* unexpected failures must become Sentry issues.
*
* This is the only place that calls `captureException`; the
* `sentry-cli/no-raw-capture-exception` lint rule enforces it.
*
* - Silenced errors (network, expected auth, 4xx) emit a metric and return
* without calling `captureException`.
* - Captured errors get grouping tags + structured context on a fresh scope.
* - Captured errors get grouping tags + structured context on a fresh scope,
* plus the caller's `context`.
*/
export function reportCliError(error: unknown): void {
export function reportCliError(error: unknown, context?: ReportContext): void {
const silenced = classifySilenced(error);
if (silenced) {
recordSilencedError(error, silenced);
Expand All @@ -459,7 +470,7 @@ export function reportCliError(error: unknown): void {
Sentry.withScope((scope) => {
setGroupingTags(scope, error);
setCliErrorContext(scope, error);
Sentry.captureException(error);
Sentry.captureException(error, context);
});
}

Expand Down
24 changes: 18 additions & 6 deletions packages/cli/src/lib/formatters/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,9 +1527,18 @@ function renderTextContent(content: string, innerWidth: number): string[] {
return wrapped.split("\n");
}

/** Render placeholder content for unsupported/error widgets (no title/border). */
function renderPlaceholderContent(message: string): string[] {
return [isPlainOutput() ? `(${message})` : muted(`(${message})`)];
/**
* Render placeholder content for unsupported/error widgets (no title/border).
* Wraps to the widget width so long API details stay readable.
*/
function renderPlaceholderContent(
message: string,
innerWidth: number,
): string[] {
const lines = wrapAnsi(`(${message})`, innerWidth, { hard: true }).split(
"\n",
);
return isPlainOutput() ? lines : lines.map((line) => muted(line));
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1706,13 +1715,16 @@ function renderContentLines(opts: {
return renderTextContent(data.content, innerWidth);

case "unsupported":
return renderPlaceholderContent(data.reason);
return renderPlaceholderContent(data.reason, innerWidth);

case "error":
return renderPlaceholderContent(`query failed: ${data.message}`);
return renderPlaceholderContent(
`query failed: ${data.message}`,
innerWidth,
);

default:
return renderPlaceholderContent("unknown widget data type");
return renderPlaceholderContent("unknown widget data type", innerWidth);
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/init/tools/create-sentry-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
* that auto-creates a personal team for eligible members.
*/

import { captureException } from "@sentry/node";
import {
createProjectWithAutoTeam,
createProjectWithDsn,
Expand All @@ -26,6 +25,7 @@ import type {
} from "../types.js";
import { formatToolError } from "./shared.js";
import type { InitToolDefinition, ToolContext } from "./types.js";
import { reportCliError } from "../../error-reporting.js";

type ProjectData = {
projectSlug: string;
Expand Down Expand Up @@ -93,7 +93,7 @@ async function resolveProjectCreation(opts: {
platform &&
err.detail?.includes("Invalid platform")
) {
captureException(err, {
reportCliError(err, {
extra: {
attemptedPlatform: platform,
projectName: name,
Expand Down
Loading
Loading