From c9b0435811fd5050d5c3cd056b778e7d63fd4684 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 16:35:41 +0000 Subject: [PATCH 01/22] 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 c593e4b5d..5a8038a83 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -73,7 +73,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 39dd14137..d35b38a85 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -486,6 +486,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 426b4fdc7..dfeb37448 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,6 +32,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"; @@ -107,6 +109,8 @@ export const routes = buildRouteMap({ local: localRoute, 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 bab49395d..c5f99b39d 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 2986101093cb995c01d9c5277b92bf97792e8929 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 16:42:38 +0000 Subject: [PATCH 02/22] 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 dfeb37448..2ad8f2e80 100644 --- a/src/app.ts +++ b/src/app.ts @@ -34,6 +34,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"; @@ -109,6 +110,8 @@ export const routes = buildRouteMap({ local: localRoute, api: apiCommand, schema: schemaCommand, + send: sendRoute, + // Backward-compat aliases for old sentry-cli — hidden from help "send-event": sendEventCommand, "send-envelope": sendEnvelopeCommand, dashboards: dashboardListCommand, @@ -147,6 +150,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 ff6d2f8f15e55d390329fb5e5a23f4e130da78b2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:05:06 +0000 Subject: [PATCH 03/22] 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 2ad8f2e80..811e55eae 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,9 +32,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 1a2b0e0c644934cb92d0b609030d411f5bd33f9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 17:05:46 +0000 Subject: [PATCH 04/22] 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 5a8038a83..93185afcd 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -64,6 +64,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 @@ -73,9 +74,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 d35b38a85..d9331a665 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -486,21 +486,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 0ee91412288b273a87e4fd141956855d7665c45a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:19:38 +0000 Subject: [PATCH 05/22] 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 33b154218be64483760bca82ce6c8579a71c6d7f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:30:49 +0000 Subject: [PATCH 06/22] 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 9b18b4b8266384951e6088e558f4765df1a4b656 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 17:57:29 +0000 Subject: [PATCH 07/22] 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 861377c600bf31f4431678ccfa81175e9f7e0e83 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 18:36:20 +0000 Subject: [PATCH 08/22] 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 899b7410a2b06b3e61db3cab753edb2dab562e87 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 18:46:33 +0000 Subject: [PATCH 09/22] 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 efa96bee07779d70c8d1c6c9c57fce3c9c02d441 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 18:56:29 +0000 Subject: [PATCH 10/22] 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 97e27bcf983f2c780ae2331780b5cfdb403e56e4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 19:06:09 +0000 Subject: [PATCH 11/22] 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 caf33031064b6fb810ef1b0866a6628fab39eeab Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 19:15:30 +0000 Subject: [PATCH 12/22] 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 8d2aa2228133fe5bc3625f7694f2aa61758de45b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 5 May 2026 19:26:42 +0000 Subject: [PATCH 13/22] 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 54aa6f7bb2f096cc3c8e667d444732f8b9a1fdcb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 6 May 2026 12:42:53 +0000 Subject: [PATCH 14/22] 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 | 140 +++++--- 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/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 ++------- 17 files changed, 550 insertions(+), 797 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 2af760ae6..43221a2cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidelines for AI agents working in this codebase. ## Project Overview -**Sentry CLI** is a command-line interface for [Sentry](https://sentry.io), built with [Node.js](https://nodejs.org) and [Stricli](https://bloomberg.github.io/stricli/). +**Sentry CLI** is a command-line interface for [Sentry](https://sentry.io), built with [Bun](https://bun.sh) and [Stricli](https://bloomberg.github.io/stricli/). ### Goals @@ -12,7 +12,7 @@ Guidelines for AI agents working in this codebase. - **AI-powered debugging** - Integrate Seer AI for root cause analysis and fix plans - **Developer-friendly** - Follow `gh` CLI conventions for intuitive UX - **Agent-friendly** - JSON output and predictable behavior for AI coding agents -- **Fast** - Native binaries via Node SEA, SQLite caching for API responses +- **Fast** - Native binaries via Bun, SQLite caching for API responses ### Key Features @@ -28,6 +28,7 @@ Guidelines for AI agents working in this codebase. Before working on this codebase, read the Cursor rules: +- **`.cursor/rules/bun-cli.mdc`** - Bun API usage, file I/O, process spawning, testing - **`.cursor/rules/ultracite.mdc`** - Code style, formatting, linting rules ## Quick Reference: Commands @@ -36,70 +37,71 @@ Before working on this codebase, read the Cursor rules: ```bash # Development -pnpm install # Install dependencies -pnpm run dev # Run CLI in dev mode -pnpm run cli # Run CLI directly via tsx +bun install # Install dependencies +bun run dev # Run CLI in dev mode +bun run --env-file=.env.local src/bin.ts # Dev with env vars # Build -pnpm run build # Build for current platform -pnpm run build:all # Build for all platforms +bun run build # Build for current platform +bun run build:all # Build for all platforms # Type Checking -pnpm run typecheck # Check types +bun run typecheck # Check types # Linting & Formatting -pnpm run lint # Check for issues -pnpm run lint:fix # Auto-fix issues (run before committing) +bun run lint # Check for issues +bun run lint:fix # Auto-fix issues (run before committing) # Testing -pnpm test # Run all tests -pnpm test -- path/to/file.test.ts # Run single test file -pnpm run test:unit # Run unit tests only -pnpm run test:e2e # Run e2e tests only +bun test # Run all tests +bun test path/to/file.test.ts # Run single test file +bun test --watch # Watch mode +bun test --filter "test name" # Run tests matching pattern +bun run test:unit # Run unit tests only +bun run test:e2e # Run e2e tests only ``` -## Rules: esbuild Bundling & `require()` in `src/` - -**CRITICAL**: The CLI ships as a CJS bundle (both the Node SEA binary and the npm package). esbuild bundles all `src/` code into a single file. This has important implications for `require()`: - -| Pattern | esbuild resolves it? | Safe in bundle? | Use for | -|---------|---------------------|-----------------|---------| -| `require("./foo.js")` | **Yes** — inlined at bundle time | Yes | Relative lazy imports (circular dep breaking) | -| `require("node:fs")` | **Yes** — left as external | Yes | Node builtins | -| `_require("node:fs")` | **No** — opaque call, passes through | Yes (builtins resolve by name) | Node builtins via `createRequire` | -| `_require("./foo.js")` | **No** — opaque call, passes through | **NO** — resolves from bundle location | **Never use this** | - -**Key rules:** -1. **Never alias `require()` for relative imports.** esbuild only statically resolves bare `require()` calls. Any aliased require (`_require`, `localRequire`, etc.) passes through as-is into the bundle. At runtime, relative paths resolve from the bundle file's location (`dist/index.cjs` or the SEA binary), where `./foo.js` doesn't exist. - -2. **Use `createRequire(import.meta.url)` as `_require` only for node builtins and npm packages.** These resolve by name (not relative path) so the base directory doesn't matter: `_require("node:sqlite")`, `_require("@sentry/node-core/light")`. +## Rules: No Runtime Dependencies -3. **Keep bare `require()` for relative lazy imports.** The global `require` shim (`script/require-shim.mjs`) provides `require` in ESM/tsx dev mode. esbuild resolves relative requires at bundle time, so they never reach runtime. +**CRITICAL**: All packages must be in `devDependencies`, never `dependencies`. Everything is bundled at build time via esbuild. CI enforces this with `bun run check:deps`. -4. **Never merge a PR with failing CI.** The build jobs (binary + npm bundle) catch require resolution bugs that unit tests miss. Always wait for all CI jobs to pass. +When adding a package, always use `bun add -d ` (the `-d` flag). -## Rules: No Runtime Dependencies +When the `@sentry/api` SDK provides types for an API response, import them directly from `@sentry/api` instead of creating redundant Zod schemas in `src/types/sentry.ts`. -**CRITICAL**: All packages must be in `devDependencies`, never `dependencies`. Everything is bundled at build time via esbuild. CI enforces this with `pnpm run check:deps`. +## Rules: Use Bun APIs -When adding a package, always use `pnpm add -D ` (the `-D` flag). +**CRITICAL**: This project uses Bun as runtime. Always prefer Bun-native APIs over Node.js equivalents. -When the `@sentry/api` SDK provides types for an API response, import them directly from `@sentry/api` instead of creating redundant Zod schemas in `src/types/sentry.ts`. +Read the full guidelines in `.cursor/rules/bun-cli.mdc`. -## Rules: Use Node.js APIs +**Bun Documentation**: https://bun.sh/docs - Consult these docs when unsure about Bun APIs. -**CRITICAL**: This project uses Node.js as its runtime. Use standard `node:*` built-in modules. +### Quick Bun API Reference | Task | Use This | NOT This | |------|----------|----------| -| Read file | `readFileSync(path, "utf-8")` | `Bun.file(path).text()` | -| Write file | `writeFileSync(path, content)` | `Bun.write(path, content)` | -| Check file exists | `existsSync(path)` | `Bun.file(path).exists()` | -| Spawn process | `spawn()` from `node:child_process` | `Bun.spawn()` | -| Find executable | `whichSync()` from `src/lib/which.ts` | `Bun.which()` | -| Glob patterns | `picomatch` | `new Bun.Glob()` | -| Sleep | `setTimeout` from `node:timers/promises` | `Bun.sleep(ms)` | -| Parse JSON file | `JSON.parse(readFileSync(path, "utf-8"))` | `Bun.file(path).json()` | +| Read file | `await Bun.file(path).text()` | `fs.readFileSync()` | +| Write file | `await Bun.write(path, content)` | `fs.writeFileSync()` | +| Check file exists | `await Bun.file(path).exists()` | `fs.existsSync()` | +| Spawn process | `Bun.spawn()` | `child_process.spawn()` | +| Shell commands | `Bun.$\`command\`` ⚠️ | `child_process.exec()` | +| Find executable | `Bun.which("git")` | `which` package | +| Glob patterns | `new Bun.Glob()` | `glob` / `fast-glob` packages | +| Sleep | `await Bun.sleep(ms)` | `setTimeout` with Promise | +| Parse JSON file | `await Bun.file(path).json()` | Read + JSON.parse | + +**Exception**: Use `node:fs` for directory creation with permissions: +```typescript +import { mkdirSync } from "node:fs"; +mkdirSync(dir, { recursive: true, mode: 0o700 }); +``` + +**Exception**: `Bun.$` (shell tagged template) has no shim in `script/node-polyfills.ts` and will crash on the npm/node distribution. Until a shim is added, use `execSync` from `node:child_process` for shell commands that must work in both runtimes: +```typescript +import { execSync } from "node:child_process"; +const result = execSync("id -u username", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }); +``` ## Architecture @@ -509,12 +511,12 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the ### Generated Docs & Skills -All command docs and skill files are generated via `pnpm run generate:docs` (which runs `generate:command-docs` then `generate:skill`). This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. +All command docs and skill files are generated via `bun run generate:docs` (which runs `generate:command-docs` then `generate:skill`). This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. - **Command docs** (`docs/src/content/docs/commands/*.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `docs/src/fragments/commands/`. - **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. - Edit fragments in `docs/src/fragments/commands/` for custom examples and guides. -- `pnpm run check:fragments` validates fragment ↔ route consistency. +- `bun run check:fragments` validates fragment ↔ route consistency. - Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. ### Zod Schemas for Validation @@ -610,7 +612,7 @@ CliError (base, exitCode=1) - Pass `alternatives: []` when defaults are irrelevant (e.g., for missing Trace ID, Event ID) - Use `" and "` in `resource` for plural grammar: `"Trace ID and span ID"` → "are required" -**CI enforcement:** `pnpm run check:errors` scans for `ContextError` with multiline commands and `CliError` with ad-hoc "Try:" strings. +**CI enforcement:** `bun run check:errors` scans for `ContextError` with multiline commands and `CliError` with ad-hoc "Try:" strings. ```typescript // Usage examples @@ -794,7 +796,7 @@ await deleteUserData(userId) ### Goal Minimal comments, maximum clarity. Comments explain **intent and reasoning**, not syntax. -## Testing (vitest + fast-check) +## Testing (bun:test + fast-check) **Prefer property-based and model-based testing** over traditional unit tests. These approaches find edge cases automatically and provide better coverage with less code. @@ -828,7 +830,7 @@ Tests that need a database or config directory **must** use `useTestConfigDir()` - `const baseDir = process.env[CONFIG_DIR_ENV_VAR]!` at module scope — This captures a value that may be stale - Manual `beforeEach`/`afterEach` that sets/deletes `SENTRY_CONFIG_DIR` -**Why**: The test runner uses `--isolate --parallel` (see `test:unit` in `package.json`), so each test file runs in a fresh global environment within a worker process. That bounds most cross-file leaks to a single worker, but `process.env` is still shared within a file's lifecycle — if your `afterEach` deletes the env var, the next describe/test's module-level code (or a beforeEach that re-reads env) gets `undefined`, causing `TypeError: The "paths[0]" property must be of type string`. Also, `TEST_TMP_DIR` is namespaced by worker ID in `test/constants.ts` so parallel workers don't wipe each other's temp state during preload. +**Why**: Bun's test runner uses `--isolate --parallel` (see `test:unit` in `package.json`), so each test file runs in a fresh global environment within a worker process. That bounds most cross-file leaks to a single worker, but `process.env` is still shared within a file's lifecycle — if your `afterEach` deletes the env var, the next describe/test's module-level code (or a beforeEach that re-reads env) gets `undefined`, causing `TypeError: The "paths[0]" property must be of type string`. Also, `TEST_TMP_DIR` is namespaced by `BUN_TEST_WORKER_ID` in `test/constants.ts` so parallel workers don't wipe each other's temp state during preload. ```typescript // CORRECT: Use the helper @@ -851,7 +853,7 @@ afterEach(() => { delete process.env.SENTRY_CONFIG_DIR; }); // BUG! Use property-based tests when verifying invariants that should hold for **any valid input**. ```typescript -import { describe, expect, test } from "vitest"; +import { describe, expect, test } from "bun:test"; import { constantFrom, assert as fcAssert, property, tuple } from "fast-check"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -899,7 +901,7 @@ describe("property: myFunction", () => { Use model-based tests for **stateful systems** where sequences of operations should maintain invariants. ```typescript -import { describe, expect, test } from "vitest"; +import { describe, expect, test } from "bun:test"; import { type AsyncCommand, asyncModelRun, @@ -1027,7 +1029,7 @@ When adding property tests for a function that already has unit tests, **remove ``` ```typescript -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test, mock } from "bun:test"; describe("feature", () => { test("should return specific value", async () => { @@ -1036,7 +1038,7 @@ describe("feature", () => { }); // Mock modules when needed -vi.mock("./some-module", () => ({ +mock.module("./some-module", () => ({ default: () => "mocked", })); ``` @@ -1064,5 +1066,33 @@ vi.mock("./some-module", () => ({ ## Long-term Knowledge -For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) (gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) in the project root. +### Architecture + + +* **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). + + +* **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\`. + + +* **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 + + +* **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. + + +* **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\`. + + +* **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 + + +* **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. + + +* **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/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 d9331a665..13c70d7f5 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` @@ -486,15 +487,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/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 811e55eae..478040edd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,7 +32,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"; @@ -110,7 +109,6 @@ export const routes = buildRouteMap({ local: localRoute, 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 61748cf17af194fe82017f7be6725d3cc016a663 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 6 May 2026 12:52:41 +0000 Subject: [PATCH 15/22] 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 c5f99b39d..ff7d51195 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 1e930220301ddfdc9c0e0a54cb46819bc98c3c4b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 6 May 2026 13:04:51 +0000 Subject: [PATCH 16/22] 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: () => "", }, From 562717a296ed5354cbb214cd60df02e65b87bca1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 2 Jun 2026 14:17:26 +0000 Subject: [PATCH 17/22] fix: migrate to Node.js APIs and vitest after rebase - Replace Bun.file() with node:fs/promises readFile in transport.ts - Replace bun:test imports with vitest in all test files - Replace mock() with vi.fn(), spyOn with vi.spyOn - Convert require() to static imports - Regenerate docs/skill files for post-rebase state --- docs/src/content/docs/contributing.md | 3 +- .../skills/sentry-cli/references/event.md | 60 ++++++++++++++++++- src/lib/envelope/transport.ts | 3 +- test/commands/event/send.test.ts | 25 +++----- test/commands/send-envelope.test.ts | 6 +- test/lib/envelope/event-builder.test.ts | 5 +- test/lib/envelope/transport.test.ts | 2 +- 7 files changed, 76 insertions(+), 28 deletions(-) diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 93185afcd..39e7d64b3 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -55,7 +55,7 @@ cli/ │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, import, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete, revisions, restore -│ │ ├── event/ # view, list +│ │ ├── event/ # view, list, send │ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge │ │ ├── local/ # serve, run │ │ ├── log/ # list, view @@ -64,7 +64,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/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 269f1bc65..0da01c94d 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.36.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/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index 41b521f4a..b21d8ab9a 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -86,8 +86,9 @@ export function requireDsn(flags: DsnFlags, cwd: string): string { * `event send` (and previously by `send-envelope`). */ export async function readFileBytes(file: string): Promise { + const { readFile } = await import("node:fs/promises"); try { - return new Uint8Array(await Bun.file(file).arrayBuffer()); + return await readFile(file); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { diff --git a/test/commands/event/send.test.ts b/test/commands/event/send.test.ts index 44f59cb6a..7678e806b 100644 --- a/test/commands/event/send.test.ts +++ b/test/commands/event/send.test.ts @@ -2,18 +2,11 @@ * Tests for `sentry event send` command func(). */ -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 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 { ValidationError } from "../../../src/lib/errors.js"; import { useTestConfigDir } from "../../helpers.js"; useTestConfigDir("send-event-"); @@ -30,7 +23,7 @@ function makeContext() { return true; }, }, - stderr: { write: mock(() => true) }, + stderr: { write: vi.fn(() => true) }, cwd: "/tmp", }, writes, @@ -39,13 +32,13 @@ function makeContext() { describe("sendCommand.func()", () => { let func: Awaited>; - let sendSpy: ReturnType; + let sendSpy: ReturnType; beforeEach(async () => { func = await sendCommand.loader(); - sendSpy = spyOn(transport, "sendEnvelopeRequest").mockResolvedValue( - undefined - ); + sendSpy = vi + .spyOn(transport, "sendEnvelopeRequest") + .mockResolvedValue(undefined); }); afterEach(() => { @@ -126,7 +119,7 @@ describe("sendCommand.func()", () => { 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, @@ -138,7 +131,7 @@ describe("sendCommand.func()", () => { 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/commands/send-envelope.test.ts b/test/commands/send-envelope.test.ts index c0dcc87da..e43b4fa5f 100644 --- a/test/commands/send-envelope.test.ts +++ b/test/commands/send-envelope.test.ts @@ -2,7 +2,7 @@ * Tests for `sentry send-envelope` deprecation shim. */ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { sendEnvelopeCommand } from "../../src/commands/send-envelope.js"; import { CliError } from "../../src/lib/errors.js"; import { useTestConfigDir } from "../helpers.js"; @@ -11,8 +11,8 @@ useTestConfigDir("send-envelope-"); function makeContext() { return { - stdout: { write: mock(() => true) }, - stderr: { write: mock(() => true) }, + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, cwd: "/tmp", }; } diff --git a/test/lib/envelope/event-builder.test.ts b/test/lib/envelope/event-builder.test.ts index 9e9d68f80..257f6ce11 100644 --- a/test/lib/envelope/event-builder.test.ts +++ b/test/lib/envelope/event-builder.test.ts @@ -5,13 +5,14 @@ * tested below. Unit tests here focus on specific edge cases and output shape. */ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import type { SendEventFlags } from "../../../src/lib/envelope/event-builder.js"; import { buildEventFromFlags, parseKeyValue, parseUserFields, } from "../../../src/lib/envelope/event-builder.js"; +import { ValidationError } from "../../../src/lib/errors.js"; // ── parseKeyValue ────────────────────────────────────────────────── @@ -28,12 +29,10 @@ describe("parseKeyValue", () => { }); 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); }); }); diff --git a/test/lib/envelope/transport.test.ts b/test/lib/envelope/transport.test.ts index a89358e55..5a1835652 100644 --- a/test/lib/envelope/transport.test.ts +++ b/test/lib/envelope/transport.test.ts @@ -9,7 +9,7 @@ * - Both string and Uint8Array bodies are supported */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { buildEnvelopeUrl, resolveDsn, From 95c39a91ae69e3a225bc973fac7e6630d6440866 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 2 Jun 2026 16:26:34 +0000 Subject: [PATCH 18/22] feat: add --logfile and --with-categories flags to event send Parses a log file into breadcrumbs attached to the event, matching the old sentry-cli send-event --logfile behavior: - Reads file line by line - Optionally parses 'CATEGORY: message' prefixes (--with-categories) - Uses file mtime as breadcrumb timestamp - Caps at 100 breadcrumbs (keeps the last 100 lines) Used by the self-hosted installer's error reporting: sentry-cli send-event --logfile $INSTALL_LOG ... 4 new tests: basic logfile, with-categories parsing, nonexistent file error, 100-breadcrumb cap with correct last-N behavior. --- .../skills/sentry-cli/references/event.md | 2 + src/commands/event/send.ts | 17 +- src/lib/envelope/event-builder.ts | 76 ++++++- test/lib/envelope/event-builder.test.ts | 186 +++++++++++++----- 4 files changed, 227 insertions(+), 54 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 0da01c94d..453848c4c 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -106,6 +106,8 @@ Send a Sentry event - `-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` +- `--logfile - Path to a log file — last 100 lines are attached as breadcrumbs` +- `--with-categories - Parse 'CATEGORY: message' prefixes from logfile breadcrumbs` - `--raw - Send file contents as-is without parsing` **Examples:** diff --git a/src/commands/event/send.ts b/src/commands/event/send.ts index f4c343ed9..c1f3377aa 100644 --- a/src/commands/event/send.ts +++ b/src/commands/event/send.ts @@ -126,6 +126,8 @@ built entirely from the file contents. | \`-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) | +| \`--logfile\` | Attach last 100 log lines as breadcrumbs | +| \`--with-categories\` | Parse 'CATEGORY: message' from logfile lines | `, }, auth: "dsn", @@ -234,6 +236,19 @@ built entirely from the file contents. default: false, optional: true, }, + logfile: { + kind: "parsed", + parse: String, + brief: + "Path to a log file — last 100 lines are attached as breadcrumbs", + optional: true, + }, + "with-categories": { + kind: "boolean", + brief: "Parse 'CATEGORY: message' prefixes from logfile breadcrumbs", + default: false, + optional: true, + }, raw: { kind: "boolean", brief: "Send file contents as-is without parsing", @@ -298,7 +313,7 @@ built entirely from the file contents. "sentry event send -m 'My message'" ); } - const event = buildEventFromFlags(flags); + const event = await buildEventFromFlags(flags); let body: string | Uint8Array; try { const envelope = createEventEnvelope(event, dsnComponents); diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index eef6f571b..56c67ea25 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -7,7 +7,8 @@ * environment variables optionally included as `extra.environ`. */ -import type { Event, SeverityLevel, User } from "@sentry/core"; +import { readFile, stat } from "node:fs/promises"; +import type { Breadcrumb, Event, SeverityLevel, User } from "@sentry/core"; import { uuid4 } from "@sentry/core"; import { ValidationError } from "../errors.js"; @@ -25,6 +26,8 @@ export type SendEventFlags = { user?: string[]; fingerprint?: string[]; timestamp?: string; + logfile?: string; + "with-categories"?: boolean; "no-environ"?: boolean; }; @@ -106,13 +109,76 @@ function parseTimestamp(ts: string | undefined): number | undefined { ); } +/** Maximum number of breadcrumbs to attach from a logfile. */ +const MAX_BREADCRUMBS = 100; + +/** Regex to split a log line into `CATEGORY: message` when --with-categories is set. */ +const CATEGORY_RE = /^([^:]+):\s*(.*)$/; + +/** + * Parse a logfile into an array of breadcrumbs. + * + * Reads the file line by line, optionally parsing `CATEGORY: message` + * prefixes. Uses the file's mtime as the breadcrumb timestamp (matching + * the old sentry-cli behaviour). Keeps the last {@link MAX_BREADCRUMBS} + * entries. + */ +export async function parseBreadcrumbsFromLogfile( + logfilePath: string, + withCategories: boolean +): Promise { + let content: string; + let mtimeSeconds: number; + try { + content = await readFile(logfilePath, "utf-8"); + const fileStat = await stat(logfilePath); + mtimeSeconds = fileStat.mtimeMs / 1000; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError(`Logfile not found: ${logfilePath}`, "logfile"); + } + throw new ValidationError( + `Cannot read logfile ${logfilePath}: ${(err as Error).message}`, + "logfile" + ); + } + + const lines = content.split("\n").filter((l) => l.length > 0); + const breadcrumbs: Breadcrumb[] = lines.map((line) => { + if (withCategories) { + const match = CATEGORY_RE.exec(line); + if (match?.[1] && match[2]) { + return { + timestamp: mtimeSeconds, + category: match[1].trim(), + message: match[2].trim(), + }; + } + } + return { + timestamp: mtimeSeconds, + category: "log", + message: line, + }; + }); + + // Keep only the last MAX_BREADCRUMBS entries + if (breadcrumbs.length > MAX_BREADCRUMBS) { + return breadcrumbs.slice(-MAX_BREADCRUMBS); + } + return breadcrumbs; +} + /** * 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 { +export async function buildEventFromFlags( + flags: SendEventFlags +): Promise { const tags = parseKeyValuePairs(flags.tag); // environ goes first so explicit --extra environ:val overrides it const extra: Record = { @@ -141,5 +207,11 @@ export function buildEventFromFlags(flags: SendEventFlags): Event { extra: Object.keys(extra).length > 0 ? extra : undefined, user: flags.user?.length ? parseUserFields(flags.user) : undefined, fingerprint: flags.fingerprint, + breadcrumbs: flags.logfile + ? await parseBreadcrumbsFromLogfile( + flags.logfile, + flags["with-categories"] ?? false + ) + : undefined, }; } diff --git a/test/lib/envelope/event-builder.test.ts b/test/lib/envelope/event-builder.test.ts index 257f6ce11..a12449d48 100644 --- a/test/lib/envelope/event-builder.test.ts +++ b/test/lib/envelope/event-builder.test.ts @@ -17,22 +17,22 @@ import { ValidationError } from "../../../src/lib/errors.js"; // ── parseKeyValue ────────────────────────────────────────────────── describe("parseKeyValue", () => { - test("splits on first colon", () => { + test("splits on first colon", async () => { expect(parseKeyValue("key:value")).toEqual(["key", "value"]); }); - test("value may contain colons", () => { + test("value may contain colons", async () => { expect(parseKeyValue("url:https://example.com")).toEqual([ "url", "https://example.com", ]); }); - test("no colon → throws ValidationError", () => { + test("no colon → throws ValidationError", async () => { expect(() => parseKeyValue("nocohere")).toThrow(ValidationError); }); - test("empty key → throws ValidationError", () => { + test("empty key → throws ValidationError", async () => { expect(() => parseKeyValue(":value")).toThrow(ValidationError); }); }); @@ -40,35 +40,35 @@ describe("parseKeyValue", () => { // ── parseUserFields ─────────────────────────────────────────────── describe("parseUserFields", () => { - test("id maps to user.id", () => { + test("id maps to user.id", async () => { expect(parseUserFields(["id:42"])).toMatchObject({ id: "42" }); }); - test("email maps to user.email", () => { + test("email maps to user.email", async () => { expect(parseUserFields(["email:alice@example.com"])).toMatchObject({ email: "alice@example.com", }); }); - test("ip_address maps to user.ip_address", () => { + test("ip_address maps to user.ip_address", async () => { expect(parseUserFields(["ip_address:1.2.3.4"])).toMatchObject({ ip_address: "1.2.3.4", }); }); - test("username maps to user.username", () => { + test("username maps to user.username", async () => { expect(parseUserFields(["username:alice"])).toMatchObject({ username: "alice", }); }); - test("unknown keys go into user.data", () => { + test("unknown keys go into user.data", async () => { expect(parseUserFields(["role:admin"])).toMatchObject({ data: { role: "admin" }, }); }); - test("multiple pairs merged", () => { + test("multiple pairs merged", async () => { const result = parseUserFields(["id:1", "email:a@b.com", "role:admin"]); expect(result).toMatchObject({ id: "1", @@ -85,101 +85,185 @@ describe("buildEventFromFlags", () => { return { "no-environ": true, ...overrides }; } - test("defaults: level=error, platform=other", () => { - const event = buildEventFromFlags(flags()); + test("defaults: level=error, platform=other", async () => { + const event = await 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()); + test("event_id is always a 32-char hex string", async () => { + const event = await buildEventFromFlags(flags()); expect(event.event_id).toMatch(/^[0-9a-f]{32}$/); }); - test("timestamp is a Unix float", () => { - const event = buildEventFromFlags(flags()); + test("timestamp is a Unix float", async () => { + const event = await buildEventFromFlags(flags()); expect(typeof event.timestamp).toBe("number"); expect(event.timestamp).toBeGreaterThan(0); }); - test("--level sets level", () => { - expect(buildEventFromFlags(flags({ level: "warning" })).level).toBe( + test("--level sets level", async () => { + expect((await buildEventFromFlags(flags({ level: "warning" }))).level).toBe( "warning" ); }); - test("--message joined with newline", () => { - const event = buildEventFromFlags(flags({ message: ["hello", "world"] })); + test("--message joined with newline", async () => { + const event = await buildEventFromFlags( + flags({ message: ["hello", "world"] }) + ); expect(event.logentry?.message).toBe("hello\nworld"); }); - test("--message-arg sets params", () => { - const event = buildEventFromFlags( + test("--message-arg sets params", async () => { + const event = await 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"] })); + test("--tag parses into tags object", async () => { + const event = await 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"] })); + test("--extra parses into extra object", async () => { + const event = await 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 })); + test("--no-environ omits process.env from extra", async () => { + const event = await 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 })); + test("environ included when --no-environ not set", async () => { + const event = await buildEventFromFlags(flags({ "no-environ": false })); expect((event.extra as Record)?.environ).toBeDefined(); }); - test("--user routes known fields correctly", () => { - const event = buildEventFromFlags( + test("--user routes known fields correctly", async () => { + const event = await 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( + test("--fingerprint sets fingerprint array", async () => { + const event = await 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("--release sets release", async () => { + expect( + (await buildEventFromFlags(flags({ release: "1.2.3" }))).release + ).toBe("1.2.3"); }); - test("--env sets environment", () => { - expect(buildEventFromFlags(flags({ env: "staging" })).environment).toBe( - "staging" - ); + test("--env sets environment", async () => { + expect( + (await buildEventFromFlags(flags({ env: "staging" }))).environment + ).toBe("staging"); }); - test("--platform sets platform", () => { - expect(buildEventFromFlags(flags({ platform: "python" })).platform).toBe( - "python" - ); + test("--platform sets platform", async () => { + expect( + (await buildEventFromFlags(flags({ platform: "python" }))).platform + ).toBe("python"); }); - test("--dist sets dist", () => { - expect(buildEventFromFlags(flags({ dist: "x86" })).dist).toBe("x86"); + test("--dist sets dist", async () => { + expect((await buildEventFromFlags(flags({ dist: "x86" }))).dist).toBe( + "x86" + ); }); - test("each call produces a unique event_id", () => { - const a = buildEventFromFlags(flags()); - const b = buildEventFromFlags(flags()); + test("each call produces a unique event_id", async () => { + const a = await buildEventFromFlags(flags()); + const b = await buildEventFromFlags(flags()); expect(a.event_id).not.toBe(b.event_id); }); + + test("--logfile attaches breadcrumbs from file", async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "sentry-logfile-test-")); + const logPath = join(dir, "test.log"); + try { + writeFileSync(logPath, "line one\nline two\nline three\n"); + const event = await buildEventFromFlags(flags({ logfile: logPath })); + expect(event.breadcrumbs).toHaveLength(3); + expect(event.breadcrumbs?.[0]?.message).toBe("line one"); + expect(event.breadcrumbs?.[0]?.category).toBe("log"); + expect(event.breadcrumbs?.[2]?.message).toBe("line three"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("--logfile with --with-categories parses CATEGORY: message", async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "sentry-logfile-cat-")); + const logPath = join(dir, "test.log"); + try { + writeFileSync( + logPath, + "INFO: Server started\nERROR: Connection lost\nplain line\n" + ); + const event = await buildEventFromFlags( + flags({ logfile: logPath, "with-categories": true }) + ); + expect(event.breadcrumbs).toHaveLength(3); + expect(event.breadcrumbs?.[0]).toMatchObject({ + category: "INFO", + message: "Server started", + }); + expect(event.breadcrumbs?.[1]).toMatchObject({ + category: "ERROR", + message: "Connection lost", + }); + // Line without category prefix falls back to "log" + expect(event.breadcrumbs?.[2]).toMatchObject({ + category: "log", + message: "plain line", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("--logfile with nonexistent file throws ValidationError", async () => { + await expect( + buildEventFromFlags(flags({ logfile: "/nonexistent/logfile.log" })) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--logfile caps at 100 breadcrumbs", async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "sentry-logfile-cap-")); + const logPath = join(dir, "big.log"); + try { + const lines = Array.from({ length: 150 }, (_, i) => `line ${i}`).join( + "\n" + ); + writeFileSync(logPath, lines); + const event = await buildEventFromFlags(flags({ logfile: logPath })); + expect(event.breadcrumbs).toHaveLength(100); + // Should keep the LAST 100 lines (50-149) + expect(event.breadcrumbs?.[0]?.message).toBe("line 50"); + expect(event.breadcrumbs?.[99]?.message).toBe("line 149"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); From 972012872ae39cda33ab3535e034bd43a0a81292 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 2 Jun 2026 16:35:25 +0000 Subject: [PATCH 19/22] fix: use nullish coalescing for regex captures in logfile parser Empty messages like 'ERROR:' now correctly produce {category: 'ERROR', message: ''} instead of falling through to the default 'log' category. --- src/lib/envelope/event-builder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index 56c67ea25..c93c0fb92 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -148,11 +148,11 @@ export async function parseBreadcrumbsFromLogfile( const breadcrumbs: Breadcrumb[] = lines.map((line) => { if (withCategories) { const match = CATEGORY_RE.exec(line); - if (match?.[1] && match[2]) { + if (match) { return { timestamp: mtimeSeconds, - category: match[1].trim(), - message: match[2].trim(), + category: (match[1] ?? "log").trim(), + message: (match[2] ?? "").trim(), }; } } From a4c810d3aa69254fd39ef666a2a7ba94c0fdff9b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 2 Jun 2026 16:44:53 +0000 Subject: [PATCH 20/22] fix: reject whitespace-only --timestamp values Number(' ') returns 0, silently producing epoch-zero timestamps. Now trims and checks for empty before numeric parsing. --- src/lib/envelope/event-builder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index c93c0fb92..c9b7912c6 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -90,8 +90,8 @@ export function parseUserFields(pairs: string[]): User { * Throws ValidationError for non-empty strings that cannot be parsed. */ function parseTimestamp(ts: string | undefined): number | undefined { - if (!ts) { - return; + if (!ts || ts.trim().length === 0) { + return undefined; } // Unix numeric const num = Number(ts); From 57974e3d64ddfc336562c0ce433f7b5522c65e3f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 2 Jun 2026 16:55:39 +0000 Subject: [PATCH 21/22] fix: replace return undefined with bare return (noUselessUndefined) --- src/lib/envelope/event-builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts index c9b7912c6..0e45d6923 100644 --- a/src/lib/envelope/event-builder.ts +++ b/src/lib/envelope/event-builder.ts @@ -91,7 +91,7 @@ export function parseUserFields(pairs: string[]): User { */ function parseTimestamp(ts: string | undefined): number | undefined { if (!ts || ts.trim().length === 0) { - return undefined; + return; } // Unix numeric const num = Number(ts); From 6195f5a8714f59f1e8ec62cd2ffa24a105762801 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 2 Jun 2026 17:52:55 +0000 Subject: [PATCH 22/22] fix: address self-review findings before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add log.debug() to all 4 silent catch blocks (AGENTS.md compliance) - Replace hardcoded exit code 1 with EXIT.GENERAL in send-envelope shim - Remove unused _cwd parameter from resolveDsn/requireDsn - Add 5 new parseTimestamp tests: Unix epoch int/float, ISO 8601, invalid string → ValidationError, whitespace-only → default --- src/commands/event/send.ts | 12 ++++++---- src/commands/send-envelope.ts | 4 ++-- src/lib/envelope/transport.ts | 17 +++++++------ test/lib/envelope/event-builder.test.ts | 32 +++++++++++++++++++++++++ test/lib/envelope/transport.test.ts | 10 ++++---- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/commands/event/send.ts b/src/commands/event/send.ts index c1f3377aa..dd6caa3b4 100644 --- a/src/commands/event/send.ts +++ b/src/commands/event/send.ts @@ -22,6 +22,9 @@ import { } from "../../lib/envelope/transport.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; + +const log = logger.withTag("event.send"); /** Shape of the data yielded to the output layer. */ type SendEventResult = { @@ -56,8 +59,8 @@ async function buildFilePayload( 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 + } catch (err) { + log.debug("Could not extract event_id from envelope header", err); } return { body: bytes, eventId }; } @@ -279,11 +282,12 @@ built entirely from the file contents. }, ...files: string[] ) { - const dsn = requireDsn(flags, this.cwd); + const dsn = requireDsn(flags); let dsnComponents: ReturnType; try { dsnComponents = makeDsn(dsn); - } catch { + } catch (err) { + log.debug("makeDsn threw for DSN input", err); dsnComponents = undefined; } if (!dsnComponents) { diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts index 0ac9c9a7f..fa652d4b5 100644 --- a/src/commands/send-envelope.ts +++ b/src/commands/send-envelope.ts @@ -7,7 +7,7 @@ import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { CliError } from "../lib/errors.js"; +import { CliError, EXIT } from "../lib/errors.js"; export const sendEnvelopeCommand = buildCommand({ docs: { @@ -55,7 +55,7 @@ export const sendEnvelopeCommand = buildCommand({ throw new CliError( "`sentry send-envelope` has been removed.\n" + `Use: sentry event send --raw${fileArgs}`, - 1 + EXIT.GENERAL ); }, }); diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts index b21d8ab9a..d4ec5b4a8 100644 --- a/src/lib/envelope/transport.ts +++ b/src/lib/envelope/transport.ts @@ -13,6 +13,9 @@ import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn } from "@sentry/core"; import { ApiError, ConfigError, ValidationError } from "../errors.js"; +import { logger } from "../logger.js"; + +const log = logger.withTag("envelope.transport"); /** Client name passed to getEnvelopeEndpointWithUrlEncodedAuth, which appends / internally. */ const SENTRY_CLIENT = "sentry-cli"; @@ -32,8 +35,8 @@ export function buildEnvelopeUrl(dsn: string): string { let dsnComponents: ReturnType; try { dsnComponents = makeDsn(dsn); - } catch { - // makeDsn may throw a SentryError on malformed input + } catch (err) { + log.debug("makeDsn threw for DSN input", err); dsnComponents = undefined; } if (!dsnComponents) { @@ -51,7 +54,7 @@ export function buildEnvelopeUrl(dsn: string): string { * 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 { +export function resolveDsn(flags: DsnFlags): string | undefined { if (flags.dsn) { return flags.dsn.trim(); } @@ -68,8 +71,8 @@ export function resolveDsn(flags: DsnFlags, _cwd: string): string | undefined { * 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); +export function requireDsn(flags: DsnFlags): string { + const dsn = resolveDsn(flags); if (dsn) { return dsn; } @@ -128,8 +131,8 @@ export async function sendEnvelopeRequest( if (typeof json.detail === "string") { detail = json.detail; } - } catch { - // Non-JSON error body — keep the HTTP status message + } catch (err) { + log.debug("Non-JSON error body, using HTTP status message", err); } throw new ApiError(detail, response.status, detail, url); } diff --git a/test/lib/envelope/event-builder.test.ts b/test/lib/envelope/event-builder.test.ts index a12449d48..ede470f1e 100644 --- a/test/lib/envelope/event-builder.test.ts +++ b/test/lib/envelope/event-builder.test.ts @@ -102,6 +102,38 @@ describe("buildEventFromFlags", () => { expect(event.timestamp).toBeGreaterThan(0); }); + test("--timestamp with Unix epoch integer", async () => { + const event = await buildEventFromFlags(flags({ timestamp: "1700000000" })); + expect(event.timestamp).toBe(1_700_000_000); + }); + + test("--timestamp with Unix epoch float", async () => { + const event = await buildEventFromFlags( + flags({ timestamp: "1700000000.123" }) + ); + expect(event.timestamp).toBe(1_700_000_000.123); + }); + + test("--timestamp with ISO 8601 string", async () => { + const event = await buildEventFromFlags( + flags({ timestamp: "2024-01-15T12:00:00Z" }) + ); + // ISO 8601 → parsed via Date.parse, converted to seconds + expect(event.timestamp).toBe(Date.parse("2024-01-15T12:00:00Z") / 1000); + }); + + test("--timestamp with invalid string throws ValidationError", async () => { + await expect( + buildEventFromFlags(flags({ timestamp: "not-a-date" })) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--timestamp with whitespace-only returns default (not epoch zero)", async () => { + const event = await buildEventFromFlags(flags({ timestamp: " " })); + // Should fall back to Date.now(), not epoch 0 + expect(event.timestamp).toBeGreaterThan(1_700_000_000); + }); + test("--level sets level", async () => { expect((await buildEventFromFlags(flags({ level: "warning" }))).level).toBe( "warning" diff --git a/test/lib/envelope/transport.test.ts b/test/lib/envelope/transport.test.ts index 5a1835652..a572624c8 100644 --- a/test/lib/envelope/transport.test.ts +++ b/test/lib/envelope/transport.test.ts @@ -66,30 +66,30 @@ describe("resolveDsn", () => { test("explicit --dsn flag takes priority over env", () => { process.env.SENTRY_DSN = SELF_HOSTED_DSN; - const result = resolveDsn({ dsn: SAAS_DSN }, "/tmp"); + const result = resolveDsn({ dsn: SAAS_DSN }); 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"); + const result = resolveDsn({ dsn: undefined }); 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"); + const result = resolveDsn({ dsn: undefined }); expect(result).toBeUndefined(); }); test("trims whitespace from --dsn flag", () => { - const result = resolveDsn({ dsn: ` ${SAAS_DSN} ` }, "/tmp"); + const result = resolveDsn({ dsn: ` ${SAAS_DSN} ` }); 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"); + const result = resolveDsn({ dsn: undefined }); expect(result).toBe(SAAS_DSN); }); });