From 8c296b2a6dbc5d5eff393171f171ca2426dcfce3 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Wed, 21 Oct 2026 15:46:08 -0400 Subject: [PATCH 1/5] feat(cli): Add event dropped command Summarize events Sentry received but dropped (rate-limited, filtered, invalid, abuse, client-discarded, cardinality-limited), grouped by outcome and reason, via the existing events-dropped endpoint. This is the last client surface after the Explore flyout, MCP, and Seer. Each reason carries its drop-classification tag (intentional vs unintentional) in both the human Tags column and the JSON tags field. --json/--fields come from the command output config, so the table and machine-readable views share one summarizer and never diverge. Refs DAIN-1940 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli-docs/src/content/docs/contributing.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/event.md | 14 ++ packages/cli/src/commands/event/dropped.ts | 134 ++++++++++++ packages/cli/src/commands/event/index.ts | 3 + packages/cli/src/lib/api-client.ts | 4 + packages/cli/src/lib/api/dropped-events.ts | 62 ++++++ .../cli/src/lib/formatters/dropped-events.ts | 178 ++++++++++++++++ packages/cli/src/types/dropped-events.ts | 89 ++++++++ packages/cli/src/types/index.ts | 10 + .../test/commands/event/dropped.func.test.ts | 196 ++++++++++++++++++ .../lib/formatters/dropped-events.test.ts | 115 ++++++++++ 12 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/event/dropped.ts create mode 100644 packages/cli/src/lib/api/dropped-events.ts create mode 100644 packages/cli/src/lib/formatters/dropped-events.ts create mode 100644 packages/cli/src/types/dropped-events.ts create mode 100644 packages/cli/test/commands/event/dropped.func.test.ts create mode 100644 packages/cli/test/lib/formatters/dropped-events.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..9680d695b 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -66,7 +66,7 @@ toolkit/ │ │ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload │ │ │ ├── docs/ # list, query │ │ │ ├── dsn/ # list -│ │ │ ├── event/ # list, send, view +│ │ │ ├── event/ # dropped, list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view │ │ │ ├── integration/ # delete, list │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 933993124..5b94cbdc2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -434,6 +434,7 @@ 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 dropped []` — Summarize events Sentry dropped - `sentry event send ` — Send a Sentry event → Full flags and examples: `references/event.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 0c5da357d..6fbaaffb4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -136,6 +136,20 @@ sentry event list PROJ-ABC -c prev sentry event list PROJ-ABC --json ``` +### `sentry event dropped []` + +Summarize events Sentry dropped + +**Flags:** +- `--dataset - Dropped-data type: errors, spans, logs, or metrics - (default: "errors")` +- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01"` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `outcomes` | array | | + ### `sentry event send ` Send a Sentry event diff --git a/packages/cli/src/commands/event/dropped.ts b/packages/cli/src/commands/event/dropped.ts new file mode 100644 index 000000000..b25a56a3f --- /dev/null +++ b/packages/cli/src/commands/event/dropped.ts @@ -0,0 +1,134 @@ +/** + * sentry event dropped + * + * Summarize events Sentry received but dropped (rate-limited, filtered, + * invalid, abuse, client-discarded, cardinality-limited), grouped by outcome + * and reason. Hits `GET /organizations/{org}/events-dropped/`; the accepted + * volume comes back too so each slice's share of total volume is shown. + * + * `--json` and `--fields` are injected automatically by the output config, so + * the same sectioned data serializes for agents and automation without a + * separate code path. Each reason carries its drop classification tag + * (intentional vs unintentional). + */ + +import type { SentryContext } from "../../context.js"; +import { getDroppedEvents, getProject } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { muted } from "../../lib/formatters/colors.js"; +import { + type DroppedEventsResult, + DroppedEventsOutputSchema, + formatDroppedEvents, + outcomeLabel, + summarizeDroppedEvents, +} from "../../lib/formatters/dropped-events.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { withProgress } from "../../lib/polling.js"; +import { resolveProjectBoundFromArg } from "../../lib/resolve-target.js"; +import { + PERIOD_BRIEF, + parsePeriod, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; +import type { DroppedEventsResponse } from "../../types/index.js"; + +const COMMAND_NAME = "dropped"; +const USAGE_HINT = "sentry event dropped /"; + +/** Dropped-data types accepted by the events-dropped endpoint. */ +const DATASETS = ["errors", "spans", "logs", "metrics"] as const; + +type DroppedFlags = { + dataset: (typeof DATASETS)[number]; + period?: TimeRange; + json?: boolean; + fields?: string[]; +}; + +export const droppedCommand = buildCommand({ + docs: { + brief: "Summarize events Sentry dropped", + fullDescription: + "Summarize events Sentry received but dropped before storage, grouped by " + + "outcome and reason, with each slice's share of total volume.\n\n" + + "Drops are either intentional (user-configured, e.g. sampling or inbound " + + "filters) or unintentional (e.g. quota, abuse) — shown in the Tags column " + + "and the JSON `tags` field.\n\n" + + "Target the project with /, or omit it to detect from your " + + "config or a local DSN.\n\n" + + "Examples:\n" + + " sentry event dropped my-org/my-project\n" + + " sentry event dropped my-org/my-project --dataset spans\n" + + " sentry event dropped my-org/my-project --period 24h\n" + + " sentry event dropped my-org/my-project --json", + }, + output: { + human: formatDroppedEvents, + schema: DroppedEventsOutputSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "/ (omit to auto-detect)", + parse: String, + optional: true, + }, + ], + }, + flags: { + dataset: { + kind: "enum", + values: [...DATASETS], + brief: "Dropped-data type: errors, spans, logs, or metrics", + default: "errors", + }, + period: { + kind: "parsed", + parse: parsePeriod, + brief: PERIOD_BRIEF, + optional: true, + }, + }, + aliases: { t: "period" }, + }, + async *func(this: SentryContext, flags: DroppedFlags, target?: string) { + const { org, project } = await resolveProjectBoundFromArg( + target, + this.cwd, + COMMAND_NAME, + ); + + // The events-dropped `project` param is a numeric ID. Resolve the slug so + // the summary scopes to the project instead of every project the token + // can read. + const projectData = await getProject(org, project); + + const timeRange = flags.period; + const response: DroppedEventsResponse = await withProgress( + { message: "Fetching dropped events...", json: flags.json }, + () => + getDroppedEvents(org, { + dataset: flags.dataset, + projectId: String(projectData.id), + ...(timeRange ? timeRangeToApiParams(timeRange) : {}), + }), + ); + + const result: DroppedEventsResult = summarizeDroppedEvents(response); + yield new CommandOutput(result); + if (result.outcomes.length === 0) { + return { + hint: muted(`No dropped events for ${org}/${project}.`), + }; + } + return undefined; + }, +}); + +// Re-exported so the route and tests share the single source of truth. +export { outcomeLabel, USAGE_HINT }; diff --git a/packages/cli/src/commands/event/index.ts b/packages/cli/src/commands/event/index.ts index 70888d552..6d397f6ca 100644 --- a/packages/cli/src/commands/event/index.ts +++ b/packages/cli/src/commands/event/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "../../lib/route-map.js"; +import { droppedCommand } from "./dropped.js"; import { listCommand } from "./list.js"; import { sendCommand } from "./send.js"; import { viewCommand } from "./view.js"; @@ -7,6 +8,7 @@ export const eventRoute = buildRouteMap({ routes: { view: viewCommand, list: listCommand, + dropped: droppedCommand, send: sendCommand, }, defaultCommand: "view", @@ -16,6 +18,7 @@ export const eventRoute = buildRouteMap({ "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.\n" + + "Use 'sentry event dropped /' to summarize dropped events.\n" + "Use 'sentry event send -m ' to send a test event.", hideRoute: {}, }, diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 3a4efd603..06ce1ce2d 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -52,6 +52,10 @@ export { restoreDashboardRevision, updateDashboard, } from "./api/dashboards.js"; +export { + type DroppedEventsOptions, + getDroppedEvents, +} from "./api/dropped-events.js"; export { isNotFoundApiError } from "./api/error-guards.js"; export { findEventAcrossOrgs, diff --git a/packages/cli/src/lib/api/dropped-events.ts b/packages/cli/src/lib/api/dropped-events.ts new file mode 100644 index 000000000..01ef8fc68 --- /dev/null +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -0,0 +1,62 @@ +/** + * Sentry dropped-events API — events received but discarded before storage. + * + * Wraps `GET /organizations/{org}/events-dropped/`, the same endpoint the + * Explore dropped-data UI, MCP `find_dropped_events`, and Seer use. Returns + * drop counts bucketed by outcome and reason plus the accepted volume per + * bucket. The `project` param is a numeric ID, so callers resolve a slug to an + * ID first (see `getProject`). + */ + +import { + type DroppedEventsResponse, + DroppedEventsResponseSchema, +} from "../../types/dropped-events.js"; +import { resolveOrgRegion } from "../region.js"; +import { apiRequestToRegion } from "./infrastructure.js"; + +/** Options for {@link getDroppedEvents}. */ +export type DroppedEventsOptions = { + /** Which dropped-data type to summarize. */ + dataset?: string; + /** Numeric project ID. Omit to span every project the token can read. */ + projectId?: string; + /** Relative time range (e.g. "24h", "7d"). Mutually exclusive with start/end. */ + statsPeriod?: string; + /** Absolute start (ISO-8601). Paired with end. */ + start?: string; + /** Absolute end (ISO-8601). Paired with start. */ + end?: string; + /** Bucket size (e.g. "1h", "1d"). Omit to let Sentry pick. */ + interval?: string; +}; + +/** + * Fetch dropped and accepted events for an organization, bucketed by outcome + * and reason over the requested time range. + */ +export async function getDroppedEvents( + orgSlug: string, + options: DroppedEventsOptions = {}, +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/events-dropped/`, + { + params: { + dataset: options.dataset ?? "errors", + project: options.projectId, + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "90d"), + start: options.start, + end: options.end, + interval: options.interval, + }, + schema: DroppedEventsResponseSchema, + }, + ); + return data; +} diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts new file mode 100644 index 000000000..f84b90854 --- /dev/null +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -0,0 +1,178 @@ +/** + * Formatting for `sentry event dropped`. + * + * {@link summarizeDroppedEvents} collapses the raw bucketed API response into + * per-outcome sections, each with its reason rows, drop counts, shares of total + * volume, and drop-classification tags. The same shape feeds both the human + * table ({@link formatDroppedEvents}) and the auto-serialized `--json` output, + * so the two can never diverge. + */ + +import { + array, + description, + type InferOutput, + number, + object, + pipe, + string, +} from "valibot"; +import type { DroppedEventsResponse } from "../../types/dropped-events.js"; +import { outcomeLabel } from "../../types/dropped-events.js"; +import { bold } from "./colors.js"; +import { fmtCount } from "./numbers.js"; +import { type Column, formatTable } from "./table.js"; + +export { outcomeLabel }; + +// --------------------------------------------------------------------------- +// Output schema (documents the --json contract; drives --fields and help) +// --------------------------------------------------------------------------- + +const ReasonRowSchema = pipe( + object({ + reason: pipe(string(), description("Drop reason (sub-classification)")), + category: pipe(string(), description("Data category (e.g. error, span)")), + events: pipe(number(), description("Events dropped for this reason")), + share: pipe( + number(), + description("Fraction of total volume (0-1), not a percentage string"), + ), + tags: pipe( + array(string()), + description("Drop classification: intentional or unintentional"), + ), + }), + description("A reason row within an outcome"), +); + +export const DroppedEventsOutputSchema = pipe( + object({ + outcomes: array( + pipe( + object({ + outcome: pipe(string(), description("Drop outcome code")), + label: pipe(string(), description("Human-readable outcome label")), + events: pipe( + number(), + description("Events dropped for this outcome"), + ), + share: pipe(number(), description("Fraction of total volume (0-1)")), + reasons: array(ReasonRowSchema), + }), + description("An outcome section"), + ), + ), + }), + description("Dropped events grouped by outcome and reason"), +); + +export type DroppedEventsResult = InferOutput; +type ReasonRow = InferOutput; +type OutcomeSection = DroppedEventsResult["outcomes"][number]; + +// --------------------------------------------------------------------------- +// Summarization +// --------------------------------------------------------------------------- + +function shareRatio(events: number, total: number): number { + return total === 0 ? 0 : events / total; +} + +/** + * Collapse the bucketed API response into outcome sections sorted by volume, + * each with reason rows sorted by volume. Share is each slice's fraction of + * total volume (dropped + accepted), matching the Explore flyout. + */ +export function summarizeDroppedEvents( + response: DroppedEventsResponse, +): DroppedEventsResult { + const total = + sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); + + // outcome -> reason -> accumulated row + const byOutcome = new Map>(); + for (const bucket of response.droppedEvents) { + const reasons = byOutcome.get(bucket.outcome) ?? new Map(); + byOutcome.set(bucket.outcome, reasons); + const existing = reasons.get(bucket.reason); + if (existing) { + existing.events += bucket.count; + } else { + reasons.set(bucket.reason, { + reason: bucket.reason, + category: bucket.category, + events: bucket.count, + share: 0, + // Tags are per (outcome, reason), so they are stable across a reason's + // buckets — take them from the first bucket seen. + tags: bucket.tags ?? [], + }); + } + } + + const outcomes: OutcomeSection[] = [...byOutcome.entries()] + .map(([outcome, reasons]) => { + const rows = [...reasons.values()] + .map((row) => ({ ...row, share: shareRatio(row.events, total) })) + .sort((a, b) => b.events - a.events); + const events = rows.reduce((sum, row) => sum + row.events, 0); + return { + outcome, + label: outcomeLabel(outcome), + events, + share: shareRatio(events, total), + reasons: rows, + }; + }) + .sort((a, b) => b.events - a.events); + + return { outcomes }; +} + +function sumCounts(buckets: DroppedEventsResponse["droppedEvents"]): number { + return buckets.reduce((sum, bucket) => sum + bucket.count, 0); +} + +// --------------------------------------------------------------------------- +// Human output +// --------------------------------------------------------------------------- + +function formatShare(ratio: number): string { + if (ratio > 0 && ratio < 0.0001) { + return "<0.01%"; + } + return `${(ratio * 100).toFixed(2)}%`; +} + +/** Join a bucket's classification tags, or "—" when untagged. */ +function formatTags(tags: string[]): string { + return tags.length > 0 ? tags.join(", ") : "—"; +} + +const REASON_COLUMNS: Column[] = [ + { header: "Reason", value: (row) => row.reason }, + { header: "Category", value: (row) => row.category }, + { header: "Dropped", value: (row) => fmtCount(row.events), align: "right" }, + { header: "Share", value: (row) => formatShare(row.share), align: "right" }, + { header: "Tags", value: (row) => formatTags(row.tags) }, +]; + +/** + * Render the sectioned table: one block per outcome (header line + reason + * table). Empty input yields an empty string — the command prints the "no + * dropped events" hint instead. + */ +export function formatDroppedEvents(result: DroppedEventsResult): string { + if (result.outcomes.length === 0) { + return ""; + } + return result.outcomes + .map((section) => { + const header = bold( + `${section.label}: ${fmtCount(section.events)} dropped (${formatShare(section.share)})`, + ); + return `${header}\n${formatTable(section.reasons, REASON_COLUMNS)}`; + }) + .join("\n\n"); +} diff --git a/packages/cli/src/types/dropped-events.ts b/packages/cli/src/types/dropped-events.ts new file mode 100644 index 000000000..88d2bd2f3 --- /dev/null +++ b/packages/cli/src/types/dropped-events.ts @@ -0,0 +1,89 @@ +/** + * Dropped-events types and schemas. + * + * Mirrors the Sentry `GET /organizations/{org}/events-dropped/` response + * (`src/sentry/api/endpoints/organization_events_dropped.py`): per-bucket + * drop counts by outcome and reason, plus the accepted volume so a caller can + * compute each slice's share. Each bucket carries `tags` classifying the drop + * as `intentional` (user-configured, e.g. sampling or inbound filters) or + * `unintentional` (e.g. quota, abuse); accepted buckets carry no tag. + */ + +import { + array, + description, + type InferOutput, + looseObject, + number, + object, + pipe, + string, +} from "valibot"; + +/** One drop/accept bucket for a single (outcome, reason) over one interval. */ +export const DroppedEventsBucketSchema = pipe( + looseObject({ + type: pipe(string(), description("Event type (e.g. error, transaction)")), + category: pipe(string(), description("Data category (e.g. error, span)")), + outcome: pipe( + string(), + description("Drop classification (e.g. rate_limited, filtered)"), + ), + reason: pipe( + string(), + description( + "Sub-classification within the outcome (e.g. spike_protection)", + ), + ), + start: pipe(number(), description("Bucket start (epoch ms)")), + end: pipe(number(), description("Bucket end (epoch ms)")), + count: pipe(number(), description("Events in this bucket")), + tags: pipe( + array(string()), + description("Drop classification tags: intentional or unintentional"), + ), + }), + description("A dropped or accepted events bucket"), +); + +export const DroppedEventsResponseSchema = pipe( + object({ + meta: pipe( + looseObject({ + dataset: pipe(string(), description("Resolved dataset label")), + start: number(), + end: number(), + interval: pipe(number(), description("Bucket size (ms)")), + }), + description("Query metadata"), + ), + droppedEvents: array(DroppedEventsBucketSchema), + acceptedEvents: array(DroppedEventsBucketSchema), + }), + description("Dropped and accepted events by outcome and reason"), +); + +export type DroppedEventsBucket = InferOutput; +export type DroppedEventsResponse = InferOutput< + typeof DroppedEventsResponseSchema +>; + +/** Human label for a drop outcome. Falls back to the raw code. */ +export function outcomeLabel(outcome: string): string { + switch (outcome) { + case "client_discard": + return "Client Discard"; + case "filtered": + return "Inbound Filter"; + case "invalid": + return "Invalid or Malformed"; + case "rate_limited": + return "Rate Limited"; + case "abuse": + return "Abuse Limit"; + case "cardinality_limited": + return "Cardinality Limit"; + default: + return outcome; + } +} diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 4ba1bb7b4..5df038c13 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -47,6 +47,16 @@ export { DashboardWidgetQuerySchema, DashboardWidgetSchema, } from "./dashboard.js"; +// Dropped-events types and schemas +export type { + DroppedEventsBucket, + DroppedEventsResponse, +} from "./dropped-events.js"; +export { + DroppedEventsBucketSchema, + DroppedEventsResponseSchema, + outcomeLabel, +} from "./dropped-events.js"; // Feedback types and schemas export type { FeedbackListResult, diff --git a/packages/cli/test/commands/event/dropped.func.test.ts b/packages/cli/test/commands/event/dropped.func.test.ts new file mode 100644 index 000000000..853506b4f --- /dev/null +++ b/packages/cli/test/commands/event/dropped.func.test.ts @@ -0,0 +1,196 @@ +/** + * Event Dropped Command Tests + * + * Drives `droppedCommand.func()` with a mocked API + project resolution to + * verify human and JSON output, including the drop-classification tags that + * were added after the original (now-closed) sentry-cli PRs. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { droppedCommand } from "../../../src/commands/event/dropped.js"; +import type { DroppedEventsResponse } from "../../../src/types/dropped-events.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +const RESPONSE: DroppedEventsResponse = { + meta: { dataset: "errors", start: 0, end: 7200, interval: 3600000 }, + droppedEvents: [ + { + type: "error", + category: "error", + outcome: "client_discard", + reason: "sample_rate", + start: 0, + end: 3600, + count: 100, + tags: ["intentional"], + }, + { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "spike_protection", + start: 0, + end: 3600, + count: 15, + tags: ["unintentional"], + }, + { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "usage_exceeded", + start: 0, + end: 3600, + count: 2, + tags: ["unintentional"], + }, + ], + acceptedEvents: [ + { + type: "error", + category: "error", + outcome: "accepted", + reason: "accepted", + start: 0, + end: 3600, + count: 883, + tags: [], + }, + ], +}; + +describe("event dropped command func()", () => { + let getDroppedEventsSpy: ReturnType; + let getProjectSpy: ReturnType; + let resolveSpy: ReturnType; + + function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; + } + + beforeEach(() => { + resolveSpy = vi + .spyOn(resolveTarget, "resolveProjectBoundFromArg") + .mockResolvedValue({ org: "my-org", project: "my-project" }); + getProjectSpy = vi + .spyOn(apiClient, "getProject") + // Only the numeric id is read by the command. + .mockResolvedValue({ id: "12345", slug: "my-project" } as never); + getDroppedEventsSpy = vi + .spyOn(apiClient, "getDroppedEvents") + .mockResolvedValue(RESPONSE); + }); + + afterEach(() => { + resolveSpy.mockRestore(); + getProjectSpy.mockRestore(); + getDroppedEventsSpy.mockRestore(); + }); + + test("scopes the request to the resolved numeric project id", async () => { + const { context } = createMockContext(); + const func = await droppedCommand.loader(); + await func.call( + context, + { dataset: "errors", json: true }, + "my-org/my-project", + ); + + expect(getDroppedEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ dataset: "errors", projectId: "12345" }), + ); + }); + + test("JSON output carries outcomes, shares, and tags", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await droppedCommand.loader(); + await func.call( + context, + { dataset: "errors", json: true }, + "my-org/my-project", + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.outcomes.map((o: { outcome: string }) => o.outcome)).toEqual([ + "client_discard", + "rate_limited", + ]); + const clientDiscard = parsed.outcomes[0]; + expect(clientDiscard.share).toBeCloseTo(0.1, 10); + expect(clientDiscard.reasons[0].tags).toEqual(["intentional"]); + // share is a raw ratio, not a formatted percentage string + expect(typeof clientDiscard.reasons[0].share).toBe("number"); + }); + + test("human output renders tables with a Tags column", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await droppedCommand.loader(); + await func.call( + context, + { dataset: "errors", json: false }, + "my-org/my-project", + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Client Discard"); + expect(output).toContain("Rate Limited"); + expect(output).toContain("Tags"); + expect(output).toContain("unintentional"); + }); + + test("prints a no-dropped-events hint when empty", async () => { + getDroppedEventsSpy.mockResolvedValue({ + meta: { dataset: "errors", start: 0, end: 0, interval: 0 }, + droppedEvents: [], + acceptedEvents: [], + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await droppedCommand.loader(); + await func.call( + context, + { dataset: "errors", json: false }, + "my-org/my-project", + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No dropped events for my-org/my-project."); + }); +}); diff --git a/packages/cli/test/lib/formatters/dropped-events.test.ts b/packages/cli/test/lib/formatters/dropped-events.test.ts new file mode 100644 index 000000000..4d01ed841 --- /dev/null +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for the dropped-events summarizer and human formatter. + * + * Covers the grouping/share math and that drop-classification tags survive + * into both the structured result and the rendered table. + */ + +import { describe, expect, test } from "vitest"; +import { + formatDroppedEvents, + summarizeDroppedEvents, +} from "../../../src/lib/formatters/dropped-events.js"; +import type { DroppedEventsResponse } from "../../../src/types/dropped-events.js"; + +function bucket( + overrides: Partial, +): DroppedEventsResponse["droppedEvents"][number] { + return { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "spike_protection", + start: 0, + end: 3600, + count: 1, + tags: ["unintentional"], + ...overrides, + }; +} + +const RESPONSE: DroppedEventsResponse = { + meta: { dataset: "errors", start: 0, end: 7200, interval: 3600000 }, + droppedEvents: [ + bucket({ + outcome: "client_discard", + reason: "sample_rate", + count: 100, + tags: ["intentional"], + }), + bucket({ outcome: "rate_limited", reason: "spike_protection", count: 10 }), + bucket({ outcome: "rate_limited", reason: "spike_protection", count: 5 }), + bucket({ outcome: "rate_limited", reason: "usage_exceeded", count: 2 }), + bucket({ + outcome: "filtered", + reason: "web-crawlers", + count: 3, + tags: ["intentional"], + }), + ], + acceptedEvents: [ + bucket({ outcome: "accepted", reason: "accepted", count: 880, tags: [] }), + ], +}; + +describe("summarizeDroppedEvents", () => { + test("groups by outcome then reason, sorted by volume", () => { + const { outcomes } = summarizeDroppedEvents(RESPONSE); + + // client_discard (100) > rate_limited (17) > filtered (3) + expect(outcomes.map((o) => o.outcome)).toEqual([ + "client_discard", + "rate_limited", + "filtered", + ]); + + const rateLimited = outcomes[1]; + expect(rateLimited.events).toBe(17); + // reasons sorted by volume, and same-reason buckets summed + expect(rateLimited.reasons.map((r) => [r.reason, r.events])).toEqual([ + ["spike_protection", 15], + ["usage_exceeded", 2], + ]); + }); + + test("share is a 0-1 ratio over total volume (dropped + accepted)", () => { + const { outcomes } = summarizeDroppedEvents(RESPONSE); + // total = 100 + 17 + 3 + 880 = 1000 + const clientDiscard = outcomes[0]; + expect(clientDiscard.share).toBeCloseTo(0.1, 10); + expect(clientDiscard.reasons[0].share).toBeCloseTo(0.1, 10); + }); + + test("carries the drop-classification tag onto each reason", () => { + const { outcomes } = summarizeDroppedEvents(RESPONSE); + const byOutcome = Object.fromEntries(outcomes.map((o) => [o.outcome, o])); + expect(byOutcome.client_discard.reasons[0].tags).toEqual(["intentional"]); + expect(byOutcome.rate_limited.reasons[0].tags).toEqual(["unintentional"]); + expect(byOutcome.filtered.reasons[0].tags).toEqual(["intentional"]); + }); + + test("empty response yields no outcomes", () => { + const { outcomes } = summarizeDroppedEvents({ + meta: { dataset: "errors", start: 0, end: 0, interval: 0 }, + droppedEvents: [], + acceptedEvents: [], + }); + expect(outcomes).toEqual([]); + }); +}); + +describe("formatDroppedEvents", () => { + test("renders a section per outcome with a Tags column", () => { + const text = formatDroppedEvents(summarizeDroppedEvents(RESPONSE)); + expect(text).toContain("Client Discard"); + expect(text).toContain("Rate Limited"); + expect(text).toContain("Tags"); + expect(text).toContain("intentional"); + expect(text).toContain("unintentional"); + expect(text).toContain("sample_rate"); + }); + + test("empty result renders nothing", () => { + expect(formatDroppedEvents({ outcomes: [] })).toBe(""); + }); +}); From 28ed097eeab25f2a5c42df006489e94530ff7167 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Wed, 21 Oct 2026 16:03:48 -0400 Subject: [PATCH 2/5] fix(cli): Normalize metrics dataset and fix event dropped usage hint Map the `metrics` dataset alias to the `tracemetrics` API name in getDroppedEvents so `event dropped --dataset metrics` no longer 400s, and pass the full "event dropped" command path into project resolution so error hints show the real command instead of "sentry dropped". Also drops redundant comments and dead re-exports from the command. Refs DAIN-1940 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/event/dropped.ts | 22 +------ packages/cli/src/lib/api/dropped-events.ts | 15 ++--- .../cli/src/lib/formatters/dropped-events.ts | 14 ----- packages/cli/src/types/dropped-events.ts | 12 ---- .../test/commands/event/dropped.func.test.ts | 8 --- .../cli/test/lib/api/dropped-events.test.ts | 59 +++++++++++++++++++ .../lib/formatters/dropped-events.test.ts | 7 --- 7 files changed, 64 insertions(+), 73 deletions(-) create mode 100644 packages/cli/test/lib/api/dropped-events.test.ts diff --git a/packages/cli/src/commands/event/dropped.ts b/packages/cli/src/commands/event/dropped.ts index b25a56a3f..a1fc4b989 100644 --- a/packages/cli/src/commands/event/dropped.ts +++ b/packages/cli/src/commands/event/dropped.ts @@ -1,17 +1,3 @@ -/** - * sentry event dropped - * - * Summarize events Sentry received but dropped (rate-limited, filtered, - * invalid, abuse, client-discarded, cardinality-limited), grouped by outcome - * and reason. Hits `GET /organizations/{org}/events-dropped/`; the accepted - * volume comes back too so each slice's share of total volume is shown. - * - * `--json` and `--fields` are injected automatically by the output config, so - * the same sectioned data serializes for agents and automation without a - * separate code path. Each reason carries its drop classification tag - * (intentional vs unintentional). - */ - import type { SentryContext } from "../../context.js"; import { getDroppedEvents, getProject } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -20,7 +6,6 @@ import { type DroppedEventsResult, DroppedEventsOutputSchema, formatDroppedEvents, - outcomeLabel, summarizeDroppedEvents, } from "../../lib/formatters/dropped-events.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -34,10 +19,8 @@ import { } from "../../lib/time-range.js"; import type { DroppedEventsResponse } from "../../types/index.js"; -const COMMAND_NAME = "dropped"; -const USAGE_HINT = "sentry event dropped /"; +const COMMAND_NAME = "event dropped"; -/** Dropped-data types accepted by the events-dropped endpoint. */ const DATASETS = ["errors", "spans", "logs", "metrics"] as const; type DroppedFlags = { @@ -129,6 +112,3 @@ export const droppedCommand = buildCommand({ return undefined; }, }); - -// Re-exported so the route and tests share the single source of truth. -export { outcomeLabel, USAGE_HINT }; diff --git a/packages/cli/src/lib/api/dropped-events.ts b/packages/cli/src/lib/api/dropped-events.ts index 01ef8fc68..222102e44 100644 --- a/packages/cli/src/lib/api/dropped-events.ts +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -1,13 +1,3 @@ -/** - * Sentry dropped-events API — events received but discarded before storage. - * - * Wraps `GET /organizations/{org}/events-dropped/`, the same endpoint the - * Explore dropped-data UI, MCP `find_dropped_events`, and Seer use. Returns - * drop counts bucketed by outcome and reason plus the accepted volume per - * bucket. The `project` param is a numeric ID, so callers resolve a slug to an - * ID first (see `getProject`). - */ - import { type DroppedEventsResponse, DroppedEventsResponseSchema, @@ -40,12 +30,15 @@ export async function getDroppedEvents( options: DroppedEventsOptions = {}, ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); + // The events API dataset is `tracemetrics`; `metrics` is the user-facing + // alias and 400s if sent through unchanged. + const dataset = options.dataset ?? "errors"; const { data } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/events-dropped/`, { params: { - dataset: options.dataset ?? "errors", + dataset: dataset === "metrics" ? "tracemetrics" : dataset, project: options.projectId, statsPeriod: options.start || options.end diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index f84b90854..492609983 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -1,13 +1,3 @@ -/** - * Formatting for `sentry event dropped`. - * - * {@link summarizeDroppedEvents} collapses the raw bucketed API response into - * per-outcome sections, each with its reason rows, drop counts, shares of total - * volume, and drop-classification tags. The same shape feeds both the human - * table ({@link formatDroppedEvents}) and the auto-serialized `--json` output, - * so the two can never diverge. - */ - import { array, description, @@ -23,8 +13,6 @@ import { bold } from "./colors.js"; import { fmtCount } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; -export { outcomeLabel }; - // --------------------------------------------------------------------------- // Output schema (documents the --json contract; drives --fields and help) // --------------------------------------------------------------------------- @@ -90,7 +78,6 @@ export function summarizeDroppedEvents( const total = sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); - // outcome -> reason -> accumulated row const byOutcome = new Map>(); for (const bucket of response.droppedEvents) { const reasons = byOutcome.get(bucket.outcome) ?? new Map(); @@ -145,7 +132,6 @@ function formatShare(ratio: number): string { return `${(ratio * 100).toFixed(2)}%`; } -/** Join a bucket's classification tags, or "—" when untagged. */ function formatTags(tags: string[]): string { return tags.length > 0 ? tags.join(", ") : "—"; } diff --git a/packages/cli/src/types/dropped-events.ts b/packages/cli/src/types/dropped-events.ts index 88d2bd2f3..5517e87c3 100644 --- a/packages/cli/src/types/dropped-events.ts +++ b/packages/cli/src/types/dropped-events.ts @@ -1,14 +1,3 @@ -/** - * Dropped-events types and schemas. - * - * Mirrors the Sentry `GET /organizations/{org}/events-dropped/` response - * (`src/sentry/api/endpoints/organization_events_dropped.py`): per-bucket - * drop counts by outcome and reason, plus the accepted volume so a caller can - * compute each slice's share. Each bucket carries `tags` classifying the drop - * as `intentional` (user-configured, e.g. sampling or inbound filters) or - * `unintentional` (e.g. quota, abuse); accepted buckets carry no tag. - */ - import { array, description, @@ -68,7 +57,6 @@ export type DroppedEventsResponse = InferOutput< typeof DroppedEventsResponseSchema >; -/** Human label for a drop outcome. Falls back to the raw code. */ export function outcomeLabel(outcome: string): string { switch (outcome) { case "client_discard": diff --git a/packages/cli/test/commands/event/dropped.func.test.ts b/packages/cli/test/commands/event/dropped.func.test.ts index 853506b4f..3091e7a00 100644 --- a/packages/cli/test/commands/event/dropped.func.test.ts +++ b/packages/cli/test/commands/event/dropped.func.test.ts @@ -1,11 +1,3 @@ -/** - * Event Dropped Command Tests - * - * Drives `droppedCommand.func()` with a mocked API + project resolution to - * verify human and JSON output, including the drop-classification tags that - * were added after the original (now-closed) sentry-cli PRs. - */ - import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { droppedCommand } from "../../../src/commands/event/dropped.js"; import type { DroppedEventsResponse } from "../../../src/types/dropped-events.js"; diff --git a/packages/cli/test/lib/api/dropped-events.test.ts b/packages/cli/test/lib/api/dropped-events.test.ts new file mode 100644 index 000000000..52f0694bb --- /dev/null +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getDroppedEvents } from "../../../src/lib/api/dropped-events.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +const EMPTY_BODY = { + meta: { dataset: "errors", start: 0, end: 0, interval: 0 }, + droppedEvents: [], + acceptedEvents: [], +}; + +describe("getDroppedEvents", () => { + useTestConfigDir("dropped-events-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown = EMPTY_BODY) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + } + + test("hits the events-dropped endpoint, defaulting dataset to errors", async () => { + mockOk(); + await getDroppedEvents("my-org"); + expect(capturedUrl).toContain( + "/api/0/organizations/my-org/events-dropped/", + ); + expect(capturedUrl).toContain("dataset=errors"); + }); + + test("maps the metrics alias to the tracemetrics API dataset", async () => { + mockOk(); + await getDroppedEvents("my-org", { dataset: "metrics" }); + expect(capturedUrl).toContain("dataset=tracemetrics"); + expect(capturedUrl).not.toContain("dataset=metrics"); + }); + + test("passes other datasets through unchanged and scopes by project id", async () => { + mockOk(); + await getDroppedEvents("my-org", { dataset: "spans", projectId: "12345" }); + expect(capturedUrl).toContain("dataset=spans"); + expect(capturedUrl).toContain("project=12345"); + }); +}); diff --git a/packages/cli/test/lib/formatters/dropped-events.test.ts b/packages/cli/test/lib/formatters/dropped-events.test.ts index 4d01ed841..30fbea05e 100644 --- a/packages/cli/test/lib/formatters/dropped-events.test.ts +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -1,10 +1,3 @@ -/** - * Unit tests for the dropped-events summarizer and human formatter. - * - * Covers the grouping/share math and that drop-classification tags survive - * into both the structured result and the rendered table. - */ - import { describe, expect, test } from "vitest"; import { formatDroppedEvents, From b974db5420f518873b413f08d64e1573090bd1d5 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Wed, 21 Oct 2026 16:22:36 -0400 Subject: [PATCH 3/5] style(cli): Drop section banners from dropped-events formatter The file is 164 lines; banner dividers are a navigation aid for the repo's large formatters (human, dashboard), not small ones. Refs DAIN-1940 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/lib/formatters/dropped-events.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index 492609983..8861fdc6c 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -13,10 +13,6 @@ import { bold } from "./colors.js"; import { fmtCount } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; -// --------------------------------------------------------------------------- -// Output schema (documents the --json contract; drives --fields and help) -// --------------------------------------------------------------------------- - const ReasonRowSchema = pipe( object({ reason: pipe(string(), description("Drop reason (sub-classification)")), @@ -59,10 +55,6 @@ export type DroppedEventsResult = InferOutput; type ReasonRow = InferOutput; type OutcomeSection = DroppedEventsResult["outcomes"][number]; -// --------------------------------------------------------------------------- -// Summarization -// --------------------------------------------------------------------------- - function shareRatio(events: number, total: number): number { return total === 0 ? 0 : events / total; } @@ -121,10 +113,6 @@ function sumCounts(buckets: DroppedEventsResponse["droppedEvents"]): number { return buckets.reduce((sum, bucket) => sum + bucket.count, 0); } -// --------------------------------------------------------------------------- -// Human output -// --------------------------------------------------------------------------- - function formatShare(ratio: number): string { if (ratio > 0 && ratio < 0.0001) { return "<0.01%"; From c56bbb54cd41708aa067b1878c0469f789e85dab Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Thu, 22 Oct 2026 14:39:02 -0400 Subject: [PATCH 4/5] refactor(cli): Rename event dropped to event analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLI owner review: `event list` returns individual stored events, but dropped events have no stored detail — this command only shows aggregate counts, so `analytics` fits the ` ` scheme better than `dropped`. "Dropped" stays in the brief/description, which feeds the CLI skill, so agent discoverability is preserved. Also makes the bucket `tags` field optional (default []) so a tags-less API bucket no longer fails the whole response, matching find_dropped_events. Refs DAIN-1940 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli-docs/src/content/docs/contributing.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/event.md | 4 +-- .../event/{dropped.ts => analytics.ts} | 26 ++++++++------- packages/cli/src/commands/event/index.ts | 6 ++-- .../cli/src/lib/formatters/dropped-events.ts | 6 ++-- packages/cli/src/types/dropped-events.ts | 12 +++++-- ...ed.func.test.ts => analytics.func.test.ts} | 12 +++---- .../cli/test/lib/api/dropped-events.test.ts | 33 +++++++++++++++++++ 9 files changed, 73 insertions(+), 30 deletions(-) rename packages/cli/src/commands/event/{dropped.ts => analytics.ts} (76%) rename packages/cli/test/commands/event/{dropped.func.test.ts => analytics.func.test.ts} (94%) diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 9680d695b..49927bf9b 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -66,7 +66,7 @@ toolkit/ │ │ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload │ │ │ ├── docs/ # list, query │ │ │ ├── dsn/ # list -│ │ │ ├── event/ # dropped, list, send, view +│ │ │ ├── event/ # analytics, list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view │ │ │ ├── integration/ # delete, list │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 5b94cbdc2..43758b737 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -434,7 +434,7 @@ 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 dropped []` — Summarize events Sentry dropped +- `sentry event analytics []` — Analytics for events Sentry dropped - `sentry event send ` — Send a Sentry event → Full flags and examples: `references/event.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 6fbaaffb4..701016179 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -136,9 +136,9 @@ sentry event list PROJ-ABC -c prev sentry event list PROJ-ABC --json ``` -### `sentry event dropped []` +### `sentry event analytics []` -Summarize events Sentry dropped +Analytics for events Sentry dropped **Flags:** - `--dataset - Dropped-data type: errors, spans, logs, or metrics - (default: "errors")` diff --git a/packages/cli/src/commands/event/dropped.ts b/packages/cli/src/commands/event/analytics.ts similarity index 76% rename from packages/cli/src/commands/event/dropped.ts rename to packages/cli/src/commands/event/analytics.ts index a1fc4b989..d6568be16 100644 --- a/packages/cli/src/commands/event/dropped.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -19,33 +19,37 @@ import { } from "../../lib/time-range.js"; import type { DroppedEventsResponse } from "../../types/index.js"; -const COMMAND_NAME = "event dropped"; +const COMMAND_NAME = "event analytics"; const DATASETS = ["errors", "spans", "logs", "metrics"] as const; -type DroppedFlags = { +type AnalyticsFlags = { dataset: (typeof DATASETS)[number]; period?: TimeRange; json?: boolean; fields?: string[]; }; -export const droppedCommand = buildCommand({ +export const analyticsCommand = buildCommand({ docs: { - brief: "Summarize events Sentry dropped", + brief: "Analytics for events Sentry dropped", fullDescription: - "Summarize events Sentry received but dropped before storage, grouped by " + - "outcome and reason, with each slice's share of total volume.\n\n" + + "Aggregate analytics for events Sentry received but dropped before " + + "storage (rate-limited, filtered, invalid, abuse, client-discarded, " + + "cardinality-limited), grouped by outcome and reason with each slice's " + + "share of total volume.\n\n" + + "Unlike 'event list', which returns individual stored events, this shows " + + "only aggregate counts — dropped events have no stored detail to list.\n\n" + "Drops are either intentional (user-configured, e.g. sampling or inbound " + "filters) or unintentional (e.g. quota, abuse) — shown in the Tags column " + "and the JSON `tags` field.\n\n" + "Target the project with /, or omit it to detect from your " + "config or a local DSN.\n\n" + "Examples:\n" + - " sentry event dropped my-org/my-project\n" + - " sentry event dropped my-org/my-project --dataset spans\n" + - " sentry event dropped my-org/my-project --period 24h\n" + - " sentry event dropped my-org/my-project --json", + " sentry event analytics my-org/my-project\n" + + " sentry event analytics my-org/my-project --dataset spans\n" + + " sentry event analytics my-org/my-project --period 24h\n" + + " sentry event analytics my-org/my-project --json", }, output: { human: formatDroppedEvents, @@ -79,7 +83,7 @@ export const droppedCommand = buildCommand({ }, aliases: { t: "period" }, }, - async *func(this: SentryContext, flags: DroppedFlags, target?: string) { + async *func(this: SentryContext, flags: AnalyticsFlags, target?: string) { const { org, project } = await resolveProjectBoundFromArg( target, this.cwd, diff --git a/packages/cli/src/commands/event/index.ts b/packages/cli/src/commands/event/index.ts index 6d397f6ca..1343a05be 100644 --- a/packages/cli/src/commands/event/index.ts +++ b/packages/cli/src/commands/event/index.ts @@ -1,5 +1,5 @@ import { buildRouteMap } from "../../lib/route-map.js"; -import { droppedCommand } from "./dropped.js"; +import { analyticsCommand } from "./analytics.js"; import { listCommand } from "./list.js"; import { sendCommand } from "./send.js"; import { viewCommand } from "./view.js"; @@ -8,7 +8,7 @@ export const eventRoute = buildRouteMap({ routes: { view: viewCommand, list: listCommand, - dropped: droppedCommand, + analytics: analyticsCommand, send: sendCommand, }, defaultCommand: "view", @@ -18,7 +18,7 @@ export const eventRoute = buildRouteMap({ "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.\n" + - "Use 'sentry event dropped /' to summarize dropped events.\n" + + "Use 'sentry event analytics /' to summarize dropped events.\n" + "Use 'sentry event send -m ' to send a test event.", hideRoute: {}, }, diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index 8861fdc6c..e54be61d9 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -83,9 +83,9 @@ export function summarizeDroppedEvents( category: bucket.category, events: bucket.count, share: 0, - // Tags are per (outcome, reason), so they are stable across a reason's - // buckets — take them from the first bucket seen. - tags: bucket.tags ?? [], + // Tags are per (outcome, reason), stable across a reason's buckets, so + // take them from the first bucket seen. + tags: bucket.tags, }); } } diff --git a/packages/cli/src/types/dropped-events.ts b/packages/cli/src/types/dropped-events.ts index 5517e87c3..db173dbab 100644 --- a/packages/cli/src/types/dropped-events.ts +++ b/packages/cli/src/types/dropped-events.ts @@ -5,6 +5,7 @@ import { looseObject, number, object, + optional, pipe, string, } from "valibot"; @@ -27,9 +28,14 @@ export const DroppedEventsBucketSchema = pipe( start: pipe(number(), description("Bucket start (epoch ms)")), end: pipe(number(), description("Bucket end (epoch ms)")), count: pipe(number(), description("Events in this bucket")), - tags: pipe( - array(string()), - description("Drop classification tags: intentional or unintentional"), + // Optional with a [] default: the API omits tags on accepted buckets and + // may omit them on drops, which must not fail the whole response. + tags: optional( + pipe( + array(string()), + description("Drop classification tags: intentional or unintentional"), + ), + [], ), }), description("A dropped or accepted events bucket"), diff --git a/packages/cli/test/commands/event/dropped.func.test.ts b/packages/cli/test/commands/event/analytics.func.test.ts similarity index 94% rename from packages/cli/test/commands/event/dropped.func.test.ts rename to packages/cli/test/commands/event/analytics.func.test.ts index 3091e7a00..ba3e37f69 100644 --- a/packages/cli/test/commands/event/dropped.func.test.ts +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { droppedCommand } from "../../../src/commands/event/dropped.js"; +import { analyticsCommand } from "../../../src/commands/event/analytics.js"; import type { DroppedEventsResponse } from "../../../src/types/dropped-events.js"; vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { @@ -78,7 +78,7 @@ const RESPONSE: DroppedEventsResponse = { ], }; -describe("event dropped command func()", () => { +describe("event analytics command func()", () => { let getDroppedEventsSpy: ReturnType; let getProjectSpy: ReturnType; let resolveSpy: ReturnType; @@ -116,7 +116,7 @@ describe("event dropped command func()", () => { test("scopes the request to the resolved numeric project id", async () => { const { context } = createMockContext(); - const func = await droppedCommand.loader(); + const func = await analyticsCommand.loader(); await func.call( context, { dataset: "errors", json: true }, @@ -131,7 +131,7 @@ describe("event dropped command func()", () => { test("JSON output carries outcomes, shares, and tags", async () => { const { context, stdoutWrite } = createMockContext(); - const func = await droppedCommand.loader(); + const func = await analyticsCommand.loader(); await func.call( context, { dataset: "errors", json: true }, @@ -153,7 +153,7 @@ describe("event dropped command func()", () => { test("human output renders tables with a Tags column", async () => { const { context, stdoutWrite } = createMockContext(); - const func = await droppedCommand.loader(); + const func = await analyticsCommand.loader(); await func.call( context, { dataset: "errors", json: false }, @@ -175,7 +175,7 @@ describe("event dropped command func()", () => { }); const { context, stdoutWrite } = createMockContext(); - const func = await droppedCommand.loader(); + const func = await analyticsCommand.loader(); await func.call( context, { dataset: "errors", json: false }, diff --git a/packages/cli/test/lib/api/dropped-events.test.ts b/packages/cli/test/lib/api/dropped-events.test.ts index 52f0694bb..cbf82bdb9 100644 --- a/packages/cli/test/lib/api/dropped-events.test.ts +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -56,4 +56,37 @@ describe("getDroppedEvents", () => { expect(capturedUrl).toContain("dataset=spans"); expect(capturedUrl).toContain("project=12345"); }); + + test("defaults tags to [] when a bucket omits the field", async () => { + mockOk({ + meta: { dataset: "errors", start: 0, end: 3600, interval: 3600 }, + // No `tags` field — the API omits it on accepted buckets and may on drops. + droppedEvents: [ + { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "key_quota", + start: 0, + end: 3600, + count: 5, + }, + ], + acceptedEvents: [ + { + type: "error", + category: "error", + outcome: "accepted", + reason: "accepted", + start: 0, + end: 3600, + count: 10, + }, + ], + }); + + const response = await getDroppedEvents("my-org"); + expect(response.droppedEvents[0].tags).toEqual([]); + expect(response.acceptedEvents[0].tags).toEqual([]); + }); }); From 150eeb40ce23ac4e23adc4bb48973072fa3fc4b9 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Thu, 22 Oct 2026 15:32:26 -0400 Subject: [PATCH 5/5] fix(cli): Pass plain empty-state hint so plain output stays ANSI-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildCommand routes human hints through writeFooter → plainSafeMuted, which already mutes on a TTY and returns plain text under SENTRY_PLAIN_OUTPUT=1. Pre-wrapping the empty-state hint with muted() double-colored it, leaking ANSI into plain output. Refs DAIN-1940 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/event/analytics.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index d6568be16..f506af58d 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -1,7 +1,6 @@ import type { SentryContext } from "../../context.js"; import { getDroppedEvents, getProject } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { muted } from "../../lib/formatters/colors.js"; import { type DroppedEventsResult, DroppedEventsOutputSchema, @@ -109,8 +108,10 @@ export const analyticsCommand = buildCommand({ const result: DroppedEventsResult = summarizeDroppedEvents(response); yield new CommandOutput(result); if (result.outcomes.length === 0) { + // Plain string: buildCommand's writeFooter applies plainSafeMuted, which + // mutes on a TTY and stays plain under SENTRY_PLAIN_OUTPUT=1. return { - hint: muted(`No dropped events for ${org}/${project}.`), + hint: `No dropped events for ${org}/${project}.`, }; } return undefined;