From 4bce2be2eca5195896a8c525e179b25c763a004b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 16:35:41 +0000 Subject: [PATCH 01/16] feat: add send-event and send-envelope commands with DSN auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements sentry send-event and sentry send-envelope — the first commands that authenticate via a DSN (not a Bearer token), matching the old sentry-cli behaviour. Architecture: - src/lib/envelope/transport.ts: shared DSN-based envelope sender using @sentry/core (makeDsn, getEnvelopeEndpointWithUrlEncodedAuth, serializeEnvelope) — no new dependencies - src/lib/envelope/event-builder.ts: builds a Sentry Event from CLI flags (message, level, tags, extras, user, fingerprint, etc.) - auth: 'dsn' in buildCommand: skips Bearer token guard and RC URL check for DSN-only commands send-event flags (matching old CLI): --dsn, -m/--message, -l/--level, -r/--release, -E/--env, -t/--tag (variadic), -e/--extra (variadic), -u/--user (variadic), -f/--fingerprint (variadic), --dist, --platform, --timestamp, --no-environ, --raw send-envelope flags: --dsn, --raw (send bytes without parsing) Tests: 48 new tests across 4 files (TDD — tests written first) - transport: URL construction, auth params, error handling - event-builder: parseKeyValue, parseUserFields, buildEventFromFlags - send-event command: inline, file, JSON output, missing DSN - send-envelope command: valid file, raw mode, invalid envelope, multiple files, missing DSN --- docs/src/content/docs/contributing.md | 4 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 16 + .../sentry-cli/references/send-envelope.md | 22 ++ .../sentry-cli/references/send-event.md | 35 +++ src/app.ts | 4 + src/commands/send-envelope.ts | 104 +++++++ src/commands/send-event.ts | 275 ++++++++++++++++++ src/lib/command-suggestions.ts | 9 +- src/lib/command.ts | 12 +- src/lib/envelope/event-builder.ts | 140 +++++++++ src/lib/envelope/transport.ts | 107 +++++++ test/commands/send-envelope.test.ts | 128 ++++++++ test/commands/send-event.test.ts | 126 ++++++++ test/lib/command-suggestions.test.ts | 4 +- test/lib/envelope/event-builder.test.ts | 186 ++++++++++++ test/lib/envelope/transport.test.ts | 150 ++++++++++ 16 files changed, 1308 insertions(+), 14 deletions(-) create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/send-event.md create mode 100644 src/commands/send-envelope.ts create mode 100644 src/commands/send-event.ts create mode 100644 src/lib/envelope/event-builder.ts create mode 100644 src/lib/envelope/transport.ts create mode 100644 test/commands/send-envelope.test.ts create mode 100644 test/commands/send-event.test.ts create mode 100644 test/lib/envelope/event-builder.test.ts create mode 100644 test/lib/envelope/transport.test.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index eff1bcec6..5e191dc90 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -71,7 +71,9 @@ cli/ │ │ ├── explore.ts # Query aggregate event data (Explore) │ │ ├── help.ts # Help command │ │ ├── init.ts # Initialize Sentry in your project (experimental) -│ │ └── schema.ts # Browse the Sentry API schema +│ │ ├── schema.ts # Browse the Sentry API schema +│ │ ├── send-envelope.ts# Send a Sentry envelope file +│ │ └── send-event.ts# Send a Sentry event │ ├── lib/ # Shared utilities │ └── types/ # TypeScript types and Zod schemas ├── test/ # Test files (mirrors src/ structure) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index d89f44bef..f8c12f7ca 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -474,6 +474,22 @@ Browse the Sentry API schema → Full flags and examples: `references/schema.md` +### Send-event + +Send a Sentry event + +- `sentry send-event ` — Send a Sentry event + +→ Full flags and examples: `references/send-event.md` + +### Send-envelope + +Send a Sentry envelope file + +- `sentry send-envelope ` — Send a Sentry envelope file + +→ Full flags and examples: `references/send-envelope.md` + ## Global Options All commands support the following global options: diff --git a/plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md b/plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md new file mode 100644 index 000000000..c83e07f2a --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md @@ -0,0 +1,22 @@ +--- +name: sentry-cli-send-envelope +version: 0.32.0-dev.0 +description: Send a Sentry envelope file +requires: + bins: ["sentry"] + auth: true +--- + +# Send-envelope Commands + +Send a Sentry envelope file + +### `sentry send-envelope ` + +Send a Sentry envelope file + +**Flags:** +- `--dsn - DSN to send envelopes to (overrides SENTRY_DSN env var)` +- `--raw - Send file bytes without parsing or validating the envelope` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/plugins/sentry-cli/skills/sentry-cli/references/send-event.md b/plugins/sentry-cli/skills/sentry-cli/references/send-event.md new file mode 100644 index 000000000..408c685e8 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/send-event.md @@ -0,0 +1,35 @@ +--- +name: sentry-cli-send-event +version: 0.32.0-dev.0 +description: Send a Sentry event +requires: + bins: ["sentry"] + auth: true +--- + +# Send-event Commands + +Send a Sentry event + +### `sentry send-event ` + +Send a Sentry event + +**Flags:** +- `--dsn - DSN to send events to (overrides SENTRY_DSN env var)` +- `-m, --message ... - Event message (repeat for multi-line)` +- `-a, --message-arg ... - Arguments for message template (repeat for multiple)` +- `-l, --level - Event severity level - (default: "error")` +- `-r, --release - Release version` +- `-d, --dist - Distribution identifier` +- `-E, --env - Environment name (e.g. production, staging)` +- `-p, --platform - Platform identifier (default: other)` +- `-t, --tag ... - Tag as KEY:VALUE (repeat for multiple)` +- `-e, --extra ... - Extra data as KEY:VALUE (repeat for multiple)` +- `-u, --user ... - User info as KEY:VALUE — id, email, username, ip_address, or custom` +- `-f, --fingerprint ... - Custom fingerprint part (repeat for multiple)` +- `--timestamp - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)` +- `--no-environ - Do not include environment variables in the event` +- `--raw - Send file contents as-is without parsing` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index ea537a416..916243958 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,8 @@ import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; +import { sendEnvelopeCommand } from "./commands/send-envelope.js"; +import { sendEventCommand } from "./commands/send-event.js"; import { sourcemapRoute } from "./commands/sourcemap/index.js"; import { spanRoute } from "./commands/span/index.js"; import { listCommand as spanListCommand } from "./commands/span/list.js"; @@ -105,6 +107,8 @@ export const routes = buildRouteMap({ init: initCommand, api: apiCommand, schema: schemaCommand, + "send-event": sendEventCommand, + "send-envelope": sendEnvelopeCommand, dashboards: dashboardListCommand, issues: issueListCommand, orgs: orgListCommand, diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts new file mode 100644 index 000000000..6119edc66 --- /dev/null +++ b/src/commands/send-envelope.ts @@ -0,0 +1,104 @@ +/** + * `sentry send-envelope` — Send a pre-built Sentry envelope file. + * + * Reads one or more envelope files from disk and POSTs them to the Sentry + * ingest endpoint via DSN-based authentication. + * + * Envelope files use the Sentry envelope format: + * https://develop.sentry.dev/sdk/envelopes/ + * + * No `sentry auth login` required — provide a DSN via --dsn or SENTRY_DSN. + */ + +import { parseEnvelope, serializeEnvelope } from "@sentry/core"; +import type { SentryContext } from "../context.js"; +import { buildCommand } from "../lib/command.js"; +import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; +import { CommandOutput } from "../lib/formatters/output.js"; + +type SendEnvelopeResult = { + file: string; +}; + +function formatSendEnvelopeHuman(result: SendEnvelopeResult): string { + return `Envelope from ${result.file} dispatched`; +} + +export const sendEnvelopeCommand = buildCommand({ + docs: { + brief: "Send a Sentry envelope file", + fullDescription: `\ +Send a pre-built Sentry envelope file to the ingest pipeline. + +No login required — provide a DSN via --dsn or the SENTRY_DSN environment variable. + +Envelope files follow the Sentry envelope format (newline-delimited JSON headers +followed by item payloads). These are typically produced by Sentry SDKs in +offline/buffered mode, or captured for debugging purposes. + +## Examples + +\`\`\` +# Send a single envelope file +sentry send-envelope ./captured.envelope + +# Send without parsing (useful for binary envelopes or debugging) +sentry send-envelope --raw ./captured.envelope + +# Send multiple envelope files +sentry send-envelope ./a.envelope ./b.envelope +\`\`\` +`, + }, + auth: "dsn", + output: { + human: formatSendEnvelopeHuman, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "Path(s) to envelope file(s) to send", + parse: String, + placeholder: "path", + }, + }, + flags: { + dsn: { + kind: "parsed", + parse: String, + brief: "DSN to send envelopes to (overrides SENTRY_DSN env var)", + optional: true, + }, + raw: { + kind: "boolean", + brief: "Send file bytes without parsing or validating the envelope", + default: false, + optional: true, + }, + }, + }, + async *func( + this: SentryContext, + flags: { dsn?: string; raw?: boolean }, + ...files: string[] + ) { + const dsn = requireDsn(flags, this.cwd); + + for (const file of files) { + let body: string | Uint8Array; + + if (flags.raw) { + body = new Uint8Array(await Bun.file(file).arrayBuffer()); + } else { + const text = await Bun.file(file).text(); + // Parse to validate, then re-serialize to normalize + const envelope = parseEnvelope(text); + body = serializeEnvelope(envelope); + } + + await sendEnvelopeRequest(dsn, body); + yield new CommandOutput({ file }); + } + }, +}); diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts new file mode 100644 index 000000000..b19ae574e --- /dev/null +++ b/src/commands/send-event.ts @@ -0,0 +1,275 @@ +/** + * `sentry send-event` — Send a Sentry event from CLI flags or a JSON file. + * + * Unlike most commands, this authenticates via a DSN (not a Bearer token), + * so no `sentry auth login` is required. The DSN can be provided via: + * 1. --dsn flag + * 2. SENTRY_DSN environment variable + * 3. Auto-detected from project source files / .env + */ + +import type { DsnComponents, Event } from "@sentry/core"; +import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; +import type { SentryContext } from "../context.js"; +import { buildCommand } from "../lib/command.js"; +import { + buildEventFromFlags, + type SendEventFlags, +} from "../lib/envelope/event-builder.js"; +import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; +import { ConfigError, ValidationError } from "../lib/errors.js"; +import { CommandOutput } from "../lib/formatters/output.js"; + +/** Shape of the data yielded to the output layer. */ +type SendEventResult = { + eventId: string; + file?: string; +}; + +function formatSendEventHuman(result: SendEventResult): string { + if (result.file) { + return `Event from ${result.file} dispatched: ${result.eventId}`; + } + return `Event dispatched.\nEvent ID: ${result.eventId}`; +} + +/** + * Build the envelope body and extract the event ID for a file-based send. + * + * In raw mode the file bytes are sent as-is; in normal mode the JSON is + * parsed, wrapped in an EventEnvelope, and re-serialized. + */ +async function buildFilePayload( + file: string, + raw: boolean, + dsnComponents: DsnComponents +): Promise<{ body: string | Uint8Array; eventId: string }> { + if (raw) { + const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()); + // Best-effort: extract event_id from the first line (envelope header JSON) + let eventId = ""; + try { + const text = await Bun.file(file).text(); + const header = JSON.parse(text.split("\n")[0] ?? "{}") as Record< + string, + unknown + >; + eventId = (header.event_id as string) ?? ""; + } catch { + // Non-critical — event_id is informational only + } + return { body: bytes, eventId }; + } + + const event = (await Bun.file(file).json()) as Event; + const envelope = createEventEnvelope(event, dsnComponents); + return { body: serializeEnvelope(envelope), eventId: event.event_id ?? "" }; +} + +export const sendEventCommand = buildCommand({ + docs: { + brief: "Send a Sentry event", + fullDescription: `\ +Send a Sentry event to the ingest pipeline using DSN-based authentication. + +No login required — provide a DSN via --dsn or the SENTRY_DSN environment variable. + +## Building an event from flags + +\`\`\` +sentry send-event -m "Something went wrong" -l error --tag env:prod +\`\`\` + +## Sending from a JSON file + +The JSON file must be a valid serialized Sentry Event object: + +\`\`\` +sentry send-event ./event.json +\`\`\` + +Use --raw to skip JSON parsing and send the file contents as-is inside an envelope. + +## Common flags + +| Flag | Description | +|------|-------------| +| \`--dsn\` | DSN to send to (overrides SENTRY_DSN) | +| \`-m\` / \`--message\` | Event message (repeat for multi-line) | +| \`-l\` / \`--level\` | Severity: debug, info, warning, error, fatal | +| \`-r\` / \`--release\` | Release version | +| \`-E\` / \`--env\` | Environment name | +| \`-t\` / \`--tag\` | Tag as KEY:VALUE (repeat for multiple) | +| \`-e\` / \`--extra\` | Extra data as KEY:VALUE | +| \`-u\` / \`--user\` | User info as KEY:VALUE (id, email, username, ip_address) | +| \`-f\` / \`--fingerprint\` | Custom fingerprint parts (repeat) | +`, + }, + auth: "dsn", + output: { + human: formatSendEventHuman, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "Path(s) to JSON event file(s) to send", + parse: String, + optional: true, + }, + }, + flags: { + dsn: { + kind: "parsed", + parse: String, + brief: "DSN to send events to (overrides SENTRY_DSN env var)", + optional: true, + }, + message: { + kind: "parsed", + parse: String, + brief: "Event message (repeat for multi-line)", + variadic: true, + optional: true, + }, + "message-arg": { + kind: "parsed", + parse: String, + brief: "Arguments for message template (repeat for multiple)", + variadic: true, + optional: true, + }, + level: { + kind: "enum", + values: ["debug", "info", "warning", "error", "fatal"], + brief: "Event severity level", + default: "error", + optional: true, + }, + release: { + kind: "parsed", + parse: String, + brief: "Release version", + optional: true, + }, + dist: { + kind: "parsed", + parse: String, + brief: "Distribution identifier", + optional: true, + }, + env: { + kind: "parsed", + parse: String, + brief: "Environment name (e.g. production, staging)", + optional: true, + }, + platform: { + kind: "parsed", + parse: String, + brief: "Platform identifier (default: other)", + optional: true, + }, + tag: { + kind: "parsed", + parse: String, + brief: "Tag as KEY:VALUE (repeat for multiple)", + variadic: true, + optional: true, + }, + extra: { + kind: "parsed", + parse: String, + brief: "Extra data as KEY:VALUE (repeat for multiple)", + variadic: true, + optional: true, + }, + user: { + kind: "parsed", + parse: String, + brief: + "User info as KEY:VALUE — id, email, username, ip_address, or custom", + variadic: true, + optional: true, + }, + fingerprint: { + kind: "parsed", + parse: String, + brief: "Custom fingerprint part (repeat for multiple)", + variadic: true, + optional: true, + }, + timestamp: { + kind: "parsed", + parse: String, + brief: "Event timestamp (Unix epoch, ISO 8601, or RFC 2822)", + optional: true, + }, + "no-environ": { + kind: "boolean", + brief: "Do not include environment variables in the event", + default: false, + optional: true, + }, + raw: { + kind: "boolean", + brief: "Send file contents as-is without parsing", + default: false, + optional: true, + }, + }, + aliases: { + m: "message", + a: "message-arg", + l: "level", + r: "release", + d: "dist", + E: "env", + p: "platform", + t: "tag", + e: "extra", + u: "user", + f: "fingerprint", + }, + }, + async *func( + this: SentryContext, + flags: SendEventFlags & { + dsn?: string; + raw?: boolean; + json?: boolean; + }, + ...files: string[] + ) { + const dsn = requireDsn(flags, this.cwd); + const dsnComponents = makeDsn(dsn); + if (!dsnComponents) { + throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); + } + + if (files.length > 0) { + for (const file of files) { + const { body, eventId } = await buildFilePayload( + file, + flags.raw ?? false, + dsnComponents + ); + await sendEnvelopeRequest(dsn, body); + yield new CommandOutput({ eventId, file }); + } + } else { + if (!flags.message?.length) { + throw new ConfigError( + "Provide a message via -m/--message or a JSON event file as a positional argument.", + "sentry send-event -m 'My message'" + ); + } + const event = buildEventFromFlags(flags); + const envelope = createEventEnvelope(event, dsnComponents); + await sendEnvelopeRequest(dsn, serializeEnvelope(envelope)); + yield new CommandOutput({ + eventId: event.event_id ?? "", + }); + } + }, +}); diff --git a/src/lib/command-suggestions.ts b/src/lib/command-suggestions.ts index eded64477..af7d3d088 100644 --- a/src/lib/command-suggestions.ts +++ b/src/lib/command-suggestions.ts @@ -99,14 +99,7 @@ const SUGGESTIONS: ReadonlyMap = new Map([ // --- old sentry-cli commands (~5 events) --- ["cli/info", { command: "sentry auth status" }], - [ - "cli/send-event", - { - command: - "sentry api /api/0/projects/{org}/{project}/store/ --method POST", - explanation: "Use the API to send test events", - }, - ], + ["cli/send-event", { command: "sentry send-event" }], ["cli/issues", { command: "sentry issue list" }], ["cli/logs", { command: "sentry log list" }], diff --git a/src/lib/command.ts b/src/lib/command.ts index 97bfbc784..f90a28ff1 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -166,8 +166,13 @@ type LocalCommandBuilderArguments< * * Set to `false` for commands that intentionally work without a token * (e.g. `auth login`, `auth logout`, `auth status`, `help`, `cli upgrade`). + * + * Set to `"dsn"` for commands that authenticate via a Sentry DSN instead of + * a Bearer token (e.g. `send-event`, `send-envelope`). These commands skip + * the token guard and the `.sentryclirc` URL trust check entirely, since + * DSN auth is fully independent of the user's logged-in session. */ - readonly auth?: boolean; + readonly auth?: boolean | "dsn"; /** * Skip the `.sentryclirc` URL trust check. Defaults to `false`. * @@ -490,8 +495,9 @@ export function buildCommand< ): Command { const originalFunc = builderArgs.func; const outputConfig = builderArgs.output; - const requiresAuth = builderArgs.auth !== false; - const skipRcUrlCheck = builderArgs.skipRcUrlCheck === true; + const requiresAuth = builderArgs.auth !== false && builderArgs.auth !== "dsn"; + const skipRcUrlCheck = + builderArgs.skipRcUrlCheck === true || builderArgs.auth === "dsn"; // Merge global flags into the command's flag definitions. const existingParams = (builderArgs.parameters ?? {}) as Record< diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts new file mode 100644 index 000000000..b6386ae9b --- /dev/null +++ b/src/lib/envelope/event-builder.ts @@ -0,0 +1,140 @@ +/** + * Constructs a Sentry Event from `sentry send-event` CLI flags. + * + * Mirrors the behaviour of the old Rust sentry-cli `send-event` command: + * tags/extras as KEY:VALUE pairs, user fields with known routing + * (id, email, ip_address, username → top-level; everything else → user.data), + * environment variables optionally included as `extra.environ`. + */ + +import type { Event, SeverityLevel, User } from "@sentry/core"; +import { uuid4 } from "@sentry/core"; +import { ValidationError } from "../errors.js"; + +/** CLI flags accepted by `sentry send-event`. */ +export type SendEventFlags = { + message?: string[]; + "message-arg"?: string[]; + level?: string; + release?: string; + dist?: string; + env?: string; + platform?: string; + tag?: string[]; + extra?: string[]; + user?: string[]; + fingerprint?: string[]; + timestamp?: string; + "no-environ"?: boolean; +}; + +const KNOWN_USER_FIELDS = new Set(["id", "email", "ip_address", "username"]); + +/** + * Parse a single KEY:VALUE string, splitting on the first colon. + * + * Values may contain colons (e.g. `url:https://example.com`). + * Throws ValidationError if the format is wrong. + */ +export function parseKeyValue(pair: string): [string, string] { + const idx = pair.indexOf(":"); + if (idx <= 0) { + throw new ValidationError( + `Expected KEY:VALUE format, got: ${JSON.stringify(pair)}`, + "tag/extra" + ); + } + return [pair.slice(0, idx), pair.slice(idx + 1)]; +} + +/** + * Parse an array of KEY:VALUE strings into a plain object. + */ +function parseKeyValuePairs( + pairs: string[] | undefined +): Record { + if (!pairs?.length) { + return {}; + } + return Object.fromEntries(pairs.map(parseKeyValue)); +} + +/** + * Parse `--user` KEY:VALUE pairs into a Sentry User object. + * + * Known fields (id, email, ip_address, username) map directly to User + * properties. Unknown keys go into `user.data` for custom attributes. + */ +export function parseUserFields(pairs: string[]): User { + const user: User & { data?: Record } = {}; + for (const pair of pairs) { + const [key, value] = parseKeyValue(pair); + if (KNOWN_USER_FIELDS.has(key)) { + (user as Record)[key] = value; + } else { + user.data ??= {}; + user.data[key] = value; + } + } + return user; +} + +/** + * Parse a timestamp string into a Unix epoch float (seconds). + * + * Accepts: Unix integer/float, ISO 8601, RFC 2822. + * Returns undefined for falsy input (caller uses Date.now()). + */ +function parseTimestamp(ts: string | undefined): number | undefined { + if (!ts) { + return; + } + // Unix numeric + const num = Number(ts); + if (!Number.isNaN(num) && num > 0) { + return num; + } + // ISO / RFC 2822 + const parsed = Date.parse(ts); + if (!Number.isNaN(parsed)) { + return parsed / 1000; + } + return; +} + +/** + * Build a Sentry Event from CLI flag values. + * + * The returned object is ready to be wrapped in an EventEnvelope and + * serialized for posting to the ingest endpoint. + */ +export function buildEventFromFlags(flags: SendEventFlags): Event { + const tags = parseKeyValuePairs(flags.tag); + const extra: Record = { + ...parseKeyValuePairs(flags.extra), + ...(flags["no-environ"] ? {} : { environ: process.env }), + }; + + return { + event_id: uuid4(), + level: (flags.level ?? "error") as SeverityLevel, + platform: flags.platform ?? "other", + timestamp: parseTimestamp(flags.timestamp) ?? Date.now() / 1000, + release: flags.release, + dist: flags.dist, + environment: flags.env, + logentry: + flags.message && flags.message.length > 0 + ? { + message: flags.message.join("\n"), + ...(flags["message-arg"]?.length + ? { params: flags["message-arg"] as unknown[] } + : {}), + } + : undefined, + tags: Object.keys(tags).length > 0 ? tags : undefined, + extra: Object.keys(extra).length > 0 ? extra : undefined, + user: flags.user?.length ? parseUserFields(flags.user) : undefined, + fingerprint: flags.fingerprint, + }; +} diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts new file mode 100644 index 000000000..b955f7d8e --- /dev/null +++ b/src/lib/envelope/transport.ts @@ -0,0 +1,107 @@ +/** + * DSN-based envelope transport for Sentry's event ingestion pipeline. + * + * Unlike the Web API (which uses Bearer token auth), envelope ingestion + * authenticates via the DSN's public key embedded in the request URL. + * This is the same mechanism all Sentry SDKs use when reporting errors. + * + * Endpoint pattern: + * POST https:///api//envelope/ + * ?sentry_key=&sentry_version=7 + * Content-Type: application/x-sentry-envelope + */ + +import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn } from "@sentry/core"; +import { ApiError, ConfigError, ValidationError } from "../errors.js"; + +const SENTRY_CLIENT = "sentry-cli/dev"; + +/** Flags subset relevant to DSN resolution. */ +export type DsnFlags = { + dsn?: string; +}; + +/** + * Build the ingest URL for a given DSN. + * + * Returns the full URL including auth query params, ready to POST to. + * Throws ValidationError on an unparseable DSN. + */ +export function buildEnvelopeUrl(dsn: string): string { + const dsnComponents = makeDsn(dsn); + if (!dsnComponents) { + throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); + } + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents, undefined, { + name: SENTRY_CLIENT, + version: "dev", + }); +} + +/** + * Resolve the DSN to use for sending, in priority order: + * 1. `--dsn` flag + * 2. `SENTRY_DSN` environment variable + * 3. Returns `undefined` (caller decides whether to auto-detect or error) + */ +export function resolveDsn(flags: DsnFlags, _cwd: string): string | undefined { + if (flags.dsn) { + return flags.dsn; + } + const envDsn = process.env.SENTRY_DSN; + if (envDsn) { + return envDsn; + } + return; +} + +/** + * Require a DSN to be available, throwing a helpful ConfigError if not. + * + * Auto-detection via project scanning is intentionally deferred — callers + * that want it can call the DSN detector before this. + */ +export function requireDsn(flags: DsnFlags, cwd: string): string { + const dsn = resolveDsn(flags, cwd); + if (dsn) { + return dsn; + } + throw new ConfigError( + "No DSN found. Provide one via --dsn, SENTRY_DSN env var, or ensure your project has a Sentry DSN configured.", + "sentry send-event --dsn " + ); +} + +/** + * POST a serialized envelope to Sentry's ingest endpoint using DSN auth. + * + * No Bearer token is required — the DSN public key serves as authentication. + * Throws ApiError on non-2xx responses. + */ +export async function sendEnvelopeRequest( + dsn: string, + body: string | Uint8Array +): Promise { + const url = buildEnvelopeUrl(dsn); + + const response = await fetch( + new Request(url, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body, + }) + ); + + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const json = (await response.json()) as Record; + if (typeof json.detail === "string") { + detail = json.detail; + } + } catch { + // Non-JSON error body — keep the HTTP status message + } + throw new ApiError(detail, response.status, detail, url); + } +} diff --git a/test/commands/send-envelope.test.ts b/test/commands/send-envelope.test.ts new file mode 100644 index 000000000..8ebd07a46 --- /dev/null +++ b/test/commands/send-envelope.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for `sentry send-envelope` command func(). + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sendEnvelopeCommand } from "../../src/commands/send-envelope.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as transport from "../../src/lib/envelope/transport.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("send-envelope-"); + +const SAAS_DSN = "https://abc123@o1.ingest.us.sentry.io/999"; + +// A minimal valid envelope: header line + item header + item body +const VALID_ENVELOPE = + '{"event_id":"aabbccddeeff00112233445566778899","sent_at":"2026-01-01T00:00:00.000Z"}\n' + + '{"type":"event","length":2}\n' + + "{}"; + +function makeContext() { + const writes: string[] = []; + return { + ctx: { + stdout: { + write: (s: string) => { + writes.push(s); + return true; + }, + }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + writes, + }; +} + +function writeTmpEnvelope(name: string, content: string): string { + const dir = join(tmpdir(), "sentry-test-envelopes"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +describe("sendEnvelopeCommand.func()", () => { + let func: Awaited>; + let sendSpy: ReturnType; + + beforeEach(async () => { + func = await sendEnvelopeCommand.loader(); + sendSpy = spyOn(transport, "sendEnvelopeRequest").mockResolvedValue( + undefined + ); + }); + + afterEach(() => { + sendSpy.mockRestore(); + }); + + test("valid envelope file is sent and success message printed", async () => { + const path = writeTmpEnvelope("test.envelope", VALID_ENVELOPE); + const { ctx, writes } = makeContext(); + + await func.call(ctx, { dsn: SAAS_DSN }, path); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const output = writes.join(""); + expect(output).toContain("dispatched"); + expect(output).toContain("test.envelope"); + }); + + test("--raw sends file bytes without parsing", async () => { + const content = "raw garbage that is not valid envelope format"; + const path = writeTmpEnvelope("raw.envelope", content); + const { ctx } = makeContext(); + + // Without --raw, this would throw a parse error + await func.call(ctx, { dsn: SAAS_DSN, raw: true }, path); + + expect(sendSpy).toHaveBeenCalledTimes(1); + // Body should be the raw bytes + const body = sendSpy.mock.calls[0]?.[1]; + expect(body).toBeDefined(); + }); + + test("invalid envelope without --raw throws parse error", async () => { + const path = writeTmpEnvelope("bad.envelope", "not valid\nenvelope"); + const { ctx } = makeContext(); + + await expect(func.call(ctx, { dsn: SAAS_DSN }, path)).rejects.toThrow(); + + expect(sendSpy).not.toHaveBeenCalled(); + }); + + test("missing DSN throws ConfigError", async () => { + const savedDsn = process.env.SENTRY_DSN; + delete process.env.SENTRY_DSN; + const path = writeTmpEnvelope("ok.envelope", VALID_ENVELOPE); + const { ctx } = makeContext(); + try { + await expect(func.call(ctx, {}, path)).rejects.toThrow(); + } finally { + if (savedDsn !== undefined) process.env.SENTRY_DSN = savedDsn; + } + }); + + test("multiple files are each sent separately", async () => { + const p1 = writeTmpEnvelope("a.envelope", VALID_ENVELOPE); + const p2 = writeTmpEnvelope("b.envelope", VALID_ENVELOPE); + const { ctx } = makeContext(); + + await func.call(ctx, { dsn: SAAS_DSN }, p1, p2); + + expect(sendSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/commands/send-event.test.ts b/test/commands/send-event.test.ts new file mode 100644 index 000000000..e8084bfe4 --- /dev/null +++ b/test/commands/send-event.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for `sentry send-event` command func(). + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { sendEventCommand } from "../../src/commands/send-event.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as transport from "../../src/lib/envelope/transport.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("send-event-"); + +const SAAS_DSN = "https://abc123@o1.ingest.us.sentry.io/999"; + +function makeContext() { + const writes: string[] = []; + return { + ctx: { + stdout: { + write: (s: string) => { + writes.push(s); + return true; + }, + }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + writes, + }; +} + +describe("sendEventCommand.func()", () => { + let func: Awaited>; + let sendSpy: ReturnType; + + beforeEach(async () => { + func = await sendEventCommand.loader(); + sendSpy = spyOn(transport, "sendEnvelopeRequest").mockResolvedValue( + undefined + ); + }); + + afterEach(() => { + sendSpy.mockRestore(); + }); + + test("inline message sends an envelope and prints event ID", async () => { + const { ctx, writes } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["Test message"], + level: "error", + "no-environ": true, + }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const [calledDsn, calledBody] = sendSpy.mock.calls[0] as [string, string]; + expect(calledDsn).toBe(SAAS_DSN); + expect(calledBody).toContain('"type":"event"'); + + const output = writes.join(""); + expect(output).toContain("Event dispatched"); + expect(output).toMatch(/[0-9a-f]{32}/); // event ID in output + }); + + test("--level flag is included in envelope body", async () => { + const { ctx } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["boom"], + level: "fatal", + "no-environ": true, + }); + + const body = sendSpy.mock.calls[0]?.[1] as string; + expect(body).toContain('"level":"fatal"'); + }); + + test("--tag pairs appear in envelope body", async () => { + const { ctx } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["hi"], + tag: ["env:prod", "region:us"], + "no-environ": true, + }); + + const body = sendSpy.mock.calls[0]?.[1] as string; + expect(body).toContain('"env":"prod"'); + expect(body).toContain('"region":"us"'); + }); + + test("missing DSN throws ConfigError", async () => { + const savedDsn = process.env.SENTRY_DSN; + delete process.env.SENTRY_DSN; + const { ctx } = makeContext(); + try { + await expect(func.call(ctx, { "no-environ": true })).rejects.toThrow(); + } finally { + if (savedDsn !== undefined) process.env.SENTRY_DSN = savedDsn; + } + }); + + test("--json outputs JSON with eventId field", async () => { + const { ctx, writes } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["hello"], + json: true, + "no-environ": true, + }); + + const output = writes.join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("eventId"); + expect(parsed.eventId).toMatch(/^[0-9a-f]{32}$/); + }); +}); diff --git a/test/lib/command-suggestions.test.ts b/test/lib/command-suggestions.test.ts index e2f7f0b39..10812062b 100644 --- a/test/lib/command-suggestions.test.ts +++ b/test/lib/command-suggestions.test.ts @@ -81,9 +81,9 @@ describe("getCommandSuggestion", () => { expect(getCommandSuggestion("cli", "logs")?.command).toContain("log list"); }); - test("suggests api for 'cli/send-event'", () => { + test("suggests send-event for 'cli/send-event'", () => { expect(getCommandSuggestion("cli", "send-event")?.command).toContain( - "sentry api" + "sentry send-event" ); }); diff --git a/test/lib/envelope/event-builder.test.ts b/test/lib/envelope/event-builder.test.ts new file mode 100644 index 000000000..9e9d68f80 --- /dev/null +++ b/test/lib/envelope/event-builder.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for buildEventFromFlags — converts CLI flags to a Sentry Event. + * + * Note: Core invariants (tag/extra parsing, user field routing) are property- + * tested below. Unit tests here focus on specific edge cases and output shape. + */ + +import { describe, expect, test } from "bun:test"; +import type { SendEventFlags } from "../../../src/lib/envelope/event-builder.js"; +import { + buildEventFromFlags, + parseKeyValue, + parseUserFields, +} from "../../../src/lib/envelope/event-builder.js"; + +// ── parseKeyValue ────────────────────────────────────────────────── + +describe("parseKeyValue", () => { + test("splits on first colon", () => { + expect(parseKeyValue("key:value")).toEqual(["key", "value"]); + }); + + test("value may contain colons", () => { + expect(parseKeyValue("url:https://example.com")).toEqual([ + "url", + "https://example.com", + ]); + }); + + test("no colon → throws ValidationError", () => { + const { ValidationError } = require("../../../src/lib/errors.js"); + expect(() => parseKeyValue("nocohere")).toThrow(ValidationError); + }); + + test("empty key → throws ValidationError", () => { + const { ValidationError } = require("../../../src/lib/errors.js"); + expect(() => parseKeyValue(":value")).toThrow(ValidationError); + }); +}); + +// ── parseUserFields ─────────────────────────────────────────────── + +describe("parseUserFields", () => { + test("id maps to user.id", () => { + expect(parseUserFields(["id:42"])).toMatchObject({ id: "42" }); + }); + + test("email maps to user.email", () => { + expect(parseUserFields(["email:alice@example.com"])).toMatchObject({ + email: "alice@example.com", + }); + }); + + test("ip_address maps to user.ip_address", () => { + expect(parseUserFields(["ip_address:1.2.3.4"])).toMatchObject({ + ip_address: "1.2.3.4", + }); + }); + + test("username maps to user.username", () => { + expect(parseUserFields(["username:alice"])).toMatchObject({ + username: "alice", + }); + }); + + test("unknown keys go into user.data", () => { + expect(parseUserFields(["role:admin"])).toMatchObject({ + data: { role: "admin" }, + }); + }); + + test("multiple pairs merged", () => { + const result = parseUserFields(["id:1", "email:a@b.com", "role:admin"]); + expect(result).toMatchObject({ + id: "1", + email: "a@b.com", + data: { role: "admin" }, + }); + }); +}); + +// ── buildEventFromFlags ─────────────────────────────────────────── + +describe("buildEventFromFlags", () => { + function flags(overrides: Partial = {}): SendEventFlags { + return { "no-environ": true, ...overrides }; + } + + test("defaults: level=error, platform=other", () => { + const event = buildEventFromFlags(flags()); + expect(event.level).toBe("error"); + expect(event.platform).toBe("other"); + }); + + test("event_id is always a 32-char hex string", () => { + const event = buildEventFromFlags(flags()); + expect(event.event_id).toMatch(/^[0-9a-f]{32}$/); + }); + + test("timestamp is a Unix float", () => { + const event = buildEventFromFlags(flags()); + expect(typeof event.timestamp).toBe("number"); + expect(event.timestamp).toBeGreaterThan(0); + }); + + test("--level sets level", () => { + expect(buildEventFromFlags(flags({ level: "warning" })).level).toBe( + "warning" + ); + }); + + test("--message joined with newline", () => { + const event = buildEventFromFlags(flags({ message: ["hello", "world"] })); + expect(event.logentry?.message).toBe("hello\nworld"); + }); + + test("--message-arg sets params", () => { + const event = buildEventFromFlags( + flags({ message: ["hello %s"], "message-arg": ["world"] }) + ); + expect(event.logentry?.params).toEqual(["world"]); + }); + + test("--tag parses into tags object", () => { + const event = buildEventFromFlags(flags({ tag: ["env:prod", "ver:1.0"] })); + expect(event.tags).toEqual({ env: "prod", ver: "1.0" }); + }); + + test("--extra parses into extra object", () => { + const event = buildEventFromFlags(flags({ extra: ["foo:bar"] })); + expect((event.extra as Record).foo).toBe("bar"); + }); + + test("--no-environ omits process.env from extra", () => { + const event = buildEventFromFlags(flags({ "no-environ": true })); + expect((event.extra as Record)?.environ).toBeUndefined(); + }); + + test("environ included when --no-environ not set", () => { + const event = buildEventFromFlags(flags({ "no-environ": false })); + expect((event.extra as Record)?.environ).toBeDefined(); + }); + + test("--user routes known fields correctly", () => { + const event = buildEventFromFlags( + flags({ user: ["id:99", "email:a@b.com"] }) + ); + expect(event.user?.id).toBe("99"); + expect(event.user?.email).toBe("a@b.com"); + }); + + test("--fingerprint sets fingerprint array", () => { + const event = buildEventFromFlags( + flags({ fingerprint: ["my-error", "{{ default }}"] }) + ); + expect(event.fingerprint).toEqual(["my-error", "{{ default }}"]); + }); + + test("--release sets release", () => { + expect(buildEventFromFlags(flags({ release: "1.2.3" })).release).toBe( + "1.2.3" + ); + }); + + test("--env sets environment", () => { + expect(buildEventFromFlags(flags({ env: "staging" })).environment).toBe( + "staging" + ); + }); + + test("--platform sets platform", () => { + expect(buildEventFromFlags(flags({ platform: "python" })).platform).toBe( + "python" + ); + }); + + test("--dist sets dist", () => { + expect(buildEventFromFlags(flags({ dist: "x86" })).dist).toBe("x86"); + }); + + test("each call produces a unique event_id", () => { + const a = buildEventFromFlags(flags()); + const b = buildEventFromFlags(flags()); + expect(a.event_id).not.toBe(b.event_id); + }); +}); diff --git a/test/lib/envelope/transport.test.ts b/test/lib/envelope/transport.test.ts new file mode 100644 index 000000000..8f32d7cb4 --- /dev/null +++ b/test/lib/envelope/transport.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for the DSN-based envelope transport. + * + * Core invariants: + * - URL is built from DSN components (host + projectId) + * - Auth is injected as query params (sentry_key, sentry_version) + * - Content-Type is always application/x-sentry-envelope + * - Non-2xx responses throw ApiError + * - Both string and Uint8Array bodies are supported + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + buildEnvelopeUrl, + resolveDsn, + sendEnvelopeRequest, +} from "../../../src/lib/envelope/transport.js"; +import { ApiError } from "../../../src/lib/errors.js"; + +const SAAS_DSN = "https://abc123@o1169445.ingest.us.sentry.io/4505229541441536"; +const SELF_HOSTED_DSN = "https://pubkey99@sentry.mycompany.com/7"; + +// ── buildEnvelopeUrl ─────────────────────────────────────────────── + +describe("buildEnvelopeUrl", () => { + test("SaaS DSN → correct ingest URL with auth params", () => { + const url = buildEnvelopeUrl(SAAS_DSN); + expect(url).toContain("/api/4505229541441536/envelope/"); + expect(url).toContain("sentry_key=abc123"); + expect(url).toContain("sentry_version=7"); + expect(url.startsWith("https://")).toBe(true); + }); + + test("self-hosted DSN → correct ingest URL", () => { + const url = buildEnvelopeUrl(SELF_HOSTED_DSN); + expect(url).toContain("sentry.mycompany.com"); + expect(url).toContain("/api/7/envelope/"); + expect(url).toContain("sentry_key=pubkey99"); + }); + + test("invalid DSN → throws ValidationError", () => { + expect(() => buildEnvelopeUrl("not-a-dsn")).toThrow(); + }); +}); + +// ── resolveDsn ──────────────────────────────────────────────────── + +describe("resolveDsn", () => { + const originalEnv = process.env.SENTRY_DSN; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.SENTRY_DSN; + } else { + process.env.SENTRY_DSN = originalEnv; + } + }); + + test("explicit --dsn flag takes priority over env", () => { + process.env.SENTRY_DSN = SELF_HOSTED_DSN; + const result = resolveDsn({ dsn: SAAS_DSN }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); + + test("SENTRY_DSN env var used when no flag", () => { + process.env.SENTRY_DSN = SAAS_DSN; + const result = resolveDsn({ dsn: undefined }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); + + test("returns undefined when neither flag nor env set", () => { + delete process.env.SENTRY_DSN; + const result = resolveDsn({ dsn: undefined }, "/tmp"); + expect(result).toBeUndefined(); + }); +}); + +// ── sendEnvelopeRequest ─────────────────────────────────────────── + +describe("sendEnvelopeRequest", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs with correct Content-Type header", async () => { + let capturedRequest: Request | undefined; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedRequest = input as Request; + return new Response("{}", { status: 200 }); + }; + + await sendEnvelopeRequest( + SAAS_DSN, + '{"event_id":"abc"}\n{"type":"event","length":2}\n{}' + ); + + expect(capturedRequest).toBeDefined(); + expect(capturedRequest!.method).toBe("POST"); + expect(capturedRequest!.headers.get("Content-Type")).toBe( + "application/x-sentry-envelope" + ); + }); + + test("URL contains sentry_key and sentry_version", async () => { + let capturedUrl = ""; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedUrl = (input as Request).url; + return new Response("{}", { status: 200 }); + }; + + await sendEnvelopeRequest(SAAS_DSN, "body"); + + expect(capturedUrl).toContain("sentry_key=abc123"); + expect(capturedUrl).toContain("sentry_version=7"); + }); + + test("accepts Uint8Array body", async () => { + globalThis.fetch = async () => new Response("{}", { status: 200 }); + // should not throw + await expect( + sendEnvelopeRequest(SAAS_DSN, new TextEncoder().encode("bytes")) + ).resolves.toBeUndefined(); + }); + + test("non-2xx response throws ApiError", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "invalid DSN" }), { status: 403 }); + + await expect(sendEnvelopeRequest(SAAS_DSN, "body")).rejects.toBeInstanceOf( + ApiError + ); + }); + + test("400 response includes error detail in message", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "bad envelope" }), { + status: 400, + }); + + const err = await sendEnvelopeRequest(SAAS_DSN, "body").catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toContain("bad envelope"); + }); +}); From 296879feea5e25d116ee95191366eb8531f13429 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 16:42:38 +0000 Subject: [PATCH 02/16] refactor: group send-event/send-envelope under 'sentry send' route sentry send event -- canonical new interface sentry send envelope sentry send-event -- hidden bw-compat alias (old sentry-cli) sentry send-envelope -- hidden bw-compat alias (old sentry-cli) --- src/app.ts | 5 +++++ src/commands/send/index.ts | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/commands/send/index.ts diff --git a/src/app.ts b/src/app.ts index 916243958..0aa6bd650 100644 --- a/src/app.ts +++ b/src/app.ts @@ -33,6 +33,7 @@ import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; import { sendEnvelopeCommand } from "./commands/send-envelope.js"; import { sendEventCommand } from "./commands/send-event.js"; +import { sendRoute } from "./commands/send/index.js"; import { sourcemapRoute } from "./commands/sourcemap/index.js"; import { spanRoute } from "./commands/span/index.js"; import { listCommand as spanListCommand } from "./commands/span/list.js"; @@ -107,6 +108,8 @@ export const routes = buildRouteMap({ init: initCommand, api: apiCommand, schema: schemaCommand, + send: sendRoute, + // Backward-compat aliases for old sentry-cli — hidden from help "send-event": sendEventCommand, "send-envelope": sendEnvelopeCommand, dashboards: dashboardListCommand, @@ -145,6 +148,8 @@ export const routes = buildRouteMap({ trials: true, sourcemaps: true, whoami: true, + "send-event": true, + "send-envelope": true, }, }, }); diff --git a/src/commands/send/index.ts b/src/commands/send/index.ts new file mode 100644 index 000000000..5579b853f --- /dev/null +++ b/src/commands/send/index.ts @@ -0,0 +1,24 @@ +import { buildRouteMap } from "../../lib/route-map.js"; +import { sendEnvelopeCommand } from "../send-envelope.js"; +import { sendEventCommand } from "../send-event.js"; + +export const sendRoute = buildRouteMap({ + routes: { + event: sendEventCommand, + envelope: sendEnvelopeCommand, + }, + docs: { + brief: "Send events and envelopes to Sentry via DSN", + fullDescription: + "Send data directly to Sentry's ingest pipeline using DSN-based authentication.\n\n" + + "No `sentry auth login` required — provide a DSN via --dsn or SENTRY_DSN env var.\n\n" + + "Commands:\n" + + " event Send a Sentry event (from flags or a JSON file)\n" + + " envelope Send a pre-built Sentry envelope file\n\n" + + "Examples:\n" + + " sentry send event -m 'Deploy check' -l info --tag env:prod\n" + + " sentry send event ./crash.json\n" + + " sentry send envelope ./captured.envelope", + hideRoute: {}, + }, +}); From 902d08905de0e2245881c657f828eeb75bd3d5d8 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:05:06 +0000 Subject: [PATCH 03/16] fix: sort imports in app.ts + add missing send command docs fragment --- docs/src/fragments/commands/send.md | 74 +++++++++++++++++++++++++++++ src/app.ts | 2 +- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 docs/src/fragments/commands/send.md diff --git a/docs/src/fragments/commands/send.md b/docs/src/fragments/commands/send.md new file mode 100644 index 000000000..16c09858e --- /dev/null +++ b/docs/src/fragments/commands/send.md @@ -0,0 +1,74 @@ + + +## Examples + +### Send an event from flags + +```bash +# Send an error event (default level) +sentry send event -m "Something went wrong" + +# Specify level, release, and environment +sentry send event -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry send event -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry send event -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry send event -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} +``` + +### Send an event from a JSON file + +```bash +# Send a serialized Sentry Event object +sentry send event ./crash.json + +# Send without re-parsing (raw mode) +sentry send event --raw ./crash.json +``` + +### Send a pre-built envelope + +```bash +# Send a captured Sentry envelope file +sentry send envelope ./captured.envelope + +# Send without validation (raw mode) +sentry send envelope --raw ./binary.envelope + +# Send multiple envelope files +sentry send envelope ./a.envelope ./b.envelope +``` + +## DSN authentication + +`sentry send` commands authenticate via a **DSN** rather than a user token. +No `sentry auth login` is required. + +The DSN is resolved in priority order: + +1. `--dsn ` flag (explicit) +2. `SENTRY_DSN` environment variable +3. Auto-detected from `.env` files and project source code + +```bash +# Explicit DSN +sentry send event -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry send event -m "Test" +``` + +## Backward compatibility + +The old sentry-cli top-level commands are available as hidden aliases: + +```bash +sentry send-event # same as: sentry send event +sentry send-envelope # same as: sentry send envelope +``` diff --git a/src/app.ts b/src/app.ts index 0aa6bd650..55401686d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,9 +31,9 @@ import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; +import { sendRoute } from "./commands/send/index.js"; import { sendEnvelopeCommand } from "./commands/send-envelope.js"; import { sendEventCommand } from "./commands/send-event.js"; -import { sendRoute } from "./commands/send/index.js"; import { sourcemapRoute } from "./commands/sourcemap/index.js"; import { spanRoute } from "./commands/span/index.js"; import { listCommand as spanListCommand } from "./commands/span/list.js"; From cb1509845fd5572662db27d7cd813f20b898d49b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 17:05:46 +0000 Subject: [PATCH 04/16] chore: regenerate docs --- docs/src/content/docs/contributing.md | 5 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 17 +--- .../sentry-cli/references/send-envelope.md | 22 ----- .../sentry-cli/references/send-event.md | 35 ------- .../skills/sentry-cli/references/send.md | 91 +++++++++++++++++++ 5 files changed, 98 insertions(+), 72 deletions(-) delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/send-event.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/send.md diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 5e191dc90..6b8df15f0 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -62,6 +62,7 @@ cli/ │ │ ├── release/ # list, view, create, finalize, delete, deploy, deploys, set-commits, propose-version │ │ ├── replay/ # list, view │ │ ├── repo/ # list +│ │ ├── send/ # event, envelope │ │ ├── sourcemap/ # inject, upload │ │ ├── span/ # list, view │ │ ├── team/ # list @@ -71,9 +72,7 @@ cli/ │ │ ├── explore.ts # Query aggregate event data (Explore) │ │ ├── help.ts # Help command │ │ ├── init.ts # Initialize Sentry in your project (experimental) -│ │ ├── schema.ts # Browse the Sentry API schema -│ │ ├── send-envelope.ts# Send a Sentry envelope file -│ │ └── send-event.ts# Send a Sentry event +│ │ └── schema.ts # Browse the Sentry API schema │ ├── lib/ # Shared utilities │ └── types/ # TypeScript types and Zod schemas ├── test/ # Test files (mirrors src/ structure) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f8c12f7ca..93e005066 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -474,21 +474,14 @@ Browse the Sentry API schema → Full flags and examples: `references/schema.md` -### Send-event +### Send -Send a Sentry event +Send events and envelopes to Sentry via DSN -- `sentry send-event ` — Send a Sentry event +- `sentry send event ` — Send a Sentry event +- `sentry send envelope ` — Send a Sentry envelope file -→ Full flags and examples: `references/send-event.md` - -### Send-envelope - -Send a Sentry envelope file - -- `sentry send-envelope ` — Send a Sentry envelope file - -→ Full flags and examples: `references/send-envelope.md` +→ Full flags and examples: `references/send.md` ## Global Options diff --git a/plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md b/plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md deleted file mode 100644 index c83e07f2a..000000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/send-envelope.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: sentry-cli-send-envelope -version: 0.32.0-dev.0 -description: Send a Sentry envelope file -requires: - bins: ["sentry"] - auth: true ---- - -# Send-envelope Commands - -Send a Sentry envelope file - -### `sentry send-envelope ` - -Send a Sentry envelope file - -**Flags:** -- `--dsn - DSN to send envelopes to (overrides SENTRY_DSN env var)` -- `--raw - Send file bytes without parsing or validating the envelope` - -All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/plugins/sentry-cli/skills/sentry-cli/references/send-event.md b/plugins/sentry-cli/skills/sentry-cli/references/send-event.md deleted file mode 100644 index 408c685e8..000000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/send-event.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: sentry-cli-send-event -version: 0.32.0-dev.0 -description: Send a Sentry event -requires: - bins: ["sentry"] - auth: true ---- - -# Send-event Commands - -Send a Sentry event - -### `sentry send-event ` - -Send a Sentry event - -**Flags:** -- `--dsn - DSN to send events to (overrides SENTRY_DSN env var)` -- `-m, --message ... - Event message (repeat for multi-line)` -- `-a, --message-arg ... - Arguments for message template (repeat for multiple)` -- `-l, --level - Event severity level - (default: "error")` -- `-r, --release - Release version` -- `-d, --dist - Distribution identifier` -- `-E, --env - Environment name (e.g. production, staging)` -- `-p, --platform - Platform identifier (default: other)` -- `-t, --tag ... - Tag as KEY:VALUE (repeat for multiple)` -- `-e, --extra ... - Extra data as KEY:VALUE (repeat for multiple)` -- `-u, --user ... - User info as KEY:VALUE — id, email, username, ip_address, or custom` -- `-f, --fingerprint ... - Custom fingerprint part (repeat for multiple)` -- `--timestamp - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)` -- `--no-environ - Do not include environment variables in the event` -- `--raw - Send file contents as-is without parsing` - -All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/plugins/sentry-cli/skills/sentry-cli/references/send.md b/plugins/sentry-cli/skills/sentry-cli/references/send.md new file mode 100644 index 000000000..6b0712ba2 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/send.md @@ -0,0 +1,91 @@ +--- +name: sentry-cli-send +version: 0.32.0-dev.0 +description: Send events and envelopes to Sentry via DSN +requires: + bins: ["sentry"] + auth: true +--- + +# Send Commands + +Send events and envelopes to Sentry via DSN + +### `sentry send event ` + +Send a Sentry event + +**Flags:** +- `--dsn - DSN to send events to (overrides SENTRY_DSN env var)` +- `-m, --message ... - Event message (repeat for multi-line)` +- `-a, --message-arg ... - Arguments for message template (repeat for multiple)` +- `-l, --level - Event severity level - (default: "error")` +- `-r, --release - Release version` +- `-d, --dist - Distribution identifier` +- `-E, --env - Environment name (e.g. production, staging)` +- `-p, --platform - Platform identifier (default: other)` +- `-t, --tag ... - Tag as KEY:VALUE (repeat for multiple)` +- `-e, --extra ... - Extra data as KEY:VALUE (repeat for multiple)` +- `-u, --user ... - User info as KEY:VALUE — id, email, username, ip_address, or custom` +- `-f, --fingerprint ... - Custom fingerprint part (repeat for multiple)` +- `--timestamp - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)` +- `--no-environ - Do not include environment variables in the event` +- `--raw - Send file contents as-is without parsing` + +**Examples:** + +```bash +# Send an error event (default level) +sentry send event -m "Something went wrong" + +# Specify level, release, and environment +sentry send event -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry send event -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry send event -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry send event -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} + +# Send a serialized Sentry Event object +sentry send event ./crash.json + +# Send without re-parsing (raw mode) +sentry send event --raw ./crash.json + +# Explicit DSN +sentry send event -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry send event -m "Test" + +sentry send-event # same as: sentry send event +sentry send-envelope # same as: sentry send envelope +``` + +### `sentry send envelope ` + +Send a Sentry envelope file + +**Flags:** +- `--dsn - DSN to send envelopes to (overrides SENTRY_DSN env var)` +- `--raw - Send file bytes without parsing or validating the envelope` + +**Examples:** + +```bash +# Send a captured Sentry envelope file +sentry send envelope ./captured.envelope + +# Send without validation (raw mode) +sentry send envelope --raw ./binary.envelope + +# Send multiple envelope files +sentry send envelope ./a.envelope ./b.envelope +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. From 7e33546b89151b6dd5cfe8c2350a580effb5dc3c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:19:38 +0000 Subject: [PATCH 05/16] fix: address bot findings on PR #921 - parseTimestamp now throws ValidationError on invalid input instead of silently falling back to Date.now() (Seer/Cursor) - Environ spread order fixed: process.env goes first so user --extra environ:val correctly overrides it (Cursor) - Raw mode no longer reads file twice: decode bytes in-memory instead of re-reading (Cursor) - Fixed --raw help text: sends raw bytes directly, not 'inside an envelope' (Sentry) - send-envelope now errors when no files provided (Sentry) - send-event --raw in inline mode now throws ValidationError (Sentry) --- src/commands/send-envelope.ts | 8 ++++++++ src/commands/send-event.ts | 18 +++++++++++------- src/lib/envelope/event-builder.ts | 9 +++++++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index 6119edc66..5fa0f45f5 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -14,6 +14,7 @@ import { parseEnvelope, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; +import { ValidationError } from "../lib/errors.js"; import { CommandOutput } from "../lib/formatters/output.js"; type SendEnvelopeResult = { @@ -83,6 +84,13 @@ sentry send-envelope ./a.envelope ./b.envelope flags: { dsn?: string; raw?: boolean }, ...files: string[] ) { + if (files.length === 0) { + throw new ValidationError( + "At least one envelope file path is required.", + "path" + ); + } + const dsn = requireDsn(flags, this.cwd); for (const file of files) { diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts index b19ae574e..9c6d01431 100644 --- a/src/commands/send-event.ts +++ b/src/commands/send-event.ts @@ -46,14 +46,12 @@ async function buildFilePayload( ): Promise<{ body: string | Uint8Array; eventId: string }> { if (raw) { const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()); - // Best-effort: extract event_id from the first line (envelope header JSON) + // Best-effort: extract event_id from the first line (envelope header JSON). + // Decode the already-read bytes instead of re-reading the file. let eventId = ""; try { - const text = await Bun.file(file).text(); - const header = JSON.parse(text.split("\n")[0] ?? "{}") as Record< - string, - unknown - >; + const firstLine = new TextDecoder().decode(bytes).split("\n")[0] ?? "{}"; + const header = JSON.parse(firstLine) as Record; eventId = (header.event_id as string) ?? ""; } catch { // Non-critical — event_id is informational only @@ -88,7 +86,7 @@ The JSON file must be a valid serialized Sentry Event object: sentry send-event ./event.json \`\`\` -Use --raw to skip JSON parsing and send the file contents as-is inside an envelope. +Use --raw to skip JSON parsing and send the file bytes directly to the ingest endpoint. ## Common flags @@ -258,6 +256,12 @@ Use --raw to skip JSON parsing and send the file contents as-is inside an envelo yield new CommandOutput({ eventId, file }); } } else { + if (flags.raw) { + throw new ValidationError( + "--raw requires a file argument (raw bytes cannot be built from inline flags)", + "raw" + ); + } if (!flags.message?.length) { throw new ConfigError( "Provide a message via -m/--message or a JSON event file as a positional argument.", diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index b6386ae9b..4151590cc 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -84,6 +84,7 @@ export function parseUserFields(pairs: string[]): User { * * Accepts: Unix integer/float, ISO 8601, RFC 2822. * Returns undefined for falsy input (caller uses Date.now()). + * Throws ValidationError for non-empty strings that cannot be parsed. */ function parseTimestamp(ts: string | undefined): number | undefined { if (!ts) { @@ -99,7 +100,10 @@ function parseTimestamp(ts: string | undefined): number | undefined { if (!Number.isNaN(parsed)) { return parsed / 1000; } - return; + throw new ValidationError( + `Invalid --timestamp value: '${ts}'. Use a Unix epoch number, ISO 8601, or RFC 2822 date.`, + "timestamp" + ); } /** @@ -110,9 +114,10 @@ function parseTimestamp(ts: string | undefined): number | undefined { */ export function buildEventFromFlags(flags: SendEventFlags): Event { const tags = parseKeyValuePairs(flags.tag); + // environ goes first so explicit --extra environ:val overrides it const extra: Record = { - ...parseKeyValuePairs(flags.extra), ...(flags["no-environ"] ? {} : { environ: process.env }), + ...parseKeyValuePairs(flags.extra), }; return { From 9956cafd170c5172ea4b142436cca7d4675752af Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:30:49 +0000 Subject: [PATCH 06/16] fix: remove false DSN auto-detect claim + reject Infinity timestamps - Remove undocumented DSN auto-detection claim from send-event.ts module comment and requireDsn error message (only --dsn and SENTRY_DSN are actually supported) - Use Number.isFinite instead of !Number.isNaN to reject Infinity and -Infinity as timestamp values --- src/commands/send-event.ts | 1 - src/lib/envelope/event-builder.ts | 2 +- src/lib/envelope/transport.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts index 9c6d01431..f05712443 100644 --- a/src/commands/send-event.ts +++ b/src/commands/send-event.ts @@ -5,7 +5,6 @@ * so no `sentry auth login` is required. The DSN can be provided via: * 1. --dsn flag * 2. SENTRY_DSN environment variable - * 3. Auto-detected from project source files / .env */ import type { DsnComponents, Event } from "@sentry/core"; diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index 4151590cc..99b59bac4 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -92,7 +92,7 @@ function parseTimestamp(ts: string | undefined): number | undefined { } // Unix numeric const num = Number(ts); - if (!Number.isNaN(num) && num > 0) { + if (Number.isFinite(num) && num > 0) { return num; } // ISO / RFC 2822 diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index b955f7d8e..4505eceb6 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -67,7 +67,7 @@ export function requireDsn(flags: DsnFlags, cwd: string): string { return dsn; } throw new ConfigError( - "No DSN found. Provide one via --dsn, SENTRY_DSN env var, or ensure your project has a Sentry DSN configured.", + "No DSN found. Provide one via --dsn or set the SENTRY_DSN environment variable.", "sentry send-event --dsn " ); } From db177f452a84c10a599d653a2439b79b9253ea35 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:57:29 +0000 Subject: [PATCH 07/16] fix: address self-review blocking items before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseTimestamp: remove && num > 0 guard — epoch-0 and negative timestamps are valid; the old guard silently corrupted them to year 2000 / year 3600 via Date.parse fallthrough - resolveDsn: trim whitespace from --dsn flag and SENTRY_DSN env var (leading space / trailing newline from shell caused confusing error) - transport test: use .toThrow(ValidationError) not bare .toThrow() - send.md: remove false claim that DSN auto-detects from project files - buildFilePayload + send-envelope: wrap Bun.file reads in try/catch, surface ENOENT and parse errors as ValidationError not raw stacks - Document that --message is ignored when file args are provided - Add tests: ENOENT → ValidationError, --raw without file → error, no files for send-envelope → ValidationError, DSN whitespace trim, whitespace trim for SENTRY_DSN env var --- docs/src/fragments/commands/send.md | 1 - src/commands/send-envelope.ts | 29 +++++++++++++++++++++++++---- src/commands/send-event.ts | 29 +++++++++++++++++++++++++++-- src/lib/envelope/event-builder.ts | 2 +- src/lib/envelope/transport.ts | 4 ++-- test/commands/send-envelope.test.ts | 18 ++++++++++++++++++ test/commands/send-event.test.ts | 20 ++++++++++++++++++++ test/lib/envelope/transport.test.ts | 15 +++++++++++++-- 8 files changed, 106 insertions(+), 12 deletions(-) diff --git a/docs/src/fragments/commands/send.md b/docs/src/fragments/commands/send.md index 16c09858e..e5d00d818 100644 --- a/docs/src/fragments/commands/send.md +++ b/docs/src/fragments/commands/send.md @@ -53,7 +53,6 @@ The DSN is resolved in priority order: 1. `--dsn ` flag (explicit) 2. `SENTRY_DSN` environment variable -3. Auto-detected from `.env` files and project source code ```bash # Explicit DSN diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index 5fa0f45f5..319367801 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -96,13 +96,34 @@ sentry send-envelope ./a.envelope ./b.envelope for (const file of files) { let body: string | Uint8Array; + let fileBytes: ArrayBuffer; + try { + fileBytes = await Bun.file(file).arrayBuffer(); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError(`File not found: ${file}`, "path"); + } + throw new ValidationError( + `Cannot read file ${file}: ${(err as Error).message}`, + "path" + ); + } + if (flags.raw) { - body = new Uint8Array(await Bun.file(file).arrayBuffer()); + body = new Uint8Array(fileBytes); } else { - const text = await Bun.file(file).text(); + const text = new TextDecoder().decode(fileBytes); // Parse to validate, then re-serialize to normalize - const envelope = parseEnvelope(text); - body = serializeEnvelope(envelope); + try { + const envelope = parseEnvelope(text); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to parse envelope from ${file}: ${(err as Error).message}`, + "path" + ); + } } await sendEnvelopeRequest(dsn, body); diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts index f05712443..bd6bdc84a 100644 --- a/src/commands/send-event.ts +++ b/src/commands/send-event.ts @@ -43,8 +43,22 @@ async function buildFilePayload( raw: boolean, dsnComponents: DsnComponents ): Promise<{ body: string | Uint8Array; eventId: string }> { + let fileBytes: ArrayBuffer; + try { + fileBytes = await Bun.file(file).arrayBuffer(); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError(`File not found: ${file}`, "path"); + } + throw new ValidationError( + `Cannot read file ${file}: ${(err as Error).message}`, + "path" + ); + } + if (raw) { - const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()); + const bytes = new Uint8Array(fileBytes); // Best-effort: extract event_id from the first line (envelope header JSON). // Decode the already-read bytes instead of re-reading the file. let eventId = ""; @@ -58,7 +72,15 @@ async function buildFilePayload( return { body: bytes, eventId }; } - const event = (await Bun.file(file).json()) as Event; + let event: Event; + try { + event = JSON.parse(new TextDecoder().decode(fileBytes)) as Event; + } catch (err) { + throw new ValidationError( + `Failed to parse JSON from ${file}: ${(err as Error).message}`, + "path" + ); + } const envelope = createEventEnvelope(event, dsnComponents); return { body: serializeEnvelope(envelope), eventId: event.event_id ?? "" }; } @@ -87,6 +109,9 @@ sentry send-event ./event.json Use --raw to skip JSON parsing and send the file bytes directly to the ingest endpoint. +When file arguments are provided, flags like -m/--message are ignored — the event is +built entirely from the file contents. + ## Common flags | Flag | Description | diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index 99b59bac4..447cda5a7 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -92,7 +92,7 @@ function parseTimestamp(ts: string | undefined): number | undefined { } // Unix numeric const num = Number(ts); - if (Number.isFinite(num) && num > 0) { + if (Number.isFinite(num)) { return num; } // ISO / RFC 2822 diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index 4505eceb6..6b7abd203 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -46,11 +46,11 @@ export function buildEnvelopeUrl(dsn: string): string { */ export function resolveDsn(flags: DsnFlags, _cwd: string): string | undefined { if (flags.dsn) { - return flags.dsn; + return flags.dsn.trim(); } const envDsn = process.env.SENTRY_DSN; if (envDsn) { - return envDsn; + return envDsn.trim(); } return; } diff --git a/test/commands/send-envelope.test.ts b/test/commands/send-envelope.test.ts index 8ebd07a46..4b24addeb 100644 --- a/test/commands/send-envelope.test.ts +++ b/test/commands/send-envelope.test.ts @@ -125,4 +125,22 @@ describe("sendEnvelopeCommand.func()", () => { expect(sendSpy).toHaveBeenCalledTimes(2); }); + + test("nonexistent file throws ValidationError (not raw stack trace)", async () => { + const { ctx } = makeContext(); + const { ValidationError } = await import("../../src/lib/errors.js"); + await expect( + func.call(ctx, { dsn: SAAS_DSN }, "/nonexistent/missing.envelope") + ).rejects.toBeInstanceOf(ValidationError); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + test("no files throws ValidationError", async () => { + const { ctx } = makeContext(); + const { ValidationError } = await import("../../src/lib/errors.js"); + await expect(func.call(ctx, { dsn: SAAS_DSN })).rejects.toBeInstanceOf( + ValidationError + ); + expect(sendSpy).not.toHaveBeenCalled(); + }); }); diff --git a/test/commands/send-event.test.ts b/test/commands/send-event.test.ts index e8084bfe4..a217e2cfe 100644 --- a/test/commands/send-event.test.ts +++ b/test/commands/send-event.test.ts @@ -123,4 +123,24 @@ describe("sendEventCommand.func()", () => { expect(parsed).toHaveProperty("eventId"); expect(parsed.eventId).toMatch(/^[0-9a-f]{32}$/); }); + + test("nonexistent file throws ValidationError (not raw stack trace)", async () => { + const { ctx } = makeContext(); + const { ValidationError } = await import("../../src/lib/errors.js"); + await expect( + func.call( + ctx, + { dsn: SAAS_DSN, "no-environ": true }, + "/nonexistent/missing.json" + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--raw requires file arguments", async () => { + const { ctx } = makeContext(); + const { ValidationError } = await import("../../src/lib/errors.js"); + await expect( + func.call(ctx, { dsn: SAAS_DSN, raw: true, "no-environ": true }) + ).rejects.toBeInstanceOf(ValidationError); + }); }); diff --git a/test/lib/envelope/transport.test.ts b/test/lib/envelope/transport.test.ts index 8f32d7cb4..07ce05f4e 100644 --- a/test/lib/envelope/transport.test.ts +++ b/test/lib/envelope/transport.test.ts @@ -15,7 +15,7 @@ import { resolveDsn, sendEnvelopeRequest, } from "../../../src/lib/envelope/transport.js"; -import { ApiError } from "../../../src/lib/errors.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; const SAAS_DSN = "https://abc123@o1169445.ingest.us.sentry.io/4505229541441536"; const SELF_HOSTED_DSN = "https://pubkey99@sentry.mycompany.com/7"; @@ -39,7 +39,7 @@ describe("buildEnvelopeUrl", () => { }); test("invalid DSN → throws ValidationError", () => { - expect(() => buildEnvelopeUrl("not-a-dsn")).toThrow(); + expect(() => buildEnvelopeUrl("not-a-dsn")).toThrow(ValidationError); }); }); @@ -73,6 +73,17 @@ describe("resolveDsn", () => { const result = resolveDsn({ dsn: undefined }, "/tmp"); expect(result).toBeUndefined(); }); + + test("trims whitespace from --dsn flag", () => { + const result = resolveDsn({ dsn: ` ${SAAS_DSN} ` }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); + + test("trims whitespace from SENTRY_DSN env var", () => { + process.env.SENTRY_DSN = `\n${SAAS_DSN}\n`; + const result = resolveDsn({ dsn: undefined }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); }); // ── sendEnvelopeRequest ─────────────────────────────────────────── From 8cf5df33a62df5c6936548b685897d1a51049b09 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 18:36:20 +0000 Subject: [PATCH 08/16] refactor: extract readFileBytes helper + wrap makeDsn and envelope creation in try/catch - Centralise ENOENT/IO error handling into shared readFileBytes() in transport.ts - Removes duplicated file-reading error block from both send-event.ts and send-envelope.ts - Wrap makeDsn() call in try/catch to guard against future SDK internal throws - Wrap createEventEnvelope()+serializeEnvelope() in try/catch with descriptive ValidationError Addresses Sentry Seer findings (medium) and Cursor Bugbot finding (low) on PR #921. --- src/commands/send-envelope.ts | 24 +++++++------------ src/commands/send-event.ts | 45 +++++++++++++++++++---------------- src/lib/envelope/transport.ts | 21 ++++++++++++++++ 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index 319367801..9bc22a68d 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -13,7 +13,11 @@ import { parseEnvelope, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; +import { + readFileBytes, + requireDsn, + sendEnvelopeRequest, +} from "../lib/envelope/transport.js"; import { ValidationError } from "../lib/errors.js"; import { CommandOutput } from "../lib/formatters/output.js"; @@ -96,24 +100,12 @@ sentry send-envelope ./a.envelope ./b.envelope for (const file of files) { let body: string | Uint8Array; - let fileBytes: ArrayBuffer; - try { - fileBytes = await Bun.file(file).arrayBuffer(); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new ValidationError(`File not found: ${file}`, "path"); - } - throw new ValidationError( - `Cannot read file ${file}: ${(err as Error).message}`, - "path" - ); - } + const bytes = await readFileBytes(file); if (flags.raw) { - body = new Uint8Array(fileBytes); + body = bytes; } else { - const text = new TextDecoder().decode(fileBytes); + const text = new TextDecoder().decode(bytes); // Parse to validate, then re-serialize to normalize try { const envelope = parseEnvelope(text); diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts index bd6bdc84a..e8b960a36 100644 --- a/src/commands/send-event.ts +++ b/src/commands/send-event.ts @@ -15,7 +15,11 @@ import { buildEventFromFlags, type SendEventFlags, } from "../lib/envelope/event-builder.js"; -import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; +import { + readFileBytes, + requireDsn, + sendEnvelopeRequest, +} from "../lib/envelope/transport.js"; import { ConfigError, ValidationError } from "../lib/errors.js"; import { CommandOutput } from "../lib/formatters/output.js"; @@ -43,24 +47,10 @@ async function buildFilePayload( raw: boolean, dsnComponents: DsnComponents ): Promise<{ body: string | Uint8Array; eventId: string }> { - let fileBytes: ArrayBuffer; - try { - fileBytes = await Bun.file(file).arrayBuffer(); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new ValidationError(`File not found: ${file}`, "path"); - } - throw new ValidationError( - `Cannot read file ${file}: ${(err as Error).message}`, - "path" - ); - } + const bytes = await readFileBytes(file); if (raw) { - const bytes = new Uint8Array(fileBytes); // Best-effort: extract event_id from the first line (envelope header JSON). - // Decode the already-read bytes instead of re-reading the file. let eventId = ""; try { const firstLine = new TextDecoder().decode(bytes).split("\n")[0] ?? "{}"; @@ -74,15 +64,25 @@ async function buildFilePayload( let event: Event; try { - event = JSON.parse(new TextDecoder().decode(fileBytes)) as Event; + event = JSON.parse(new TextDecoder().decode(bytes)) as Event; } catch (err) { throw new ValidationError( `Failed to parse JSON from ${file}: ${(err as Error).message}`, "path" ); } - const envelope = createEventEnvelope(event, dsnComponents); - return { body: serializeEnvelope(envelope), eventId: event.event_id ?? "" }; + + let body: string | Uint8Array; + try { + const envelope = createEventEnvelope(event, dsnComponents); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to create envelope from ${file}: ${(err as Error).message}`, + "path" + ); + } + return { body, eventId: event.event_id ?? "" }; } export const sendEventCommand = buildCommand({ @@ -264,7 +264,12 @@ built entirely from the file contents. ...files: string[] ) { const dsn = requireDsn(flags, this.cwd); - const dsnComponents = makeDsn(dsn); + let dsnComponents: ReturnType; + try { + dsnComponents = makeDsn(dsn); + } catch { + dsnComponents = undefined; + } if (!dsnComponents) { throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); } diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index 6b7abd203..e64c05fc9 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -72,6 +72,27 @@ export function requireDsn(flags: DsnFlags, cwd: string): string { ); } +/** + * Read a file's bytes, throwing a clean ValidationError on ENOENT or I/O errors. + * + * Centralises the duplicated error-handling pattern used by both + * `send-event` and `send-envelope`. + */ +export async function readFileBytes(file: string): Promise { + try { + return new Uint8Array(await Bun.file(file).arrayBuffer()); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError(`File not found: ${file}`, "path"); + } + throw new ValidationError( + `Cannot read file ${file}: ${(err as Error).message}`, + "path" + ); + } +} + /** * POST a serialized envelope to Sentry's ingest endpoint using DSN auth. * From da503bb6558fbaf0047c1d2ca0b0f9fa07ea54a4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 18:46:33 +0000 Subject: [PATCH 09/16] fix: wrap inline event createEventEnvelope/serializeEnvelope in try/catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the file-based path's error handling — catches any internal SDK errors and re-throws as ValidationError. --- src/commands/send-event.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts index e8b960a36..41ec1f2a2 100644 --- a/src/commands/send-event.ts +++ b/src/commands/send-event.ts @@ -298,8 +298,17 @@ built entirely from the file contents. ); } const event = buildEventFromFlags(flags); - const envelope = createEventEnvelope(event, dsnComponents); - await sendEnvelopeRequest(dsn, serializeEnvelope(envelope)); + let body: string | Uint8Array; + try { + const envelope = createEventEnvelope(event, dsnComponents); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to create event envelope: ${(err as Error).message}`, + "event" + ); + } + await sendEnvelopeRequest(dsn, body); yield new CommandOutput({ eventId: event.event_id ?? "", }); From 254e593ef650e33c1fd9111bb0c22c3bb85fd181 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 18:56:29 +0000 Subject: [PATCH 10/16] fix: validate DSN before file I/O in send-envelope Calling buildEnvelopeUrl(dsn) before the file read loop ensures invalid DSNs are caught upfront rather than after unnecessary I/O, consistent with send-event's behavior. --- src/commands/send-envelope.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index 9bc22a68d..a7f1833c1 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -14,6 +14,7 @@ import { parseEnvelope, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; import { + buildEnvelopeUrl, readFileBytes, requireDsn, sendEnvelopeRequest, @@ -96,6 +97,8 @@ sentry send-envelope ./a.envelope ./b.envelope } const dsn = requireDsn(flags, this.cwd); + // Validate the DSN fully before doing any file I/O + buildEnvelopeUrl(dsn); for (const file of files) { let body: string | Uint8Array; From b6f5babffa1fef3b4f0afec2a8a20761596cb303 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 19:06:09 +0000 Subject: [PATCH 11/16] fix: use 'sentry-cli' as client name, not 'sentry-cli/dev' getEnvelopeEndpointWithUrlEncodedAuth appends / internally, so passing 'sentry-cli/dev' produced the malformed identifier sentry_client=sentry-cli/dev/dev on every envelope request. Now passes bare name 'sentry-cli' and lets the SDK append '/dev'. --- src/lib/envelope/transport.ts | 3 ++- test/lib/envelope/transport.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index e64c05fc9..d6beb2ca9 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -14,7 +14,8 @@ import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn } from "@sentry/core"; import { ApiError, ConfigError, ValidationError } from "../errors.js"; -const SENTRY_CLIENT = "sentry-cli/dev"; +/** Client name passed to getEnvelopeEndpointWithUrlEncodedAuth, which appends / internally. */ +const SENTRY_CLIENT = "sentry-cli"; /** Flags subset relevant to DSN resolution. */ export type DsnFlags = { diff --git a/test/lib/envelope/transport.test.ts b/test/lib/envelope/transport.test.ts index 07ce05f4e..a89358e55 100644 --- a/test/lib/envelope/transport.test.ts +++ b/test/lib/envelope/transport.test.ts @@ -41,6 +41,14 @@ describe("buildEnvelopeUrl", () => { test("invalid DSN → throws ValidationError", () => { expect(() => buildEnvelopeUrl("not-a-dsn")).toThrow(ValidationError); }); + + test("sentry_client does not have doubled version suffix", () => { + // SENTRY_CLIENT must be the bare name ('sentry-cli'), not 'sentry-cli/dev', + // because getEnvelopeEndpointWithUrlEncodedAuth appends / internally. + const url = buildEnvelopeUrl(SAAS_DSN); + expect(url).not.toContain("sentry-cli%2Fdev%2Fdev"); + expect(decodeURIComponent(url)).toContain("sentry_client=sentry-cli/"); + }); }); // ── resolveDsn ──────────────────────────────────────────────────── From 7c28f8080b35dfd92703a89095ca78adcccdb0e2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 19:15:30 +0000 Subject: [PATCH 12/16] fix: wrap makeDsn in try/catch in buildEnvelopeUrl + fix generic error message - makeDsn may throw SentryError internally; wrap in try/catch for safety - requireDsn error message now uses canonical 'sentry send event' instead of hardcoded 'sentry send-event' (wrong for send-envelope callers) --- src/lib/envelope/transport.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index d6beb2ca9..daa2e3d1f 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -29,7 +29,13 @@ export type DsnFlags = { * Throws ValidationError on an unparseable DSN. */ export function buildEnvelopeUrl(dsn: string): string { - const dsnComponents = makeDsn(dsn); + let dsnComponents; + try { + dsnComponents = makeDsn(dsn); + } catch { + // makeDsn may throw a SentryError on malformed input + dsnComponents = undefined; + } if (!dsnComponents) { throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); } @@ -69,7 +75,7 @@ export function requireDsn(flags: DsnFlags, cwd: string): string { } throw new ConfigError( "No DSN found. Provide one via --dsn or set the SENTRY_DSN environment variable.", - "sentry send-event --dsn " + "sentry send event --dsn " ); } From 2fae0812c37a29215d38274f7a54e84b4956d277 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 19:26:42 +0000 Subject: [PATCH 13/16] fix: add explicit type annotation to avoid noImplicitAnyLet lint error --- src/lib/envelope/transport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index daa2e3d1f..44b488024 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -29,7 +29,7 @@ export type DsnFlags = { * Throws ValidationError on an unparseable DSN. */ export function buildEnvelopeUrl(dsn: string): string { - let dsnComponents; + let dsnComponents: ReturnType; try { dsnComponents = makeDsn(dsn); } catch { From 9f45678d12afb808fe83e545dce489be4670b5e4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 6 May 2026 12:42:53 +0000 Subject: [PATCH 14/16] refactor: move send-event to event send, deprecate send-envelope - Canonical command is now 'sentry event send' under the event route - send-envelope is now a deprecation shim suggesting 'sentry event send --raw' - send-event remains as a hidden backward-compat alias - Removed the 'send' route group entirely - Updated docs, tests, and all references --- AGENTS.md | 90 +---- docs/src/content/docs/contributing.md | 3 +- docs/src/fragments/commands/event.md | 58 ++++ docs/src/fragments/commands/send.md | 73 ---- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 12 +- .../skills/sentry-cli/references/event.md | 60 +++- .../skills/sentry-cli/references/send.md | 91 ----- src/app.ts | 2 - src/commands/event/index.ts | 9 +- src/commands/event/send.ts | 318 ++++++++++++++++++ src/commands/send-envelope.ts | 112 ++---- src/commands/send-event.ts | 318 +----------------- src/commands/send/index.ts | 24 -- src/lib/command-suggestions.ts | 2 +- src/lib/command.ts | 2 +- src/lib/envelope/event-builder.ts | 6 +- src/lib/envelope/transport.ts | 6 +- .../send.test.ts} | 18 +- test/commands/send-envelope.test.ts | 156 ++------- 19 files changed, 540 insertions(+), 820 deletions(-) delete mode 100644 docs/src/fragments/commands/send.md delete mode 100644 plugins/sentry-cli/skills/sentry-cli/references/send.md create mode 100644 src/commands/event/send.ts delete mode 100644 src/commands/send/index.ts rename test/commands/{send-event.test.ts => event/send.test.ts} (86%) diff --git a/AGENTS.md b/AGENTS.md index a36c3ec76..43221a2cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1068,89 +1068,31 @@ mock.module("./some-module", () => ({ ### Architecture - -* **Issue resolve --in grammar: release + @next + @commit sentinels**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\\`→\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\@\\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. + +* **env-registry.ts drives --help env var section + docs**: \`src/lib/env-registry.ts\` (\`ENV\_VAR\_REGISTRY\`) is the single source for all env vars the CLI honors. Entries have \`{name, description, example?, defaultValue?, installOnly?, topLevel?, briefDescription?}\`. \`topLevel: true\` + \`briefDescription\` surfaces in \`sentry --help\` Environment Variables section (via \`formatEnvVarsSection()\` in \`help.ts\`) and in \`sentry help --json\` as \`envVars\` array on the full-tree envelope. Docs generator consumes the full registry for \`configuration.md\`. When adding a new env var, add it here with \`installOnly: true\` if install-script-only. Reserve \`topLevel: true\` for core-path vars only (auth, targeting, URL, key display/logging). - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. + +* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, etc.) live in \`hex-id.ts\`. - -* **repo\_cache SQLite table for offline Sentry repo lookups**: Schema v14 adds \`repo\_cache\` table in \`src/lib/db/schema.ts\` + helpers in \`src/lib/db/repo-cache.ts\` (7-day TTL). \`listAllRepositories(org)\` in \`src/lib/api/repositories.ts\` paginates through \`listRepositoriesPaginated\` using \`API\_MAX\_PER\_PAGE\` and \`MAX\_PAGINATION\_PAGES\` — never use the unpaginated \`listRepositories\` for cache-backed lookups (silently caps at ~25). \`listRepositoriesCached(org)\` wraps it with cache-first lookup and a try/catch around \`setCachedRepos\` so read-only databases (macOS \`sudo brew install\`) don't crash commands whose API fetch already succeeded. Used by \`@commit\` resolver to match git origin \`owner/repo\` against Sentry repo \`externalSlug\` or \`name\`. - - -* **Response cache hit invisibility — synthetic Response carries no marker**: Response cache hit invisibility — synthetic Response from \`getCachedResponse()\` in \`src/lib/response-cache.ts\` is indistinguishable from network. Solved via module-level \`lastCacheHitAgeMs\`: set on hit, cleared at top of \`authenticatedFetch()\` per-call (single-process CLI = race-free). \`src/lib/cache-hint.ts\` provides \`formatCacheHint()\` (\`"cached · 3m ago · use -f to refresh"\`) and \`appendCacheHint(existingHint)\` (joins with \` | \`). Wired in \`buildCommand\` (\`src/lib/command.ts\`): \`appendCacheHint(returned?.hint)\` runs only when generator returns a \`CommandReturn\` — bare \`return;\` paths (e.g. \`--web\`) skip the hint. Same chokepoint can host future cross-cutting hint decorators. Test-only \`\_setLastCacheHitAgeForTesting(ms)\` exposes state. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. - -### Decision - - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. + +* **Three Sentry APIs for span custom attributes with different capabilities**: \*\*Three Sentry span APIs with different capabilities\*\*: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\&field=X\` — list/search. Critical: \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\`/\`-F\` list, not \`Object.keys()\`. See \`orderFieldNames()\` in \`explore.ts\`. ### Gotcha - -* **--json schema stability: collapse=organization drops nested org fields**: --json schema + response cache gotchas: (1) \`?collapse=organization\` shrinks \`organization\` to \`{id, slug}\` — silent --json regression. \`jsonTransform\` re-hydrates \`organization.name\` via \`resolveOrgDisplayName\` against \`org\_regions\` cache. (2) \`buildCacheKey()\` normalizes URL with sorted query params, so \`invalidateCachedResponse(baseUrl)\` misses entries with query suffixes. Use \`invalidateCachedResponsesMatching(prefix)\` (raw \`startsWith()\`); \`buildApiUrl()\` always emits trailing slash → safe prefix. (3) When \`jsonTransform\` is set, \`jsonExclude\` and \`filterFields\` are NOT applied — transform must call \`filterFields(result, fields)\` and omit excluded keys itself. - - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined → \`prepareHeaders\` creates empty headers, stripping Content-Type on Node.js (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access Link header for pagination. \`per\_page\` not in SDK types — cast query at runtime. SDK returns \`data={}\` (not \`\[]\`) for empty/204/missing Content-Type responses — always guard with \`Array.isArray(data)\` before \`.map()\`. Self-hosted instances behind reverse proxies commonly trigger this. + +* **api.ts: plain Error throws inside func() bypass CliError handling**: \*\*api.ts: plain Error throws inside func() bypass CliError handling\*\*: \`src/commands/api.ts\` throws plain \`new Error(...)\` in validation paths called from \`func()\` — this bypasses \`app.ts\`'s \`instanceof CliError\` check, causing user to see stack traces AND Sentry bug reports. Fix: use \`ValidationError\` for user-input errors inside \`func()\`. Plain \`Error\` is only OK in Stricli \`parse:\` callbacks where Stricli catches them. - -* **API tests must use useTestConfigDir to isolate disk response cache**: API tests that mock \`globalThis.fetch\` MUST call \`useTestConfigDir()\` from \`test/helpers.ts\` + \`setAuthToken()\`. The \`authenticatedFetch\` singleton in \`src/lib/sentry-client.ts\` checks a filesystem-based response cache (\`~/.sentry/cache/responses/\`, see \`response-cache.ts\`) BEFORE calling fetch. Without per-test config dirs, test N's API response gets cached to disk and served to test N+1 — fetch mock never fires, assertion sees stale data. TTL tiers in \`classifyUrl()\`: stable=5min (default), volatile=60s (issues, logs), immutable=24h (events/traces by ID). Symptom: test expects fresh mock value, receives prior test's value. Reference: \`test/lib/api/issues.test.ts\` (correct pattern), \`test/lib/api/repositories.test.ts\` regression fixed by adding \`useTestConfigDir("repo-cache-")\` + \`setAuthToken("test-token", 3600, "test-refresh")\` in beforeEach. + +* **Biome lint differs between local lint:fix and CI lint**: \*\*Biome lint differs between local lint:fix and CI lint\*\*: \`lint:fix\` hides CI issues; always run \`bun run lint\` before pushing. Key gotchas: (1) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (2) \`noIncrementDecrement\` — use \`i += 1\`. (3) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers, don't biome-ignore. (4) \`noUselessUndefined\` then \`noEmptyBlockStatements\` — use \`function noop() {}\`. (5) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`. - -* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: Biome lint traps: (1) \`noUselessUndefined\` rejects \`() => undefined\` AND \`noEmptyBlockStatements\` rejects \`() => {}\` — use top-level \`function noop(): void {}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15. (3) \`expect(() => fn()).toThrow(X)\` must be one line. (4) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (5) Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`, \`useSimplifiedLogicExpression\`, \`noShadow\`. Namespace imports forbidden. (6) \`useYield\` fires on \`async \*func()\` with statements but not empty bodies — only add \`biome-ignore\` to generators with statements. \`lint:fix\` differs from CI \`lint\`: auto-fix hides \`noPrecisionLoss\` on >2^53 literals, \`noIncrementDecrement\`, import ordering. Always \`bun run lint\` before pushing. - - -* **Bun --isolate coverage inflates LF count for files with verbose comments/JSDoc**: Bun --isolate coverage inflates LF count: under \`bun test --isolate --parallel\` (CI's \`test:unit\`), Bun's coverage instrumentation counts comments, blank lines, type annotations, and closing braces as 'executable'. E.g. \`zstd-transport.ts\` LF=165 locally → 210 under --isolate, dropping coverage 99%→78%. Codecov sees inflated number. Workaround: trim verbose inline comments inside function bodies (move rationale to JSDoc above function or module-level doc). Statement coverage stays 100% — 'missing' lines are non-executable. - - -* **Bun 1.3.11 tty.ReadStream leaks libuv handle — process.stdin.unref is undefined**: Bun 1.3.11 macOS TTY bug: \`process.stdin\` via kqueue \`EVFILT\_READ\` on reopened non-session-leader TTY fd fails to deliver keystrokes when fd 0 inherited via \`exec bin \ -* **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, Bun's fetch dispatcher keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`. - - -* **Multi-region fan-out: distinguish all-403 from empty orgs with hasSuccessfulRegion flag**: In \`listOrganizationsUncached\` (\`src/lib/api/organizations.ts\`), \`Promise.allSettled\` collects multi-region results. Don't use \`flatResults.length === 0\` to detect all-regions-failed — a region returning 200 OK with zero orgs pushes nothing into \`flatResults\`. Track a \`hasSuccessfulRegion\` boolean on any \`"fulfilled"\` settlement. Only re-throw 403 \`ApiError\` when \`!hasSuccessfulRegion && lastScopeError\`. + +* **buildCommand wrapper: loader() returns wrapped async fn, not the generator**: \*\*buildCommand wrapper: loader() returns wrapped async fn, not generator\*\*: \`cmd.loader()\` returns the wrapped async fn, not \`async \*func()\`. Wrapper iterates generator internally and writes to \`ctx.stdout\`. Tests: \`await func.call(ctx, flags, ...args)\` like a promise — don't iterate. Auth guard runs first; \`test/preload.ts:100\` sets fake \`SENTRY\_AUTH\_TOKEN\`. Tests must save/restore only env vars they mutate. ### Pattern - -* **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race (CLI-1D3): \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600 = 3.1s). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Windows + Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path → opaque \`Executable not found in $PATH\` from \`Bun.spawn\`. Safety net \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` detects both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free delayed-write tests: writer must POLL until bad state exists THEN overwrite. - - -* **Cross-compile sentry-cli with patched Bun: drop compile.target to use selfExePath**: Cross-compile sentry-cli with patched Bun: \`Bun.build({compile})\` downloads stock Bun from npm when \`compile.target\` is set. Workaround in \`script/build.ts\`: omit \`target\` entirely so Bun hits \`isDefault()\` branch → uses \`selfExePath()\` = the running Bun as embed runtime. Only works when host OS/arch matches desired output. Escape hatch: place file at \`$CWD/bun-\-\-v\\` (e.g. \`bun-darwin-arm64-v1.3.13\`) picked up via \`bun.FD.cwd().existsAt(version\_str)\` in \`src/compile\_target.zig:exePath\`. Build also requires \`SENTRY\_CLIENT\_ID\` env var. - - -* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (src/commands/issue/merge.ts): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\`, not raw input (users pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, \`123\`). Throw ValidationError if \`new Set(ids).size < 2\`. (2) Reject undefined orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`; filtering them out lets mixed-org merges slip through. (3) Pass \`--into\` through \`resolveIssue()\` for alias/org-qualified parity; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs. Empty results return 204. - - -* **findProjectsByPattern as fuzzy fallback for exact slug misses**: When \`findProjectsBySlug\` returns empty (no exact match), use \`findProjectsByPattern\` as a fallback to suggest similar projects. \`findProjectsByPattern\` does bidirectional word-boundary matching (\`matchesWordBoundary\`) against all projects in all orgs — the same logic used for directory name inference. In the \`project-search\` handler, call it after the exact miss, format matches as \`\/\\` suggestions in the \`ResolutionError\`. This avoids a dead-end error for typos like 'patagonai' when 'patagon-ai' exists. Note: \`findProjectsByPattern\` makes additional API calls (lists all projects per org), so only call it on the failure path. - - -* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. - - -* **Hidden --org/--project compat flags via mergeGlobalFlags**: Hidden global \`--org\`/\`--project\` flags accept old \`sentry-cli\` syntax. Defined in \`GLOBAL\_FLAGS\` (global-flags.ts) so argv-hoist relocates them. \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes (skip if command owns the flag — e.g. \`release create --project -p\`) and returns \`stripKeys\` set used by \`cleanRawFlags\`. \`applyOrgProjectFlags()\` writes values to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` via \`getEnv()\` before auth guard, overwriting existing env vars (explicit CLI > env var). Resolution chain in resolve-target.ts picks them up at priority #2. No short aliases (\`-p\` conflicts). The helper extraction was needed to keep \`buildCommand\` under Biome's cognitive complexity limit of 15. - - -* **Preserve ApiError type so classifySilenced can silence 4xx errors**: Preserve ApiError type for classifySilenced: \`classifySilenced\` (src/lib/error-reporting.ts) only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\` and causes 403s to be captured. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\` with terse message (\`ApiError.format()\` appends detail/endpoint). \`ValidationError\` without \`field\` collapses unfielded errors into one fingerprint; always pass \`field\`. Fingerprint rule changes don't retroactively re-fingerprint — manually merge new groups into canonical old parents. \`ApiError\` rule keys by \`api\_status + command\`. - - -* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via bun patch: \`patchedDependencies\` in \`package.json\` strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Non-light root of \`@sentry/node-core\` pulls uninstalled \`@opentelemetry/instrumentation\` — \*\*always import from \`@sentry/node-core/light\`\*\* (subpaths: \`.\`, \`./light\`, \`./light/otlp\`, \`./init\`, \`./loader\`, \`./import\`). No supported import for \`HttpsProxyAgent\`. Bumping SDK: remove old patches, \`rm -rf ~/.bun/install/cache/@sentry\`, \`bun install\`, \`bun patch @sentry/core\`, edit, \`bun patch --commit\`; repeat for node-core. Preserved: \`\_INTERNAL\_safeUnref\`, \`\_INTERNAL\_safeDateNow\`, \`nodeRuntimeMetricsIntegration\`. Before stripping any core export, grep \`node-core/build/{cjs,esm}/light/sdk.js\` for runtime usage (e.g. \`spanStreamingIntegration\` when \`traceLifecycle === 'stream'\`). Remove \`.bun-tag-\*\` hunks from generated patches. Manual \`git diff\` patches fail. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. \`hasPreviousPage\` checks \`page\_index > 0\`. \`paginationHint()\` builds nav strings. All list commands use this. Critical: \`resolveCursor()\` must be called inside \`org-all\` override closures, not before \`dispatchOrgScopedList\`. - - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). - - -* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: (1) \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`loader()\` return type union causes \`.call()\` LSP false-positives that pass \`tsc --noEmit\`. (2) When API functions are renamed, update both spy target AND mock return shape. (3) \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. (4) Bun \`mockFetch()\` replaces \`globalThis.fetch\` — use one unified mock dispatching by URL. (5) \`mock.module()\` pollutes module registry for ALL subsequent files — put in \`test/isolated/\` and run via \`test:isolated\`. (6) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - -### Preference + +* **Merging mock.module() test files with static-import counterparts**: \*\*Bun test mocking traps\*\*: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — pre-existing static imports won't re-bind. (3) Destructured imports capture binding at load. (4) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. - -* **Bot review triage: distinguish real bugs from SDK-mirroring false positives**: When Sentry Seer or Cursor Bugbot flags 'unusual' code that intentionally mirrors upstream SDK behavior (e.g., \`http\_proxy\` as last-resort fallback for HTTPS URLs — deliberate in \`@sentry/node-core\` \`applyNoProxyOption\`), decline with a written rationale referencing the SDK source rather than silently changing behavior. Removing the mirror creates a divergence where users get different proxy semantics from our transport vs. the SDK default. BYK's pattern: verify against \`node\_modules/@sentry/node-core/build/esm/transports/http.js\`, post a reply explaining the precedent, and resolve the thread. Real bugs (uppercase env var support, whitespace trimming, wildcard handling) get fixed; SDK-mirroring 'bugs' get explained and dismissed. + +* **URL-encoded paren assertions: decode before contains-check**: \*\*URL-encoded paren assertions in tests\*\*: Aggregate field names like \`count()\` become \`count%28%29\` via \`encodeURIComponent\` — use \`expect(decodeURIComponent(url)).toContain("field=count()")\`. Sentry pagination Link header format: \`\; rel="next"; cursor="0:50:0"\` — cursor is in a separate attribute, NOT in URL query. Use \`parseSentryLinkHeader()\` from \`src/lib/api/infrastructure.ts\` to extract. diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 6b8df15f0..6964c9021 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -54,7 +54,7 @@ cli/ │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete -│ │ ├── event/ # view, list +│ │ ├── event/ # view, list, send │ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge │ │ ├── log/ # list, view │ │ ├── org/ # list, view @@ -62,7 +62,6 @@ cli/ │ │ ├── release/ # list, view, create, finalize, delete, deploy, deploys, set-commits, propose-version │ │ ├── replay/ # list, view │ │ ├── repo/ # list -│ │ ├── send/ # event, envelope │ │ ├── sourcemap/ # inject, upload │ │ ├── span/ # list, view │ │ ├── team/ # list diff --git a/docs/src/fragments/commands/event.md b/docs/src/fragments/commands/event.md index bad1c71ce..92b69704a 100644 --- a/docs/src/fragments/commands/event.md +++ b/docs/src/fragments/commands/event.md @@ -1,7 +1,57 @@ + ## Examples +### Sending Events + +```bash +# Send an error event (default level) +sentry event send -m "Something went wrong" + +# Specify level, release, and environment +sentry event send -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry event send -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry event send -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry event send -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} +``` + +### Send from a JSON file + +```bash +# Send a serialized Sentry Event object +sentry event send ./crash.json + +# Send without re-parsing (raw mode — also supports pre-built envelopes) +sentry event send --raw ./crash.json +sentry event send --raw ./captured.envelope +``` + +### DSN authentication + +`sentry event send` authenticates via a **DSN** rather than a user token. +No `sentry auth login` is required. + +The DSN is resolved in priority order: + +1. `--dsn ` flag (explicit) +2. `SENTRY_DSN` environment variable + +```bash +# Explicit DSN +sentry event send -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry event send -m "Test" +``` + ### Listing Events ```bash @@ -68,3 +118,11 @@ Event IDs can be found: 1. In the Sentry UI when viewing an issue's events 2. In the output of `sentry issue view` commands 3. In error reports sent to Sentry (as `event_id`) + +## Backward compatibility + +The old sentry-cli top-level command is available as a hidden alias: + +```bash +sentry send-event # same as: sentry event send +``` diff --git a/docs/src/fragments/commands/send.md b/docs/src/fragments/commands/send.md deleted file mode 100644 index e5d00d818..000000000 --- a/docs/src/fragments/commands/send.md +++ /dev/null @@ -1,73 +0,0 @@ - - -## Examples - -### Send an event from flags - -```bash -# Send an error event (default level) -sentry send event -m "Something went wrong" - -# Specify level, release, and environment -sentry send event -m "Deploy check" -l info -r 1.0.0 -E production - -# Add tags and extra data -sentry send event -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 - -# Set user context -sentry send event -m "Login error" --user id:42 --user email:alice@example.com - -# Custom fingerprint to group related events together -sentry send event -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} -``` - -### Send an event from a JSON file - -```bash -# Send a serialized Sentry Event object -sentry send event ./crash.json - -# Send without re-parsing (raw mode) -sentry send event --raw ./crash.json -``` - -### Send a pre-built envelope - -```bash -# Send a captured Sentry envelope file -sentry send envelope ./captured.envelope - -# Send without validation (raw mode) -sentry send envelope --raw ./binary.envelope - -# Send multiple envelope files -sentry send envelope ./a.envelope ./b.envelope -``` - -## DSN authentication - -`sentry send` commands authenticate via a **DSN** rather than a user token. -No `sentry auth login` is required. - -The DSN is resolved in priority order: - -1. `--dsn ` flag (explicit) -2. `SENTRY_DSN` environment variable - -```bash -# Explicit DSN -sentry send event -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" - -# Via environment variable -export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" -sentry send event -m "Test" -``` - -## Backward compatibility - -The old sentry-cli top-level commands are available as hidden aliases: - -```bash -sentry send-event # same as: sentry send event -sentry send-envelope # same as: sentry send envelope -``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 93e005066..453af3718 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -323,10 +323,11 @@ Manage Sentry issues ### Event -View and list Sentry events +View, list, and send Sentry events - `sentry event view ` — View details of one or more events - `sentry event list ` — List events for an issue +- `sentry event send ` — Send a Sentry event → Full flags and examples: `references/event.md` @@ -474,15 +475,6 @@ Browse the Sentry API schema → Full flags and examples: `references/schema.md` -### Send - -Send events and envelopes to Sentry via DSN - -- `sentry send event ` — Send a Sentry event -- `sentry send envelope ` — Send a Sentry envelope file - -→ Full flags and examples: `references/send.md` - ## Global Options All commands support the following global options: diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 705dbf667..94358dafd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -1,7 +1,7 @@ --- name: sentry-cli-event version: 0.32.0-dev.0 -description: View and list Sentry events +description: View, list, and send Sentry events requires: bins: ["sentry"] auth: true @@ -9,7 +9,7 @@ requires: # Event Commands -View and list Sentry events +View, list, and send Sentry events ### `sentry event view ` @@ -87,4 +87,60 @@ sentry event list PROJ-ABC -c prev sentry event list PROJ-ABC --json ``` +### `sentry event send ` + +Send a Sentry event + +**Flags:** +- `--dsn - DSN to send events to (overrides SENTRY_DSN env var)` +- `-m, --message ... - Event message (repeat for multi-line)` +- `-a, --message-arg ... - Arguments for message template (repeat for multiple)` +- `-l, --level - Event severity level - (default: "error")` +- `-r, --release - Release version` +- `-d, --dist - Distribution identifier` +- `-E, --env - Environment name (e.g. production, staging)` +- `-p, --platform - Platform identifier (default: other)` +- `-t, --tag ... - Tag as KEY:VALUE (repeat for multiple)` +- `-e, --extra ... - Extra data as KEY:VALUE (repeat for multiple)` +- `-u, --user ... - User info as KEY:VALUE — id, email, username, ip_address, or custom` +- `-f, --fingerprint ... - Custom fingerprint part (repeat for multiple)` +- `--timestamp - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)` +- `--no-environ - Do not include environment variables in the event` +- `--raw - Send file contents as-is without parsing` + +**Examples:** + +```bash +# Send an error event (default level) +sentry event send -m "Something went wrong" + +# Specify level, release, and environment +sentry event send -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry event send -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry event send -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry event send -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} + +# Send a serialized Sentry Event object +sentry event send ./crash.json + +# Send without re-parsing (raw mode — also supports pre-built envelopes) +sentry event send --raw ./crash.json +sentry event send --raw ./captured.envelope + +# Explicit DSN +sentry event send -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry event send -m "Test" + +sentry send-event # same as: sentry event send +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/plugins/sentry-cli/skills/sentry-cli/references/send.md b/plugins/sentry-cli/skills/sentry-cli/references/send.md deleted file mode 100644 index 6b0712ba2..000000000 --- a/plugins/sentry-cli/skills/sentry-cli/references/send.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: sentry-cli-send -version: 0.32.0-dev.0 -description: Send events and envelopes to Sentry via DSN -requires: - bins: ["sentry"] - auth: true ---- - -# Send Commands - -Send events and envelopes to Sentry via DSN - -### `sentry send event ` - -Send a Sentry event - -**Flags:** -- `--dsn - DSN to send events to (overrides SENTRY_DSN env var)` -- `-m, --message ... - Event message (repeat for multi-line)` -- `-a, --message-arg ... - Arguments for message template (repeat for multiple)` -- `-l, --level - Event severity level - (default: "error")` -- `-r, --release - Release version` -- `-d, --dist - Distribution identifier` -- `-E, --env - Environment name (e.g. production, staging)` -- `-p, --platform - Platform identifier (default: other)` -- `-t, --tag ... - Tag as KEY:VALUE (repeat for multiple)` -- `-e, --extra ... - Extra data as KEY:VALUE (repeat for multiple)` -- `-u, --user ... - User info as KEY:VALUE — id, email, username, ip_address, or custom` -- `-f, --fingerprint ... - Custom fingerprint part (repeat for multiple)` -- `--timestamp - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)` -- `--no-environ - Do not include environment variables in the event` -- `--raw - Send file contents as-is without parsing` - -**Examples:** - -```bash -# Send an error event (default level) -sentry send event -m "Something went wrong" - -# Specify level, release, and environment -sentry send event -m "Deploy check" -l info -r 1.0.0 -E production - -# Add tags and extra data -sentry send event -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 - -# Set user context -sentry send event -m "Login error" --user id:42 --user email:alice@example.com - -# Custom fingerprint to group related events together -sentry send event -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} - -# Send a serialized Sentry Event object -sentry send event ./crash.json - -# Send without re-parsing (raw mode) -sentry send event --raw ./crash.json - -# Explicit DSN -sentry send event -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" - -# Via environment variable -export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" -sentry send event -m "Test" - -sentry send-event # same as: sentry send event -sentry send-envelope # same as: sentry send envelope -``` - -### `sentry send envelope ` - -Send a Sentry envelope file - -**Flags:** -- `--dsn - DSN to send envelopes to (overrides SENTRY_DSN env var)` -- `--raw - Send file bytes without parsing or validating the envelope` - -**Examples:** - -```bash -# Send a captured Sentry envelope file -sentry send envelope ./captured.envelope - -# Send without validation (raw mode) -sentry send envelope --raw ./binary.envelope - -# Send multiple envelope files -sentry send envelope ./a.envelope ./b.envelope -``` - -All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index 55401686d..95f3b590b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,7 +31,6 @@ import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; -import { sendRoute } from "./commands/send/index.js"; import { sendEnvelopeCommand } from "./commands/send-envelope.js"; import { sendEventCommand } from "./commands/send-event.js"; import { sourcemapRoute } from "./commands/sourcemap/index.js"; @@ -108,7 +107,6 @@ export const routes = buildRouteMap({ init: initCommand, api: apiCommand, schema: schemaCommand, - send: sendRoute, // Backward-compat aliases for old sentry-cli — hidden from help "send-event": sendEventCommand, "send-envelope": sendEnvelopeCommand, diff --git a/src/commands/event/index.ts b/src/commands/event/index.ts index b1909fadc..70888d552 100644 --- a/src/commands/event/index.ts +++ b/src/commands/event/index.ts @@ -1,19 +1,22 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { listCommand } from "./list.js"; +import { sendCommand } from "./send.js"; import { viewCommand } from "./view.js"; export const eventRoute = buildRouteMap({ routes: { view: viewCommand, list: listCommand, + send: sendCommand, }, defaultCommand: "view", docs: { - brief: "View and list Sentry events", + brief: "View, list, and send Sentry events", fullDescription: - "View and list event data from Sentry.\n\n" + + "View, list, and send event data from Sentry.\n\n" + "Use 'sentry event view ' to view a specific event.\n" + - "Use 'sentry event list ' to list events for an issue.", + "Use 'sentry event list ' to list events for an issue.\n" + + "Use 'sentry event send -m ' to send a test event.", hideRoute: {}, }, }); diff --git a/src/commands/event/send.ts b/src/commands/event/send.ts new file mode 100644 index 000000000..f4c343ed9 --- /dev/null +++ b/src/commands/event/send.ts @@ -0,0 +1,318 @@ +/** + * `sentry event send` — Send a Sentry event from CLI flags or a JSON file. + * + * Unlike most commands, this authenticates via a DSN (not a Bearer token), + * so no `sentry auth login` is required. The DSN can be provided via: + * 1. --dsn flag + * 2. SENTRY_DSN environment variable + */ + +import type { DsnComponents, Event } from "@sentry/core"; +import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { + buildEventFromFlags, + type SendEventFlags, +} from "../../lib/envelope/event-builder.js"; +import { + readFileBytes, + requireDsn, + sendEnvelopeRequest, +} from "../../lib/envelope/transport.js"; +import { ConfigError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; + +/** Shape of the data yielded to the output layer. */ +type SendEventResult = { + eventId: string; + file?: string; +}; + +function formatSendEventHuman(result: SendEventResult): string { + if (result.file) { + return `Event from ${result.file} dispatched: ${result.eventId}`; + } + return `Event dispatched.\nEvent ID: ${result.eventId}`; +} + +/** + * Build the envelope body and extract the event ID for a file-based send. + * + * In raw mode the file bytes are sent as-is; in normal mode the JSON is + * parsed, wrapped in an EventEnvelope, and re-serialized. + */ +async function buildFilePayload( + file: string, + raw: boolean, + dsnComponents: DsnComponents +): Promise<{ body: string | Uint8Array; eventId: string }> { + const bytes = await readFileBytes(file); + + if (raw) { + // Best-effort: extract event_id from the first line (envelope header JSON). + let eventId = ""; + try { + const firstLine = new TextDecoder().decode(bytes).split("\n")[0] ?? "{}"; + const header = JSON.parse(firstLine) as Record; + eventId = (header.event_id as string) ?? ""; + } catch { + // Non-critical — event_id is informational only + } + return { body: bytes, eventId }; + } + + let event: Event; + try { + event = JSON.parse(new TextDecoder().decode(bytes)) as Event; + } catch (err) { + throw new ValidationError( + `Failed to parse JSON from ${file}: ${(err as Error).message}`, + "path" + ); + } + + let body: string | Uint8Array; + try { + const envelope = createEventEnvelope(event, dsnComponents); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to create envelope from ${file}: ${(err as Error).message}`, + "path" + ); + } + return { body, eventId: event.event_id ?? "" }; +} + +export const sendCommand = buildCommand({ + docs: { + brief: "Send a Sentry event", + fullDescription: `\ +Send a Sentry event to the ingest pipeline using DSN-based authentication. + +No login required — provide a DSN via --dsn or the SENTRY_DSN environment variable. + +## Building an event from flags + +\`\`\` +sentry event send -m "Something went wrong" -l error --tag env:prod +\`\`\` + +## Sending from a JSON file + +The JSON file must be a valid serialized Sentry Event object: + +\`\`\` +sentry event send ./event.json +\`\`\` + +Use --raw to skip JSON parsing and send the file bytes directly to the ingest endpoint. +This also supports sending pre-built Sentry envelope files. + +When file arguments are provided, flags like -m/--message are ignored — the event is +built entirely from the file contents. + +## Common flags + +| Flag | Description | +|------|-------------| +| \`--dsn\` | DSN to send to (overrides SENTRY_DSN) | +| \`-m\` / \`--message\` | Event message (repeat for multi-line) | +| \`-l\` / \`--level\` | Severity: debug, info, warning, error, fatal | +| \`-r\` / \`--release\` | Release version | +| \`-E\` / \`--env\` | Environment name | +| \`-t\` / \`--tag\` | Tag as KEY:VALUE (repeat for multiple) | +| \`-e\` / \`--extra\` | Extra data as KEY:VALUE | +| \`-u\` / \`--user\` | User info as KEY:VALUE (id, email, username, ip_address) | +| \`-f\` / \`--fingerprint\` | Custom fingerprint parts (repeat) | +`, + }, + auth: "dsn", + output: { + human: formatSendEventHuman, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "Path(s) to JSON event file(s) to send", + parse: String, + optional: true, + }, + }, + flags: { + dsn: { + kind: "parsed", + parse: String, + brief: "DSN to send events to (overrides SENTRY_DSN env var)", + optional: true, + }, + message: { + kind: "parsed", + parse: String, + brief: "Event message (repeat for multi-line)", + variadic: true, + optional: true, + }, + "message-arg": { + kind: "parsed", + parse: String, + brief: "Arguments for message template (repeat for multiple)", + variadic: true, + optional: true, + }, + level: { + kind: "enum", + values: ["debug", "info", "warning", "error", "fatal"], + brief: "Event severity level", + default: "error", + optional: true, + }, + release: { + kind: "parsed", + parse: String, + brief: "Release version", + optional: true, + }, + dist: { + kind: "parsed", + parse: String, + brief: "Distribution identifier", + optional: true, + }, + env: { + kind: "parsed", + parse: String, + brief: "Environment name (e.g. production, staging)", + optional: true, + }, + platform: { + kind: "parsed", + parse: String, + brief: "Platform identifier (default: other)", + optional: true, + }, + tag: { + kind: "parsed", + parse: String, + brief: "Tag as KEY:VALUE (repeat for multiple)", + variadic: true, + optional: true, + }, + extra: { + kind: "parsed", + parse: String, + brief: "Extra data as KEY:VALUE (repeat for multiple)", + variadic: true, + optional: true, + }, + user: { + kind: "parsed", + parse: String, + brief: + "User info as KEY:VALUE — id, email, username, ip_address, or custom", + variadic: true, + optional: true, + }, + fingerprint: { + kind: "parsed", + parse: String, + brief: "Custom fingerprint part (repeat for multiple)", + variadic: true, + optional: true, + }, + timestamp: { + kind: "parsed", + parse: String, + brief: "Event timestamp (Unix epoch, ISO 8601, or RFC 2822)", + optional: true, + }, + "no-environ": { + kind: "boolean", + brief: "Do not include environment variables in the event", + default: false, + optional: true, + }, + raw: { + kind: "boolean", + brief: "Send file contents as-is without parsing", + default: false, + optional: true, + }, + }, + aliases: { + m: "message", + a: "message-arg", + l: "level", + r: "release", + d: "dist", + E: "env", + p: "platform", + t: "tag", + e: "extra", + u: "user", + f: "fingerprint", + }, + }, + async *func( + this: SentryContext, + flags: SendEventFlags & { + dsn?: string; + raw?: boolean; + json?: boolean; + }, + ...files: string[] + ) { + const dsn = requireDsn(flags, this.cwd); + let dsnComponents: ReturnType; + try { + dsnComponents = makeDsn(dsn); + } catch { + dsnComponents = undefined; + } + if (!dsnComponents) { + throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); + } + + if (files.length > 0) { + for (const file of files) { + const { body, eventId } = await buildFilePayload( + file, + flags.raw ?? false, + dsnComponents + ); + await sendEnvelopeRequest(dsn, body); + yield new CommandOutput({ eventId, file }); + } + } else { + if (flags.raw) { + throw new ValidationError( + "--raw requires a file argument (raw bytes cannot be built from inline flags)", + "raw" + ); + } + if (!flags.message?.length) { + throw new ConfigError( + "Provide a message via -m/--message or a JSON event file as a positional argument.", + "sentry event send -m 'My message'" + ); + } + const event = buildEventFromFlags(flags); + let body: string | Uint8Array; + try { + const envelope = createEventEnvelope(event, dsnComponents); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to create event envelope: ${(err as Error).message}`, + "event" + ); + } + await sendEnvelopeRequest(dsn, body); + yield new CommandOutput({ + eventId: event.event_id ?? "", + }); + } + }, +}); diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index a7f1833c1..3649eb052 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -1,128 +1,60 @@ /** - * `sentry send-envelope` — Send a pre-built Sentry envelope file. + * `sentry send-envelope` — Deprecated. Suggests `sentry event send --raw`. * - * Reads one or more envelope files from disk and POSTs them to the Sentry - * ingest endpoint via DSN-based authentication. - * - * Envelope files use the Sentry envelope format: - * https://develop.sentry.dev/sdk/envelopes/ - * - * No `sentry auth login` required — provide a DSN via --dsn or SENTRY_DSN. + * Kept as a hidden backward-compat alias that prints a deprecation notice + * and forwards to `sentry event send --raw`. */ -import { parseEnvelope, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { - buildEnvelopeUrl, - readFileBytes, - requireDsn, - sendEnvelopeRequest, -} from "../lib/envelope/transport.js"; -import { ValidationError } from "../lib/errors.js"; -import { CommandOutput } from "../lib/formatters/output.js"; - -type SendEnvelopeResult = { - file: string; -}; - -function formatSendEnvelopeHuman(result: SendEnvelopeResult): string { - return `Envelope from ${result.file} dispatched`; -} +import { CliError } from "../lib/errors.js"; export const sendEnvelopeCommand = buildCommand({ docs: { - brief: "Send a Sentry envelope file", - fullDescription: `\ -Send a pre-built Sentry envelope file to the ingest pipeline. - -No login required — provide a DSN via --dsn or the SENTRY_DSN environment variable. - -Envelope files follow the Sentry envelope format (newline-delimited JSON headers -followed by item payloads). These are typically produced by Sentry SDKs in -offline/buffered mode, or captured for debugging purposes. - -## Examples - -\`\`\` -# Send a single envelope file -sentry send-envelope ./captured.envelope - -# Send without parsing (useful for binary envelopes or debugging) -sentry send-envelope --raw ./captured.envelope - -# Send multiple envelope files -sentry send-envelope ./a.envelope ./b.envelope -\`\`\` -`, + brief: "Send a Sentry envelope file (deprecated)", + fullDescription: + "This command has been replaced by `sentry event send --raw `.\n\n" + + "Use `sentry event send --raw ./captured.envelope` instead.", }, - auth: "dsn", + auth: false, output: { - human: formatSendEnvelopeHuman, + human: () => "", }, parameters: { positional: { kind: "array", parameter: { - brief: "Path(s) to envelope file(s) to send", + brief: "Path(s) to envelope file(s)", parse: String, - placeholder: "path", + optional: true, }, }, flags: { dsn: { kind: "parsed", parse: String, - brief: "DSN to send envelopes to (overrides SENTRY_DSN env var)", + brief: "DSN", optional: true, }, raw: { kind: "boolean", - brief: "Send file bytes without parsing or validating the envelope", + brief: "Raw mode", default: false, optional: true, }, }, }, + // biome-ignore lint/correctness/useYield lint/suspicious/useAwait: deprecation shim — throws before yielding async *func( this: SentryContext, - flags: { dsn?: string; raw?: boolean }, + _flags: { dsn?: string; raw?: boolean }, ...files: string[] ) { - if (files.length === 0) { - throw new ValidationError( - "At least one envelope file path is required.", - "path" - ); - } - - const dsn = requireDsn(flags, this.cwd); - // Validate the DSN fully before doing any file I/O - buildEnvelopeUrl(dsn); - - for (const file of files) { - let body: string | Uint8Array; - - const bytes = await readFileBytes(file); - - if (flags.raw) { - body = bytes; - } else { - const text = new TextDecoder().decode(bytes); - // Parse to validate, then re-serialize to normalize - try { - const envelope = parseEnvelope(text); - body = serializeEnvelope(envelope); - } catch (err) { - throw new ValidationError( - `Failed to parse envelope from ${file}: ${(err as Error).message}`, - "path" - ); - } - } - - await sendEnvelopeRequest(dsn, body); - yield new CommandOutput({ file }); - } + const fileArgs = files.length > 0 ? ` ${files.join(" ")}` : " "; + throw new CliError( + "`sentry send-envelope` has been removed.\n" + + `Use: sentry event send --raw${fileArgs}`, + 1 + ); }, }); diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts index 41ec1f2a2..5adbaf16d 100644 --- a/src/commands/send-event.ts +++ b/src/commands/send-event.ts @@ -1,317 +1,9 @@ /** - * `sentry send-event` — Send a Sentry event from CLI flags or a JSON file. + * Backward-compat re-export: `sentry send-event` → `sentry event send`. * - * Unlike most commands, this authenticates via a DSN (not a Bearer token), - * so no `sentry auth login` is required. The DSN can be provided via: - * 1. --dsn flag - * 2. SENTRY_DSN environment variable + * Registered as a hidden alias in app.ts. The canonical command lives + * in `event/send.ts`. */ -import type { DsnComponents, Event } from "@sentry/core"; -import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; -import type { SentryContext } from "../context.js"; -import { buildCommand } from "../lib/command.js"; -import { - buildEventFromFlags, - type SendEventFlags, -} from "../lib/envelope/event-builder.js"; -import { - readFileBytes, - requireDsn, - sendEnvelopeRequest, -} from "../lib/envelope/transport.js"; -import { ConfigError, ValidationError } from "../lib/errors.js"; -import { CommandOutput } from "../lib/formatters/output.js"; - -/** Shape of the data yielded to the output layer. */ -type SendEventResult = { - eventId: string; - file?: string; -}; - -function formatSendEventHuman(result: SendEventResult): string { - if (result.file) { - return `Event from ${result.file} dispatched: ${result.eventId}`; - } - return `Event dispatched.\nEvent ID: ${result.eventId}`; -} - -/** - * Build the envelope body and extract the event ID for a file-based send. - * - * In raw mode the file bytes are sent as-is; in normal mode the JSON is - * parsed, wrapped in an EventEnvelope, and re-serialized. - */ -async function buildFilePayload( - file: string, - raw: boolean, - dsnComponents: DsnComponents -): Promise<{ body: string | Uint8Array; eventId: string }> { - const bytes = await readFileBytes(file); - - if (raw) { - // Best-effort: extract event_id from the first line (envelope header JSON). - let eventId = ""; - try { - const firstLine = new TextDecoder().decode(bytes).split("\n")[0] ?? "{}"; - const header = JSON.parse(firstLine) as Record; - eventId = (header.event_id as string) ?? ""; - } catch { - // Non-critical — event_id is informational only - } - return { body: bytes, eventId }; - } - - let event: Event; - try { - event = JSON.parse(new TextDecoder().decode(bytes)) as Event; - } catch (err) { - throw new ValidationError( - `Failed to parse JSON from ${file}: ${(err as Error).message}`, - "path" - ); - } - - let body: string | Uint8Array; - try { - const envelope = createEventEnvelope(event, dsnComponents); - body = serializeEnvelope(envelope); - } catch (err) { - throw new ValidationError( - `Failed to create envelope from ${file}: ${(err as Error).message}`, - "path" - ); - } - return { body, eventId: event.event_id ?? "" }; -} - -export const sendEventCommand = buildCommand({ - docs: { - brief: "Send a Sentry event", - fullDescription: `\ -Send a Sentry event to the ingest pipeline using DSN-based authentication. - -No login required — provide a DSN via --dsn or the SENTRY_DSN environment variable. - -## Building an event from flags - -\`\`\` -sentry send-event -m "Something went wrong" -l error --tag env:prod -\`\`\` - -## Sending from a JSON file - -The JSON file must be a valid serialized Sentry Event object: - -\`\`\` -sentry send-event ./event.json -\`\`\` - -Use --raw to skip JSON parsing and send the file bytes directly to the ingest endpoint. - -When file arguments are provided, flags like -m/--message are ignored — the event is -built entirely from the file contents. - -## Common flags - -| Flag | Description | -|------|-------------| -| \`--dsn\` | DSN to send to (overrides SENTRY_DSN) | -| \`-m\` / \`--message\` | Event message (repeat for multi-line) | -| \`-l\` / \`--level\` | Severity: debug, info, warning, error, fatal | -| \`-r\` / \`--release\` | Release version | -| \`-E\` / \`--env\` | Environment name | -| \`-t\` / \`--tag\` | Tag as KEY:VALUE (repeat for multiple) | -| \`-e\` / \`--extra\` | Extra data as KEY:VALUE | -| \`-u\` / \`--user\` | User info as KEY:VALUE (id, email, username, ip_address) | -| \`-f\` / \`--fingerprint\` | Custom fingerprint parts (repeat) | -`, - }, - auth: "dsn", - output: { - human: formatSendEventHuman, - }, - parameters: { - positional: { - kind: "array", - parameter: { - brief: "Path(s) to JSON event file(s) to send", - parse: String, - optional: true, - }, - }, - flags: { - dsn: { - kind: "parsed", - parse: String, - brief: "DSN to send events to (overrides SENTRY_DSN env var)", - optional: true, - }, - message: { - kind: "parsed", - parse: String, - brief: "Event message (repeat for multi-line)", - variadic: true, - optional: true, - }, - "message-arg": { - kind: "parsed", - parse: String, - brief: "Arguments for message template (repeat for multiple)", - variadic: true, - optional: true, - }, - level: { - kind: "enum", - values: ["debug", "info", "warning", "error", "fatal"], - brief: "Event severity level", - default: "error", - optional: true, - }, - release: { - kind: "parsed", - parse: String, - brief: "Release version", - optional: true, - }, - dist: { - kind: "parsed", - parse: String, - brief: "Distribution identifier", - optional: true, - }, - env: { - kind: "parsed", - parse: String, - brief: "Environment name (e.g. production, staging)", - optional: true, - }, - platform: { - kind: "parsed", - parse: String, - brief: "Platform identifier (default: other)", - optional: true, - }, - tag: { - kind: "parsed", - parse: String, - brief: "Tag as KEY:VALUE (repeat for multiple)", - variadic: true, - optional: true, - }, - extra: { - kind: "parsed", - parse: String, - brief: "Extra data as KEY:VALUE (repeat for multiple)", - variadic: true, - optional: true, - }, - user: { - kind: "parsed", - parse: String, - brief: - "User info as KEY:VALUE — id, email, username, ip_address, or custom", - variadic: true, - optional: true, - }, - fingerprint: { - kind: "parsed", - parse: String, - brief: "Custom fingerprint part (repeat for multiple)", - variadic: true, - optional: true, - }, - timestamp: { - kind: "parsed", - parse: String, - brief: "Event timestamp (Unix epoch, ISO 8601, or RFC 2822)", - optional: true, - }, - "no-environ": { - kind: "boolean", - brief: "Do not include environment variables in the event", - default: false, - optional: true, - }, - raw: { - kind: "boolean", - brief: "Send file contents as-is without parsing", - default: false, - optional: true, - }, - }, - aliases: { - m: "message", - a: "message-arg", - l: "level", - r: "release", - d: "dist", - E: "env", - p: "platform", - t: "tag", - e: "extra", - u: "user", - f: "fingerprint", - }, - }, - async *func( - this: SentryContext, - flags: SendEventFlags & { - dsn?: string; - raw?: boolean; - json?: boolean; - }, - ...files: string[] - ) { - const dsn = requireDsn(flags, this.cwd); - let dsnComponents: ReturnType; - try { - dsnComponents = makeDsn(dsn); - } catch { - dsnComponents = undefined; - } - if (!dsnComponents) { - throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); - } - - if (files.length > 0) { - for (const file of files) { - const { body, eventId } = await buildFilePayload( - file, - flags.raw ?? false, - dsnComponents - ); - await sendEnvelopeRequest(dsn, body); - yield new CommandOutput({ eventId, file }); - } - } else { - if (flags.raw) { - throw new ValidationError( - "--raw requires a file argument (raw bytes cannot be built from inline flags)", - "raw" - ); - } - if (!flags.message?.length) { - throw new ConfigError( - "Provide a message via -m/--message or a JSON event file as a positional argument.", - "sentry send-event -m 'My message'" - ); - } - const event = buildEventFromFlags(flags); - let body: string | Uint8Array; - try { - const envelope = createEventEnvelope(event, dsnComponents); - body = serializeEnvelope(envelope); - } catch (err) { - throw new ValidationError( - `Failed to create event envelope: ${(err as Error).message}`, - "event" - ); - } - await sendEnvelopeRequest(dsn, body); - yield new CommandOutput({ - eventId: event.event_id ?? "", - }); - } - }, -}); +// biome-ignore lint/performance/noBarrelFile: backward-compat alias, not a barrel +export { sendCommand as sendEventCommand } from "./event/send.js"; diff --git a/src/commands/send/index.ts b/src/commands/send/index.ts deleted file mode 100644 index 5579b853f..000000000 --- a/src/commands/send/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { buildRouteMap } from "../../lib/route-map.js"; -import { sendEnvelopeCommand } from "../send-envelope.js"; -import { sendEventCommand } from "../send-event.js"; - -export const sendRoute = buildRouteMap({ - routes: { - event: sendEventCommand, - envelope: sendEnvelopeCommand, - }, - docs: { - brief: "Send events and envelopes to Sentry via DSN", - fullDescription: - "Send data directly to Sentry's ingest pipeline using DSN-based authentication.\n\n" + - "No `sentry auth login` required — provide a DSN via --dsn or SENTRY_DSN env var.\n\n" + - "Commands:\n" + - " event Send a Sentry event (from flags or a JSON file)\n" + - " envelope Send a pre-built Sentry envelope file\n\n" + - "Examples:\n" + - " sentry send event -m 'Deploy check' -l info --tag env:prod\n" + - " sentry send event ./crash.json\n" + - " sentry send envelope ./captured.envelope", - hideRoute: {}, - }, -}); diff --git a/src/lib/command-suggestions.ts b/src/lib/command-suggestions.ts index af7d3d088..6730394f9 100644 --- a/src/lib/command-suggestions.ts +++ b/src/lib/command-suggestions.ts @@ -99,7 +99,7 @@ const SUGGESTIONS: ReadonlyMap = new Map([ // --- old sentry-cli commands (~5 events) --- ["cli/info", { command: "sentry auth status" }], - ["cli/send-event", { command: "sentry send-event" }], + ["cli/send-event", { command: "sentry event send" }], ["cli/issues", { command: "sentry issue list" }], ["cli/logs", { command: "sentry log list" }], diff --git a/src/lib/command.ts b/src/lib/command.ts index f90a28ff1..a90f0778b 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -168,7 +168,7 @@ type LocalCommandBuilderArguments< * (e.g. `auth login`, `auth logout`, `auth status`, `help`, `cli upgrade`). * * Set to `"dsn"` for commands that authenticate via a Sentry DSN instead of - * a Bearer token (e.g. `send-event`, `send-envelope`). These commands skip + * a Bearer token (e.g. `event send`). These commands skip * the token guard and the `.sentryclirc` URL trust check entirely, since * DSN auth is fully independent of the user's logged-in session. */ diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index 447cda5a7..eef6f571b 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -1,7 +1,7 @@ /** - * Constructs a Sentry Event from `sentry send-event` CLI flags. + * Constructs a Sentry Event from `sentry event send` CLI flags. * - * Mirrors the behaviour of the old Rust sentry-cli `send-event` command: + * Mirrors the behaviour of the old Rust sentry-cli `send-event` command * tags/extras as KEY:VALUE pairs, user fields with known routing * (id, email, ip_address, username → top-level; everything else → user.data), * environment variables optionally included as `extra.environ`. @@ -11,7 +11,7 @@ import type { Event, SeverityLevel, User } from "@sentry/core"; import { uuid4 } from "@sentry/core"; import { ValidationError } from "../errors.js"; -/** CLI flags accepted by `sentry send-event`. */ +/** CLI flags accepted by `sentry event send`. */ export type SendEventFlags = { message?: string[]; "message-arg"?: string[]; diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index 44b488024..41b521f4a 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -75,15 +75,15 @@ export function requireDsn(flags: DsnFlags, cwd: string): string { } throw new ConfigError( "No DSN found. Provide one via --dsn or set the SENTRY_DSN environment variable.", - "sentry send event --dsn " + "sentry event send --dsn " ); } /** * Read a file's bytes, throwing a clean ValidationError on ENOENT or I/O errors. * - * Centralises the duplicated error-handling pattern used by both - * `send-event` and `send-envelope`. + * Centralises the file-reading error-handling pattern used by + * `event send` (and previously by `send-envelope`). */ export async function readFileBytes(file: string): Promise { try { diff --git a/test/commands/send-event.test.ts b/test/commands/event/send.test.ts similarity index 86% rename from test/commands/send-event.test.ts rename to test/commands/event/send.test.ts index a217e2cfe..44f59cb6a 100644 --- a/test/commands/send-event.test.ts +++ b/test/commands/event/send.test.ts @@ -1,5 +1,5 @@ /** - * Tests for `sentry send-event` command func(). + * Tests for `sentry event send` command func(). */ import { @@ -11,10 +11,10 @@ import { spyOn, test, } from "bun:test"; -import { sendEventCommand } from "../../src/commands/send-event.js"; +import { sendCommand } from "../../../src/commands/event/send.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn -import * as transport from "../../src/lib/envelope/transport.js"; -import { useTestConfigDir } from "../helpers.js"; +import * as transport from "../../../src/lib/envelope/transport.js"; +import { useTestConfigDir } from "../../helpers.js"; useTestConfigDir("send-event-"); @@ -37,12 +37,12 @@ function makeContext() { }; } -describe("sendEventCommand.func()", () => { - let func: Awaited>; +describe("sendCommand.func()", () => { + let func: Awaited>; let sendSpy: ReturnType; beforeEach(async () => { - func = await sendEventCommand.loader(); + func = await sendCommand.loader(); sendSpy = spyOn(transport, "sendEnvelopeRequest").mockResolvedValue( undefined ); @@ -126,7 +126,7 @@ describe("sendEventCommand.func()", () => { test("nonexistent file throws ValidationError (not raw stack trace)", async () => { const { ctx } = makeContext(); - const { ValidationError } = await import("../../src/lib/errors.js"); + const { ValidationError } = await import("../../../src/lib/errors.js"); await expect( func.call( ctx, @@ -138,7 +138,7 @@ describe("sendEventCommand.func()", () => { test("--raw requires file arguments", async () => { const { ctx } = makeContext(); - const { ValidationError } = await import("../../src/lib/errors.js"); + const { ValidationError } = await import("../../../src/lib/errors.js"); await expect( func.call(ctx, { dsn: SAAS_DSN, raw: true, "no-environ": true }) ).rejects.toBeInstanceOf(ValidationError); diff --git a/test/commands/send-envelope.test.ts b/test/commands/send-envelope.test.ts index 4b24addeb..c0dcc87da 100644 --- a/test/commands/send-envelope.test.ts +++ b/test/commands/send-envelope.test.ts @@ -1,146 +1,64 @@ /** - * Tests for `sentry send-envelope` command func(). + * Tests for `sentry send-envelope` deprecation shim. */ -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; import { sendEnvelopeCommand } from "../../src/commands/send-envelope.js"; -// biome-ignore lint/performance/noNamespaceImport: needed for spyOn -import * as transport from "../../src/lib/envelope/transport.js"; +import { CliError } from "../../src/lib/errors.js"; import { useTestConfigDir } from "../helpers.js"; useTestConfigDir("send-envelope-"); -const SAAS_DSN = "https://abc123@o1.ingest.us.sentry.io/999"; - -// A minimal valid envelope: header line + item header + item body -const VALID_ENVELOPE = - '{"event_id":"aabbccddeeff00112233445566778899","sent_at":"2026-01-01T00:00:00.000Z"}\n' + - '{"type":"event","length":2}\n' + - "{}"; - function makeContext() { - const writes: string[] = []; return { - ctx: { - stdout: { - write: (s: string) => { - writes.push(s); - return true; - }, - }, - stderr: { write: mock(() => true) }, - cwd: "/tmp", - }, - writes, + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", }; } -function writeTmpEnvelope(name: string, content: string): string { - const dir = join(tmpdir(), "sentry-test-envelopes"); - mkdirSync(dir, { recursive: true }); - const path = join(dir, name); - writeFileSync(path, content, "utf8"); - return path; -} - -describe("sendEnvelopeCommand.func()", () => { +describe("sendEnvelopeCommand (deprecation shim)", () => { let func: Awaited>; - let sendSpy: ReturnType; beforeEach(async () => { func = await sendEnvelopeCommand.loader(); - sendSpy = spyOn(transport, "sendEnvelopeRequest").mockResolvedValue( - undefined - ); - }); - - afterEach(() => { - sendSpy.mockRestore(); - }); - - test("valid envelope file is sent and success message printed", async () => { - const path = writeTmpEnvelope("test.envelope", VALID_ENVELOPE); - const { ctx, writes } = makeContext(); - - await func.call(ctx, { dsn: SAAS_DSN }, path); - - expect(sendSpy).toHaveBeenCalledTimes(1); - const output = writes.join(""); - expect(output).toContain("dispatched"); - expect(output).toContain("test.envelope"); - }); - - test("--raw sends file bytes without parsing", async () => { - const content = "raw garbage that is not valid envelope format"; - const path = writeTmpEnvelope("raw.envelope", content); - const { ctx } = makeContext(); - - // Without --raw, this would throw a parse error - await func.call(ctx, { dsn: SAAS_DSN, raw: true }, path); - - expect(sendSpy).toHaveBeenCalledTimes(1); - // Body should be the raw bytes - const body = sendSpy.mock.calls[0]?.[1]; - expect(body).toBeDefined(); }); - test("invalid envelope without --raw throws parse error", async () => { - const path = writeTmpEnvelope("bad.envelope", "not valid\nenvelope"); - const { ctx } = makeContext(); - - await expect(func.call(ctx, { dsn: SAAS_DSN }, path)).rejects.toThrow(); - - expect(sendSpy).not.toHaveBeenCalled(); + test("throws CliError suggesting event send --raw", async () => { + const ctx = makeContext(); + await expect( + func.call( + ctx, + { dsn: "https://x@o1.ingest.sentry.io/1" }, + "file.envelope" + ) + ).rejects.toBeInstanceOf(CliError); }); - test("missing DSN throws ConfigError", async () => { - const savedDsn = process.env.SENTRY_DSN; - delete process.env.SENTRY_DSN; - const path = writeTmpEnvelope("ok.envelope", VALID_ENVELOPE); - const { ctx } = makeContext(); + test("error message includes the file argument", async () => { + const ctx = makeContext(); try { - await expect(func.call(ctx, {}, path)).rejects.toThrow(); - } finally { - if (savedDsn !== undefined) process.env.SENTRY_DSN = savedDsn; + await func.call( + ctx, + { dsn: "https://x@o1.ingest.sentry.io/1" }, + "my.envelope" + ); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as CliError).message).toContain("sentry event send --raw"); + expect((err as CliError).message).toContain("my.envelope"); } }); - test("multiple files are each sent separately", async () => { - const p1 = writeTmpEnvelope("a.envelope", VALID_ENVELOPE); - const p2 = writeTmpEnvelope("b.envelope", VALID_ENVELOPE); - const { ctx } = makeContext(); - - await func.call(ctx, { dsn: SAAS_DSN }, p1, p2); - - expect(sendSpy).toHaveBeenCalledTimes(2); - }); - - test("nonexistent file throws ValidationError (not raw stack trace)", async () => { - const { ctx } = makeContext(); - const { ValidationError } = await import("../../src/lib/errors.js"); - await expect( - func.call(ctx, { dsn: SAAS_DSN }, "/nonexistent/missing.envelope") - ).rejects.toBeInstanceOf(ValidationError); - expect(sendSpy).not.toHaveBeenCalled(); - }); - - test("no files throws ValidationError", async () => { - const { ctx } = makeContext(); - const { ValidationError } = await import("../../src/lib/errors.js"); - await expect(func.call(ctx, { dsn: SAAS_DSN })).rejects.toBeInstanceOf( - ValidationError - ); - expect(sendSpy).not.toHaveBeenCalled(); + test("error message uses placeholder when no files given", async () => { + const ctx = makeContext(); + try { + await func.call(ctx, { dsn: "https://x@o1.ingest.sentry.io/1" }); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as CliError).message).toContain( + "sentry event send --raw " + ); + } }); }); From b70175efef7cfbaf6e56820ca5d02a08c504b44e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 6 May 2026 12:52:41 +0000 Subject: [PATCH 15/16] fix: update command-suggestions test to expect 'sentry event send' --- test/lib/command-suggestions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/command-suggestions.test.ts b/test/lib/command-suggestions.test.ts index 10812062b..75c207079 100644 --- a/test/lib/command-suggestions.test.ts +++ b/test/lib/command-suggestions.test.ts @@ -83,7 +83,7 @@ describe("getCommandSuggestion", () => { test("suggests send-event for 'cli/send-event'", () => { expect(getCommandSuggestion("cli", "send-event")?.command).toContain( - "sentry send-event" + "sentry event send" ); }); From 494700a8bc04545d5b699d00fda12abd71327cdb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 6 May 2026 13:04:51 +0000 Subject: [PATCH 16/16] fix: add skipRcUrlCheck to send-envelope deprecation shim Prevents HostScopeError from masking the deprecation message when a .sentryclirc file is present. --- src/commands/send-envelope.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index 3649eb052..0ac9c9a7f 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -17,6 +17,7 @@ export const sendEnvelopeCommand = buildCommand({ "Use `sentry event send --raw ./captured.envelope` instead.", }, auth: false, + skipRcUrlCheck: true, output: { human: () => "", },