From 6cb682b8bd37aa6ab47799360842efae8bc11f81 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Mon, 2 Nov 2026 16:12:20 -0500 Subject: [PATCH 1/5] feat(cli): add event analytics command for dropped events Refs DAIN-1940 --- .../cli-docs/src/content/docs/contributing.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/event.md | 42 +++++ packages/cli/src/commands/event/analytics.ts | 140 ++++++++++++++++ packages/cli/src/commands/event/index.ts | 5 +- packages/cli/src/lib/api-client.ts | 4 + packages/cli/src/lib/api/dropped-events.ts | 57 +++++++ .../cli/src/lib/formatters/dropped-events.ts | 157 ++++++++++++++++++ packages/cli/src/types/dropped-events.ts | 72 ++++++++ packages/cli/src/types/index.ts | 10 ++ .../commands/event/analytics.func.test.ts | 130 +++++++++++++++ .../cli/test/lib/api/dropped-events.test.ts | 125 ++++++++++++++ .../lib/formatters/dropped-events.test.ts | 93 +++++++++++ 13 files changed, 836 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/event/analytics.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/analytics.func.test.ts create mode 100644 packages/cli/test/lib/api/dropped-events.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 eccf6d2b5..c63a543e2 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -67,7 +67,7 @@ toolkit/ │ │ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload │ │ │ ├── docs/ # list, query │ │ │ ├── dsn/ # list -│ │ │ ├── event/ # 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, tags, 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 ac0aa3623..0b04c2eee 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -452,6 +452,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 send ` — Send a Sentry event +- `sentry event analytics []` — Analytics for events Sentry dropped → 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 b470357e7..b0d3a5003 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 @@ -240,4 +240,46 @@ sentry event send my-project -m "Test" sentry event send my-org/my-project -m "Test" ``` +### `sentry event analytics []` + +Analytics for events Sentry dropped + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `[]` | <org>/<project> or <project> (search) | + +**Flags:** +- `-d, --dataset - Dropped-data type: errors, spans, logs, metrics - (default: "errors")` +- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "24h")` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `outcome` | string | Drop outcome code (e.g. rate_limited, filtered) | +| `label` | string | Human-readable outcome label | +| `reason` | string | Drop reason within the outcome (e.g. spike_protection) | +| `category` | string | Data category (e.g. error, span) | +| `events` | number | Events dropped for this slice | +| `share` | number | Fraction of total volume (0-1), not a percentage string | +| `tags` | array | Drop classification: intentional or unintentional | + +**Examples:** + +```bash +# Dropped-event analytics for a project +sentry event analytics my-org/my-project + +# Analytics for a different dataset +sentry event analytics my-org/my-project --dataset spans + +# Widen the time range +sentry event analytics my-org/my-project --period 7d + +# Select specific JSON fields +sentry event analytics my-org/my-project --json --fields outcome,reason,events +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts new file mode 100644 index 000000000..141d0b8eb --- /dev/null +++ b/packages/cli/src/commands/event/analytics.ts @@ -0,0 +1,140 @@ +import type { SentryContext } from "../../context.js"; +import { getDroppedEvents } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { choiceParser } from "../../lib/completion-target.js"; +import { buildDatasetParser } from "../../lib/datasets.js"; +import { PROJECT_TARGET_BRIEF } from "../../lib/list-command.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + type DroppedEventRow, + DroppedEventRowSchema, + formatDroppedEvents, + summarizeDroppedEvents, +} from "../../lib/formatters/dropped-events.js"; +import { withProgress } from "../../lib/polling.js"; +import { fetchProjectId } from "../../lib/resolve-target.js"; +import { + type ProjectBoundTarget, + projectBoundTarget, +} from "../../lib/target-positional.js"; +import { + PERIOD_BRIEF, + parsePeriod, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; + +const { parse: parseDataset, names: DATASET_NAMES } = buildDatasetParser([ + "errors", + "spans", + "logs", + "tracemetrics", +]); + +/** Default time period, matching the explore command. */ +const DEFAULT_PERIOD = "24h"; + +type AnalyticsFlags = { + dataset: string; + period: TimeRange; + json?: boolean; + fields?: string[]; +}; + +export const analyticsCommand = buildCommand({ + docs: { + brief: "Analytics for events Sentry dropped", + fullDescription: + "Aggregate analytics for events Sentry received but dropped before " + + "storage (rate-limited, filtered, invalid, abuse, client-discarded, " + + "cardinality-limited), one row per 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.", + examples: [ + { + description: "Dropped-event analytics for a project", + command: "sentry event analytics my-org/my-project", + }, + { + description: "Analytics for a different dataset", + command: "sentry event analytics my-org/my-project --dataset spans", + }, + { + description: "Widen the time range", + command: "sentry event analytics my-org/my-project --period 7d", + }, + { + description: "Select specific JSON fields", + command: + "sentry event analytics my-org/my-project --json --fields outcome,reason,events", + }, + ], + }, + output: { + human: formatDroppedEvents, + schema: DroppedEventRowSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: PROJECT_TARGET_BRIEF, + parse: projectBoundTarget, + optional: true, + }, + ], + }, + flags: { + dataset: { + kind: "parsed", + parse: choiceParser(DATASET_NAMES, parseDataset), + brief: `Dropped-data type: ${DATASET_NAMES.join(", ")}`, + default: "errors", + }, + period: { + kind: "parsed", + parse: parsePeriod, + brief: PERIOD_BRIEF, + default: DEFAULT_PERIOD, + }, + }, + aliases: { d: "dataset", t: "period" }, + }, + async *func( + this: SentryContext, + flags: AnalyticsFlags, + target: ProjectBoundTarget, + ) { + const { org, project } = await target.resolved; + + // The events-dropped `project` param takes a numeric ID, and the command + // sends no query — a missing ID would widen the request to every project. + // strict resolution errors on a bad lookup instead of silently doing that. + const projectId = await fetchProjectId(org, project, { strict: true }); + + const response = await withProgress( + { message: "Fetching dropped events...", json: flags.json }, + () => + getDroppedEvents(org, { + dataset: flags.dataset, + projectId: projectId === undefined ? undefined : String(projectId), + ...timeRangeToApiParams(flags.period), + }), + ); + + const rows: DroppedEventRow[] = summarizeDroppedEvents(response); + yield new CommandOutput(rows); + if (rows.length === 0) { + return { hint: `No dropped events for ${org}/${project}.` }; + } + return undefined; + }, +}); diff --git a/packages/cli/src/commands/event/index.ts b/packages/cli/src/commands/event/index.ts index 3a21469ac..2232be053 100644 --- a/packages/cli/src/commands/event/index.ts +++ b/packages/cli/src/commands/event/index.ts @@ -1,5 +1,6 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { topLevelBrief } from "../../top-level-briefs.js"; +import { analyticsCommand } from "./analytics.js"; import { listCommand } from "./list.js"; import { sendCommand } from "./send.js"; import { viewCommand } from "./view.js"; @@ -9,6 +10,7 @@ export const eventRoute = buildRouteMap({ view: viewCommand, list: listCommand, send: sendCommand, + analytics: analyticsCommand, }, defaultCommand: "view", docs: { @@ -17,7 +19,8 @@ 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 send -m ' to send a test event.", + "Use 'sentry event send -m ' to send a test event.\n" + + "Use 'sentry event analytics' to see events Sentry dropped.", hideRoute: {}, }, }); diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index f652704a2..f85bbde6e 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -53,6 +53,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..b33409005 --- /dev/null +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -0,0 +1,57 @@ +/** + * Dropped events API — events Sentry received but dropped before storage. + * + * Wraps the `events-dropped/` endpoint, which buckets dropped and accepted + * volume by outcome and reason over a time range. Not yet in `@sentry/api`, so + * it is called via `apiRequestToRegion` with a local Valibot schema. + */ + +import { + type DroppedEventsResponse, + DroppedEventsResponseSchema, +} from "../../types/index.js"; +import { resolveOrgRegion } from "../region.js"; +import { apiRequestToRegion } from "./infrastructure.js"; + +/** Options for {@link getDroppedEvents}. */ +export type DroppedEventsOptions = { + /** API-level dataset name. Callers resolve user-facing aliases first. */ + dataset?: string; + /** Numeric project ID. Omit to span every project the token can read. */ + projectId?: string; + /** Relative time period (e.g. "24h", "7d"). Mutually exclusive with start/end. */ + statsPeriod?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; +}; + +/** + * Fetch dropped and accepted events for an organization, bucketed by outcome + * and reason over the requested range. `dataset` is sent as given, so callers + * pass the API-level name (e.g. `tracemetrics`). When no time range is given, + * the endpoint defaults to 90d server-side. + */ +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, + start: options.start, + end: options.end, + }, + 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..f1a34e463 --- /dev/null +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -0,0 +1,157 @@ +import { + array, + description, + type InferOutput, + number, + object, + pipe, + string, +} from "valibot"; +import type { DroppedEventsResponse } from "../../types/index.js"; +import { outcomeLabel } from "../../types/index.js"; +import { bold } from "./colors.js"; +import { fmtCount } from "./numbers.js"; +import { type Column, formatTable } from "./table.js"; + +/** One (outcome, reason) slice of dropped volume. One JSON row, one table line. */ +export const DroppedEventRowSchema = pipe( + object({ + outcome: pipe( + string(), + description("Drop outcome code (e.g. rate_limited, filtered)"), + ), + label: pipe(string(), description("Human-readable outcome label")), + reason: pipe( + string(), + description("Drop reason within the outcome (e.g. spike_protection)"), + ), + category: pipe(string(), description("Data category (e.g. error, span)")), + events: pipe(number(), description("Events dropped for this slice")), + 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 dropped-events row: one outcome/reason slice"), +); + +export type DroppedEventRow = InferOutput; + +function shareRatio(events: number, total: number): number { + return total === 0 ? 0 : events / total; +} + +function sumCounts(buckets: DroppedEventsResponse["droppedEvents"]): number { + return buckets.reduce((sum, bucket) => sum + bucket.count, 0); +} + +/** + * Collapse the bucketed API response into flat rows, one per (outcome, reason), + * sorted by outcome volume then reason volume within each outcome. Share is each + * slice's fraction of total volume (dropped + accepted), matching the Explore + * flyout. + */ +export function summarizeDroppedEvents( + response: DroppedEventsResponse, +): DroppedEventRow[] { + const total = + sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); + + const byOutcome = new Map< + string, + 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 { + // Tags are per (outcome, reason), stable across a reason's buckets, so + // take them from the first bucket seen. + reasons.set(bucket.reason, { + events: bucket.count, + category: bucket.category, + tags: bucket.tags, + }); + } + } + + return [...byOutcome.entries()] + .map(([outcome, reasons]) => { + const rows: DroppedEventRow[] = [...reasons.entries()] + .map(([reason, r]) => ({ + outcome, + label: outcomeLabel(outcome), + reason, + category: r.category, + events: r.events, + share: shareRatio(r.events, total), + tags: r.tags, + })) + .sort((a, b) => b.events - a.events); + const events = rows.reduce((sum, row) => sum + row.events, 0); + return { events, rows }; + }) + .sort((a, b) => b.events - a.events) + .flatMap((outcome) => outcome.rows); +} + +function formatShare(ratio: number): string { + if (ratio > 0 && ratio < 0.0001) { + return "<0.01%"; + } + return `${(ratio * 100).toFixed(2)}%`; +} + +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 one block per outcome (header line + reason table). Rows arrive grouped + * by outcome (contiguous), so split on outcome change rather than re-grouping. + * Empty input yields an empty string — the command prints the "no dropped + * events" hint instead. + */ +export function formatDroppedEvents(rows: DroppedEventRow[]): string { + if (rows.length === 0) { + return ""; + } + const sections: Array<{ + outcome: string; + label: string; + rows: DroppedEventRow[]; + }> = []; + for (const row of rows) { + const last = sections.at(-1); + if (last && last.outcome === row.outcome) { + last.rows.push(row); + } else { + sections.push({ outcome: row.outcome, label: row.label, rows: [row] }); + } + } + return sections + .map(({ label, rows: section }) => { + const events = section.reduce((sum, row) => sum + row.events, 0); + const share = section.reduce((sum, row) => sum + row.share, 0); + const header = bold( + `${label}: ${fmtCount(events)} dropped (${formatShare(share)})`, + ); + return `${header}\n${formatTable(section, 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..dc83b7b48 --- /dev/null +++ b/packages/cli/src/types/dropped-events.ts @@ -0,0 +1,72 @@ +/** + * Dropped Events Types + * + * Types and Valibot schemas for the organizations events-dropped endpoint, + * which reports events Sentry received but dropped before storage, bucketed by + * outcome and reason over time. Mirrors the MCP `find_dropped_events` schema so + * the CLI and MCP agree on the response shape. + */ + +import { + array, + type InferOutput, + looseObject, + number, + object, + optional, + string, +} from "valibot"; + +/** + * One drop/accept bucket for a single (outcome, reason) over one interval. + * + * `looseObject` tolerates extra fields the endpoint may add. `tags` is optional + * with a `[]` default: the API omits it on accepted buckets and may omit it on + * drops, which must not fail validation of the whole response. + */ +export const DroppedEventsBucketSchema = looseObject({ + type: string(), + category: string(), + outcome: string(), + reason: string(), + start: number(), + end: number(), + count: number(), + tags: optional(array(string()), []), +}); + +export const DroppedEventsResponseSchema = object({ + meta: looseObject({ + dataset: string(), + start: number(), + end: number(), + interval: number(), + }), + droppedEvents: array(DroppedEventsBucketSchema), + acceptedEvents: array(DroppedEventsBucketSchema), +}); + +export type DroppedEventsBucket = InferOutput; +export type DroppedEventsResponse = InferOutput< + typeof DroppedEventsResponseSchema +>; + +/** Human-readable label for a drop outcome 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 5b92ad4ca..2829cf645 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -46,6 +46,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/analytics.func.test.ts b/packages/cli/test/commands/event/analytics.func.test.ts new file mode 100644 index 000000000..df7a45d2f --- /dev/null +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { analyticsCommand } from "../../../src/commands/event/analytics.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"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { parsePeriod } from "../../../src/lib/time-range.js"; +import type { DroppedEventsResponse } from "../../../src/types/index.js"; + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +function boundTarget(org: string, project: string) { + return { resolved: Promise.resolve({ org, project }) }; +} + +function response( + overrides: Partial = {}, +): DroppedEventsResponse { + return { + meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + droppedEvents: [], + acceptedEvents: [], + ...overrides, + }; +} + +let getDroppedEventsSpy: ReturnType; + +beforeEach(() => { + vi.spyOn(resolveTarget, "fetchProjectId").mockResolvedValue(42); + getDroppedEventsSpy = vi.spyOn(apiClient, "getDroppedEvents"); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("event analytics", () => { + test("sends the resolved numeric project id and API dataset", async () => { + getDroppedEventsSpy.mockResolvedValue(response()); + + const { context } = createMockContext(); + const func = await analyticsCommand.loader(); + await func.call( + context, + { dataset: "tracemetrics", period: parsePeriod("24h"), json: true }, + boundTarget("my-org", "my-proj"), + ); + + expect(getDroppedEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ + dataset: "tracemetrics", + projectId: "42", + statsPeriod: "24h", + }), + ); + }); + + test("renders a no-dropped-events hint when empty", async () => { + getDroppedEventsSpy.mockResolvedValue(response()); + + const { context, stdoutWrite } = createMockContext(); + const func = await analyticsCommand.loader(); + await func.call( + context, + { dataset: "errors", period: parsePeriod("90d"), json: false }, + boundTarget("my-org", "my-proj"), + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No dropped events for my-org/my-proj"); + }); + + test("emits summarized rows as JSON", async () => { + getDroppedEventsSpy.mockResolvedValue( + response({ + droppedEvents: [ + { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "key_quota", + start: 0, + end: 1, + count: 5, + tags: ["unintentional"], + }, + ], + }), + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await analyticsCommand.loader(); + await func.call( + context, + { dataset: "errors", period: parsePeriod("90d"), json: true }, + boundTarget("my-org", "my-proj"), + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + const rows = Array.isArray(parsed) ? parsed : parsed.data; + expect(rows[0].outcome).toBe("rate_limited"); + expect(rows[0].reason).toBe("key_quota"); + expect(rows[0].events).toBe(5); + }); +}); 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..b0626b9fa --- /dev/null +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getDroppedEvents } from "../../../src/lib/api/dropped-events.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +const ORG = "test-org"; + +function mockOk(body: unknown): { + getCapturedUrl: () => string; + getCapturedMethod: () => string; +} { + let capturedUrl = ""; + let capturedMethod = ""; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + return { + getCapturedUrl: () => capturedUrl, + getCapturedMethod: () => capturedMethod, + }; +} + +function emptyResponse() { + return { + meta: { dataset: "errors", start: 1, end: 2, interval: 3600 }, + droppedEvents: [], + acceptedEvents: [], + }; +} + +useTestConfigDir("dropped-events-api-test-"); + +let originalFetch: typeof globalThis.fetch; +beforeEach(() => { + originalFetch = globalThis.fetch; +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("getDroppedEvents", () => { + test("hits /organizations/{org}/events-dropped/ with GET", async () => { + const { getCapturedUrl, getCapturedMethod } = mockOk(emptyResponse()); + + await getDroppedEvents(ORG); + + expect(getCapturedMethod()).toBe("GET"); + expect(getCapturedUrl()).toContain( + `/api/0/organizations/${ORG}/events-dropped/`, + ); + }); + + test("forwards the dataset as given (API-level name)", async () => { + const { getCapturedUrl } = mockOk(emptyResponse()); + + await getDroppedEvents(ORG, { dataset: "tracemetrics" }); + + const url = new URL(getCapturedUrl()); + expect(url.searchParams.get("dataset")).toBe("tracemetrics"); + }); + + test("forwards project and statsPeriod", async () => { + const { getCapturedUrl } = mockOk(emptyResponse()); + + await getDroppedEvents(ORG, { projectId: "42", statsPeriod: "24h" }); + + const url = new URL(getCapturedUrl()); + expect(url.searchParams.get("project")).toBe("42"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + }); + + test("drops statsPeriod when an absolute range is given", async () => { + const { getCapturedUrl } = mockOk(emptyResponse()); + + await getDroppedEvents(ORG, { + statsPeriod: "24h", + start: "2026-01-01T00:00:00Z", + end: "2026-01-02T00:00:00Z", + }); + + const url = new URL(getCapturedUrl()); + expect(url.searchParams.has("statsPeriod")).toBe(false); + expect(url.searchParams.get("start")).toBe("2026-01-01T00:00:00Z"); + expect(url.searchParams.get("end")).toBe("2026-01-02T00:00:00Z"); + }); + + test("parses a response whose accepted bucket omits tags", async () => { + mockOk({ + meta: { dataset: "errors", start: 1, end: 2, interval: 3600 }, + droppedEvents: [ + { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "key_quota", + start: 1, + end: 2, + count: 5, + tags: ["unintentional"], + }, + ], + acceptedEvents: [ + { + type: "error", + category: "error", + outcome: "accepted", + reason: "none", + start: 1, + end: 2, + count: 9, + }, + ], + }); + + const result = await getDroppedEvents(ORG); + + expect(result.acceptedEvents[0]!.tags).toEqual([]); + expect(result.droppedEvents[0]!.tags).toEqual(["unintentional"]); + }); +}); 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..79de10c3e --- /dev/null +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import { + formatDroppedEvents, + summarizeDroppedEvents, +} from "../../../src/lib/formatters/dropped-events.js"; +import type { DroppedEventsResponse } from "../../../src/types/index.js"; + +function bucket( + overrides: Partial, +): DroppedEventsResponse["droppedEvents"][number] { + return { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "key_quota", + start: 0, + end: 1, + count: 1, + tags: [], + ...overrides, + }; +} + +describe("summarizeDroppedEvents", () => { + test("collapses buckets into one row per (outcome, reason)", () => { + const response: DroppedEventsResponse = { + meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + droppedEvents: [ + bucket({ reason: "key_quota", count: 3 }), + bucket({ reason: "key_quota", count: 2 }), + bucket({ outcome: "filtered", reason: "error_message", count: 4 }), + ], + acceptedEvents: [], + }; + + const rows = summarizeDroppedEvents(response); + + expect(rows).toHaveLength(2); + const quota = rows.find((r) => r.reason === "key_quota"); + expect(quota?.events).toBe(5); + }); + + test("sorts by outcome volume, then reason volume", () => { + const response: DroppedEventsResponse = { + meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + droppedEvents: [ + bucket({ outcome: "filtered", reason: "a", count: 1 }), + bucket({ outcome: "rate_limited", reason: "b", count: 10 }), + bucket({ outcome: "rate_limited", reason: "c", count: 5 }), + ], + acceptedEvents: [], + }; + + const rows = summarizeDroppedEvents(response); + + expect(rows.map((r) => r.reason)).toEqual(["b", "c", "a"]); + }); + + test("share is the slice over total dropped plus accepted", () => { + const response: DroppedEventsResponse = { + meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + droppedEvents: [bucket({ count: 25 })], + acceptedEvents: [bucket({ outcome: "accepted", count: 75 })], + }; + + const rows = summarizeDroppedEvents(response); + + expect(rows[0]!.share).toBeCloseTo(0.25); + }); +}); + +describe("formatDroppedEvents", () => { + test("returns empty string for no rows", () => { + expect(formatDroppedEvents([])).toBe(""); + }); + + test("renders one section per outcome with a reason table", () => { + const response: DroppedEventsResponse = { + meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + droppedEvents: [ + bucket({ outcome: "rate_limited", reason: "key_quota", count: 5 }), + bucket({ outcome: "filtered", reason: "error_message", count: 2 }), + ], + acceptedEvents: [], + }; + + const out = formatDroppedEvents(summarizeDroppedEvents(response)); + + expect(out).toContain("Rate Limited"); + expect(out).toContain("Inbound Filter"); + expect(out).toContain("key_quota"); + }); +}); From 870b28e8ffeccbf69497b2c8e1fc1c2f9a33bf75 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Wed, 4 Nov 2026 10:08:17 -0500 Subject: [PATCH 2/5] fix(cli): port event analytics to the generated output-schema system Refs DAIN-1940 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/event/analytics.ts | 2 +- .../cli/src/lib/formatters/dropped-events.ts | 38 +---------------- .../cli/src/output-schemas/dropped-events.ts | 41 +++++++++++++++++++ 3 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 packages/cli/src/output-schemas/dropped-events.ts diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index 141d0b8eb..c0e77e44b 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -7,10 +7,10 @@ import { PROJECT_TARGET_BRIEF } from "../../lib/list-command.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type DroppedEventRow, - DroppedEventRowSchema, formatDroppedEvents, summarizeDroppedEvents, } from "../../lib/formatters/dropped-events.js"; +import { DroppedEventRowSchema } from "../../generated/output-schemas/dropped-events.js"; import { withProgress } from "../../lib/polling.js"; import { fetchProjectId } from "../../lib/resolve-target.js"; import { diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index f1a34e463..8487e00ea 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -1,45 +1,11 @@ -import { - array, - description, - type InferOutput, - number, - object, - pipe, - string, -} from "valibot"; +import type { DroppedEventRow } from "../../output-schemas/dropped-events.js"; import type { DroppedEventsResponse } from "../../types/index.js"; import { outcomeLabel } from "../../types/index.js"; import { bold } from "./colors.js"; import { fmtCount } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; -/** One (outcome, reason) slice of dropped volume. One JSON row, one table line. */ -export const DroppedEventRowSchema = pipe( - object({ - outcome: pipe( - string(), - description("Drop outcome code (e.g. rate_limited, filtered)"), - ), - label: pipe(string(), description("Human-readable outcome label")), - reason: pipe( - string(), - description("Drop reason within the outcome (e.g. spike_protection)"), - ), - category: pipe(string(), description("Data category (e.g. error, span)")), - events: pipe(number(), description("Events dropped for this slice")), - 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 dropped-events row: one outcome/reason slice"), -); - -export type DroppedEventRow = InferOutput; +export type { DroppedEventRow }; function shareRatio(events: number, total: number): number { return total === 0 ? 0 : events / total; diff --git a/packages/cli/src/output-schemas/dropped-events.ts b/packages/cli/src/output-schemas/dropped-events.ts new file mode 100644 index 000000000..0f7b9d81d --- /dev/null +++ b/packages/cli/src/output-schemas/dropped-events.ts @@ -0,0 +1,41 @@ +/** + * Output schema of the `sentry event analytics` command. + */ + +import { + array, + description, + type InferOutput, + number, + object, + pipe, + string, +} from "valibot"; + +/** One (outcome, reason) slice of dropped volume. One JSON row, one table line. */ +export const DroppedEventRowSchema = pipe( + object({ + outcome: pipe( + string(), + description("Drop outcome code (e.g. rate_limited, filtered)"), + ), + label: pipe(string(), description("Human-readable outcome label")), + reason: pipe( + string(), + description("Drop reason within the outcome (e.g. spike_protection)"), + ), + category: pipe(string(), description("Data category (e.g. error, span)")), + events: pipe(number(), description("Events dropped for this slice")), + 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 dropped-events row: one outcome/reason slice"), +); + +export type DroppedEventRow = InferOutput; From 14a37710e29a04c0ec3629bf7e107dc4653340c1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:57:22 +0000 Subject: [PATCH 3/5] ref(cli): Call events-dropped through @sentry/api in event analytics - getDroppedEvents uses listOrganizationEventsDropped with getOrgSdkConfig and unwrapResult instead of a hand-written request and response schema. - The row schema moves to output-schemas/event.ts and takes its API fields from vListOrganizationEventsDroppedResponse. types/dropped-events.ts and output-schemas/dropped-events.ts are removed. - Tags still default to [] for servers older than getsentry/sentry#127850. - --period uses LIST_PERIOD_FLAG and PERIOD_ALIASES (7d default, -t). Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../skills/sentry-cli/references/event.md | 2 +- packages/cli/src/commands/event/analytics.ts | 29 +++----- packages/cli/src/lib/api/dropped-events.ts | 48 ++++++------- .../cli/src/lib/formatters/dropped-events.ts | 36 ++++++++-- .../cli/src/output-schemas/dropped-events.ts | 41 ----------- packages/cli/src/output-schemas/event.ts | 40 +++++++++++ packages/cli/src/types/dropped-events.ts | 72 ------------------- packages/cli/src/types/index.ts | 10 --- .../commands/event/analytics.func.test.ts | 14 ++-- .../cli/test/lib/api/dropped-events.test.ts | 34 --------- .../lib/formatters/dropped-events.test.ts | 68 +++++++++++++++--- 11 files changed, 171 insertions(+), 223 deletions(-) delete mode 100644 packages/cli/src/output-schemas/dropped-events.ts delete mode 100644 packages/cli/src/types/dropped-events.ts 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 b0d3a5003..99d41190d 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 @@ -252,7 +252,7 @@ Analytics for events Sentry dropped **Flags:** - `-d, --dataset - Dropped-data type: errors, spans, logs, metrics - (default: "errors")` -- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "24h")` +- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "7d")` **JSON Fields** (use `--json --fields` to select specific fields): diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index c0e77e44b..d7337e22e 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -3,26 +3,25 @@ import { getDroppedEvents } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser } from "../../lib/completion-target.js"; import { buildDatasetParser } from "../../lib/datasets.js"; -import { PROJECT_TARGET_BRIEF } from "../../lib/list-command.js"; +import { + LIST_PERIOD_FLAG, + PERIOD_ALIASES, + PROJECT_TARGET_BRIEF, +} from "../../lib/list-command.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type DroppedEventRow, formatDroppedEvents, summarizeDroppedEvents, } from "../../lib/formatters/dropped-events.js"; -import { DroppedEventRowSchema } from "../../generated/output-schemas/dropped-events.js"; +import { DroppedEventRowSchema } from "../../generated/output-schemas/event.js"; import { withProgress } from "../../lib/polling.js"; import { fetchProjectId } from "../../lib/resolve-target.js"; import { type ProjectBoundTarget, projectBoundTarget, } from "../../lib/target-positional.js"; -import { - PERIOD_BRIEF, - parsePeriod, - type TimeRange, - timeRangeToApiParams, -} from "../../lib/time-range.js"; +import { type TimeRange, timeRangeToApiParams } from "../../lib/time-range.js"; const { parse: parseDataset, names: DATASET_NAMES } = buildDatasetParser([ "errors", @@ -31,11 +30,8 @@ const { parse: parseDataset, names: DATASET_NAMES } = buildDatasetParser([ "tracemetrics", ]); -/** Default time period, matching the explore command. */ -const DEFAULT_PERIOD = "24h"; - type AnalyticsFlags = { - dataset: string; + dataset: ReturnType; period: TimeRange; json?: boolean; fields?: string[]; @@ -99,14 +95,9 @@ export const analyticsCommand = buildCommand({ brief: `Dropped-data type: ${DATASET_NAMES.join(", ")}`, default: "errors", }, - period: { - kind: "parsed", - parse: parsePeriod, - brief: PERIOD_BRIEF, - default: DEFAULT_PERIOD, - }, + period: LIST_PERIOD_FLAG, }, - aliases: { d: "dataset", t: "period" }, + aliases: { ...PERIOD_ALIASES, d: "dataset" }, }, async *func( this: SentryContext, diff --git a/packages/cli/src/lib/api/dropped-events.ts b/packages/cli/src/lib/api/dropped-events.ts index b33409005..9c46b15c8 100644 --- a/packages/cli/src/lib/api/dropped-events.ts +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -2,21 +2,20 @@ * Dropped events API — events Sentry received but dropped before storage. * * Wraps the `events-dropped/` endpoint, which buckets dropped and accepted - * volume by outcome and reason over a time range. Not yet in `@sentry/api`, so - * it is called via `apiRequestToRegion` with a local Valibot schema. + * volume by outcome and reason over a time range. */ import { - type DroppedEventsResponse, - DroppedEventsResponseSchema, -} from "../../types/index.js"; -import { resolveOrgRegion } from "../region.js"; -import { apiRequestToRegion } from "./infrastructure.js"; + type ListOrganizationEventsDroppedData, + type ListOrganizationEventsDroppedResponse, + listOrganizationEventsDropped, +} from "@sentry/api"; +import { getOrgSdkConfig, unwrapResult } from "./infrastructure.js"; /** Options for {@link getDroppedEvents}. */ export type DroppedEventsOptions = { /** API-level dataset name. Callers resolve user-facing aliases first. */ - dataset?: string; + dataset?: ListOrganizationEventsDroppedData["query"]["dataset"]; /** Numeric project ID. Omit to span every project the token can read. */ projectId?: string; /** Relative time period (e.g. "24h", "7d"). Mutually exclusive with start/end. */ @@ -36,22 +35,23 @@ export type DroppedEventsOptions = { 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, - start: options.start, - end: options.end, - }, - schema: DroppedEventsResponseSchema, +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await listOrganizationEventsDropped({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: options.dataset ?? "errors", + project: + options.projectId === undefined ? undefined : [options.projectId], + statsPeriod: + options.start || options.end ? undefined : options.statsPeriod, + start: options.start, + end: options.end, }, + }); + return unwrapResult( + result, + "Failed to fetch dropped events", ); - return data; } diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index 8487e00ea..9fb47bdd0 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -1,17 +1,38 @@ -import type { DroppedEventRow } from "../../output-schemas/dropped-events.js"; -import type { DroppedEventsResponse } from "../../types/index.js"; -import { outcomeLabel } from "../../types/index.js"; +import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; +import type { DroppedEventRow } from "../../output-schemas/event.js"; import { bold } from "./colors.js"; import { fmtCount } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; export type { DroppedEventRow }; +/** Human-readable label for a drop outcome code. */ +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; + } +} + function shareRatio(events: number, total: number): number { return total === 0 ? 0 : events / total; } -function sumCounts(buckets: DroppedEventsResponse["droppedEvents"]): number { +function sumCounts( + buckets: ListOrganizationEventsDroppedResponse["droppedEvents"], +): number { return buckets.reduce((sum, bucket) => sum + bucket.count, 0); } @@ -22,7 +43,7 @@ function sumCounts(buckets: DroppedEventsResponse["droppedEvents"]): number { * flyout. */ export function summarizeDroppedEvents( - response: DroppedEventsResponse, + response: ListOrganizationEventsDroppedResponse, ): DroppedEventRow[] { const total = sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); @@ -39,11 +60,12 @@ export function summarizeDroppedEvents( existing.events += bucket.count; } else { // Tags are per (outcome, reason), stable across a reason's buckets, so - // take them from the first bucket seen. + // take them from the first bucket seen. Sentry added them in + // getsentry/sentry#127850; older self-hosted servers omit them. reasons.set(bucket.reason, { events: bucket.count, category: bucket.category, - tags: bucket.tags, + tags: bucket.tags ?? [], }); } } diff --git a/packages/cli/src/output-schemas/dropped-events.ts b/packages/cli/src/output-schemas/dropped-events.ts deleted file mode 100644 index 0f7b9d81d..000000000 --- a/packages/cli/src/output-schemas/dropped-events.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Output schema of the `sentry event analytics` command. - */ - -import { - array, - description, - type InferOutput, - number, - object, - pipe, - string, -} from "valibot"; - -/** One (outcome, reason) slice of dropped volume. One JSON row, one table line. */ -export const DroppedEventRowSchema = pipe( - object({ - outcome: pipe( - string(), - description("Drop outcome code (e.g. rate_limited, filtered)"), - ), - label: pipe(string(), description("Human-readable outcome label")), - reason: pipe( - string(), - description("Drop reason within the outcome (e.g. spike_protection)"), - ), - category: pipe(string(), description("Data category (e.g. error, span)")), - events: pipe(number(), description("Events dropped for this slice")), - 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 dropped-events row: one outcome/reason slice"), -); - -export type DroppedEventRow = InferOutput; diff --git a/packages/cli/src/output-schemas/event.ts b/packages/cli/src/output-schemas/event.ts index d807f98e0..e3c30e647 100644 --- a/packages/cli/src/output-schemas/event.ts +++ b/packages/cli/src/output-schemas/event.ts @@ -6,12 +6,15 @@ import { vEventAttachmentDetailsResponse, vGroupEventsResponseDict, vIssueEventDetailsResponse, + vListOrganizationEventsDroppedResponse, } from "@sentry/api/valibot"; import { array, description, + type InferOutput, looseObject, nullable, + number, object, optional, partial, @@ -141,3 +144,40 @@ export const IssueEventSchema = pipe( }), description("Issue event (list endpoint)"), ); + +const _DroppedEventsBucket = + vListOrganizationEventsDroppedResponse.entries.droppedEvents.item.entries; + +/** + * One (outcome, reason) slice of dropped volume in `event analytics`: one + * JSON row, one table line. + */ +export const DroppedEventRowSchema = pipe( + object({ + outcome: pipe( + _DroppedEventsBucket.outcome, + description("Drop outcome code (e.g. rate_limited, filtered)"), + ), + label: pipe(string(), description("Human-readable outcome label")), + reason: pipe( + _DroppedEventsBucket.reason, + description("Drop reason within the outcome (e.g. spike_protection)"), + ), + category: pipe( + _DroppedEventsBucket.category, + description("Data category (e.g. error, span)"), + ), + events: pipe(number(), description("Events dropped for this slice")), + share: pipe( + number(), + description("Fraction of total volume (0-1), not a percentage string"), + ), + tags: pipe( + _DroppedEventsBucket.tags, + description("Drop classification: intentional or unintentional"), + ), + }), + description("A dropped-events row: one outcome/reason slice"), +); + +export type DroppedEventRow = InferOutput; diff --git a/packages/cli/src/types/dropped-events.ts b/packages/cli/src/types/dropped-events.ts deleted file mode 100644 index dc83b7b48..000000000 --- a/packages/cli/src/types/dropped-events.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Dropped Events Types - * - * Types and Valibot schemas for the organizations events-dropped endpoint, - * which reports events Sentry received but dropped before storage, bucketed by - * outcome and reason over time. Mirrors the MCP `find_dropped_events` schema so - * the CLI and MCP agree on the response shape. - */ - -import { - array, - type InferOutput, - looseObject, - number, - object, - optional, - string, -} from "valibot"; - -/** - * One drop/accept bucket for a single (outcome, reason) over one interval. - * - * `looseObject` tolerates extra fields the endpoint may add. `tags` is optional - * with a `[]` default: the API omits it on accepted buckets and may omit it on - * drops, which must not fail validation of the whole response. - */ -export const DroppedEventsBucketSchema = looseObject({ - type: string(), - category: string(), - outcome: string(), - reason: string(), - start: number(), - end: number(), - count: number(), - tags: optional(array(string()), []), -}); - -export const DroppedEventsResponseSchema = object({ - meta: looseObject({ - dataset: string(), - start: number(), - end: number(), - interval: number(), - }), - droppedEvents: array(DroppedEventsBucketSchema), - acceptedEvents: array(DroppedEventsBucketSchema), -}); - -export type DroppedEventsBucket = InferOutput; -export type DroppedEventsResponse = InferOutput< - typeof DroppedEventsResponseSchema ->; - -/** Human-readable label for a drop outcome 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 2829cf645..5b92ad4ca 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -46,16 +46,6 @@ 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/analytics.func.test.ts b/packages/cli/test/commands/event/analytics.func.test.ts index df7a45d2f..e42a7c4b4 100644 --- a/packages/cli/test/commands/event/analytics.func.test.ts +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -1,3 +1,4 @@ +import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { analyticsCommand } from "../../../src/commands/event/analytics.js"; @@ -17,7 +18,6 @@ import * as apiClient from "../../../src/lib/api-client.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import { parsePeriod } from "../../../src/lib/time-range.js"; -import type { DroppedEventsResponse } from "../../../src/types/index.js"; function createMockContext() { const stdoutWrite = vi.fn(() => true); @@ -36,10 +36,16 @@ function boundTarget(org: string, project: string) { } function response( - overrides: Partial = {}, -): DroppedEventsResponse { + overrides: Partial = {}, +): ListOrganizationEventsDroppedResponse { return { - meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, droppedEvents: [], acceptedEvents: [], ...overrides, diff --git a/packages/cli/test/lib/api/dropped-events.test.ts b/packages/cli/test/lib/api/dropped-events.test.ts index b0626b9fa..c18ebd2d2 100644 --- a/packages/cli/test/lib/api/dropped-events.test.ts +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -88,38 +88,4 @@ describe("getDroppedEvents", () => { expect(url.searchParams.get("start")).toBe("2026-01-01T00:00:00Z"); expect(url.searchParams.get("end")).toBe("2026-01-02T00:00:00Z"); }); - - test("parses a response whose accepted bucket omits tags", async () => { - mockOk({ - meta: { dataset: "errors", start: 1, end: 2, interval: 3600 }, - droppedEvents: [ - { - type: "error", - category: "error", - outcome: "rate_limited", - reason: "key_quota", - start: 1, - end: 2, - count: 5, - tags: ["unintentional"], - }, - ], - acceptedEvents: [ - { - type: "error", - category: "error", - outcome: "accepted", - reason: "none", - start: 1, - end: 2, - count: 9, - }, - ], - }); - - const result = await getDroppedEvents(ORG); - - expect(result.acceptedEvents[0]!.tags).toEqual([]); - expect(result.droppedEvents[0]!.tags).toEqual(["unintentional"]); - }); }); diff --git a/packages/cli/test/lib/formatters/dropped-events.test.ts b/packages/cli/test/lib/formatters/dropped-events.test.ts index 79de10c3e..9e8958a5b 100644 --- a/packages/cli/test/lib/formatters/dropped-events.test.ts +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -1,13 +1,15 @@ +import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; import { describe, expect, test } from "vitest"; import { formatDroppedEvents, summarizeDroppedEvents, } from "../../../src/lib/formatters/dropped-events.js"; -import type { DroppedEventsResponse } from "../../../src/types/index.js"; function bucket( - overrides: Partial, -): DroppedEventsResponse["droppedEvents"][number] { + overrides: Partial< + ListOrganizationEventsDroppedResponse["droppedEvents"][number] + >, +): ListOrganizationEventsDroppedResponse["droppedEvents"][number] { return { type: "error", category: "error", @@ -23,8 +25,14 @@ function bucket( describe("summarizeDroppedEvents", () => { test("collapses buckets into one row per (outcome, reason)", () => { - const response: DroppedEventsResponse = { - meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, droppedEvents: [ bucket({ reason: "key_quota", count: 3 }), bucket({ reason: "key_quota", count: 2 }), @@ -41,8 +49,14 @@ describe("summarizeDroppedEvents", () => { }); test("sorts by outcome volume, then reason volume", () => { - const response: DroppedEventsResponse = { - meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, droppedEvents: [ bucket({ outcome: "filtered", reason: "a", count: 1 }), bucket({ outcome: "rate_limited", reason: "b", count: 10 }), @@ -57,8 +71,14 @@ describe("summarizeDroppedEvents", () => { }); test("share is the slice over total dropped plus accepted", () => { - const response: DroppedEventsResponse = { - meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, droppedEvents: [bucket({ count: 25 })], acceptedEvents: [bucket({ outcome: "accepted", count: 75 })], }; @@ -67,6 +87,26 @@ describe("summarizeDroppedEvents", () => { expect(rows[0]!.share).toBeCloseTo(0.25); }); + + test("defaults tags to [] when an older server omits them", () => { + const { tags: _tags, ...untagged } = bucket({ count: 3 }); + const response = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, + droppedEvents: [untagged], + acceptedEvents: [], + } as unknown as ListOrganizationEventsDroppedResponse; + + const rows = summarizeDroppedEvents(response); + + expect(rows[0]!.tags).toEqual([]); + expect(formatDroppedEvents(rows)).toContain("—"); + }); }); describe("formatDroppedEvents", () => { @@ -75,8 +115,14 @@ describe("formatDroppedEvents", () => { }); test("renders one section per outcome with a reason table", () => { - const response: DroppedEventsResponse = { - meta: { dataset: "errors", start: 0, end: 10, interval: 1 }, + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, droppedEvents: [ bucket({ outcome: "rate_limited", reason: "key_quota", count: 5 }), bucket({ outcome: "filtered", reason: "error_message", count: 2 }), From f536c4c58a1ffe14a094d54c45a8324d8944ca92 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:52:31 +0000 Subject: [PATCH 4/5] feat(cli): Show accepted volume in event analytics event analytics only listed drops, though the endpoint returns the accepted volume and share was already computed against it. BYK asked on #1579 for accepted volume so this is a real analytics command. - Accepted volume comes first as its own outcome row (Sentry reports outcome and reason `accepted`, no tags). Drop rows follow, sorted as before. - The human output shows accepted as a header line with no reason table. - The "No dropped events" hint now fires when only accepted rows exist. - The row schema, summarizer and formatter are renamed from DroppedEvent* to EventOutcome*, since rows are no longer only drops. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/event.md | 12 +-- packages/cli/src/commands/event/analytics.ts | 31 +++---- packages/cli/src/commands/event/index.ts | 2 +- .../cli/src/lib/formatters/dropped-events.ts | 76 +++++++++++------- packages/cli/src/output-schemas/event.ts | 24 +++--- .../commands/event/analytics.func.test.ts | 31 +++++++ .../lib/formatters/dropped-events.test.ts | 80 ++++++++++++++++--- 8 files changed, 184 insertions(+), 74 deletions(-) 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 0b04c2eee..8e0d47ff9 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -452,7 +452,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 send ` — Send a Sentry event -- `sentry event analytics []` — Analytics for events Sentry dropped +- `sentry event analytics []` — Analytics for events Sentry accepted and dropped → 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 99d41190d..37607dbc2 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 @@ -242,7 +242,7 @@ sentry event send my-org/my-project -m "Test" ### `sentry event analytics []` -Analytics for events Sentry dropped +Analytics for events Sentry accepted and dropped **Arguments:** @@ -258,18 +258,18 @@ Analytics for events Sentry dropped | Field | Type | Description | |-------|------|-------------| -| `outcome` | string | Drop outcome code (e.g. rate_limited, filtered) | +| `outcome` | string | Outcome code: accepted, or a drop such as rate_limited or filtered | | `label` | string | Human-readable outcome label | -| `reason` | string | Drop reason within the outcome (e.g. spike_protection) | +| `reason` | string | Reason within the outcome (e.g. spike_protection); accepted for accepted volume | | `category` | string | Data category (e.g. error, span) | -| `events` | number | Events dropped for this slice | +| `events` | number | Events in this slice | | `share` | number | Fraction of total volume (0-1), not a percentage string | -| `tags` | array | Drop classification: intentional or unintentional | +| `tags` | array | Drop classification: intentional or unintentional; empty for accepted | **Examples:** ```bash -# Dropped-event analytics for a project +# Accepted and dropped event analytics for a project sentry event analytics my-org/my-project # Analytics for a different dataset diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index d7337e22e..75a51548a 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -10,11 +10,12 @@ import { } from "../../lib/list-command.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { - type DroppedEventRow, - formatDroppedEvents, - summarizeDroppedEvents, + ACCEPTED_OUTCOME, + type EventOutcomeRow, + formatEventOutcomes, + summarizeEventOutcomes, } from "../../lib/formatters/dropped-events.js"; -import { DroppedEventRowSchema } from "../../generated/output-schemas/event.js"; +import { EventOutcomeRowSchema } from "../../generated/output-schemas/event.js"; import { withProgress } from "../../lib/polling.js"; import { fetchProjectId } from "../../lib/resolve-target.js"; import { @@ -39,12 +40,12 @@ type AnalyticsFlags = { export const analyticsCommand = buildCommand({ docs: { - brief: "Analytics for events Sentry dropped", + brief: "Analytics for events Sentry accepted and dropped", fullDescription: - "Aggregate analytics for events Sentry received but dropped before " + - "storage (rate-limited, filtered, invalid, abuse, client-discarded, " + - "cardinality-limited), one row per outcome and reason with each slice's " + - "share of total volume.\n\n" + + "Aggregate analytics for events Sentry received: the accepted volume, " + + "then the events dropped before storage (rate-limited, filtered, " + + "invalid, abuse, client-discarded, cardinality-limited), one row per " + + "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 " + @@ -54,7 +55,7 @@ export const analyticsCommand = buildCommand({ "config or a local DSN.", examples: [ { - description: "Dropped-event analytics for a project", + description: "Accepted and dropped event analytics for a project", command: "sentry event analytics my-org/my-project", }, { @@ -73,8 +74,8 @@ export const analyticsCommand = buildCommand({ ], }, output: { - human: formatDroppedEvents, - schema: DroppedEventRowSchema, + human: formatEventOutcomes, + schema: EventOutcomeRowSchema, }, parameters: { positional: { @@ -112,7 +113,7 @@ export const analyticsCommand = buildCommand({ const projectId = await fetchProjectId(org, project, { strict: true }); const response = await withProgress( - { message: "Fetching dropped events...", json: flags.json }, + { message: "Fetching event analytics...", json: flags.json }, () => getDroppedEvents(org, { dataset: flags.dataset, @@ -121,9 +122,9 @@ export const analyticsCommand = buildCommand({ }), ); - const rows: DroppedEventRow[] = summarizeDroppedEvents(response); + const rows: EventOutcomeRow[] = summarizeEventOutcomes(response); yield new CommandOutput(rows); - if (rows.length === 0) { + if (rows.every((row) => row.outcome === ACCEPTED_OUTCOME)) { return { hint: `No dropped events for ${org}/${project}.` }; } return undefined; diff --git a/packages/cli/src/commands/event/index.ts b/packages/cli/src/commands/event/index.ts index 2232be053..6e2c5ae7e 100644 --- a/packages/cli/src/commands/event/index.ts +++ b/packages/cli/src/commands/event/index.ts @@ -20,7 +20,7 @@ export const eventRoute = buildRouteMap({ "Use 'sentry event view ' to view a specific event.\n" + "Use 'sentry event list ' to list events for an issue.\n" + "Use 'sentry event send -m ' to send a test event.\n" + - "Use 'sentry event analytics' to see events Sentry dropped.", + "Use 'sentry event analytics' to see accepted and dropped event volume.", hideRoute: {}, }, }); diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index 9fb47bdd0..cfcbd3782 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -1,14 +1,19 @@ import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; -import type { DroppedEventRow } from "../../output-schemas/event.js"; +import type { EventOutcomeRow } from "../../output-schemas/event.js"; import { bold } from "./colors.js"; import { fmtCount } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; -export type { DroppedEventRow }; +export type { EventOutcomeRow }; -/** Human-readable label for a drop outcome code. */ +/** Outcome (and reason) Sentry reports for accepted volume. */ +export const ACCEPTED_OUTCOME = "accepted"; + +/** Human-readable label for an outcome code. */ function outcomeLabel(outcome: string): string { switch (outcome) { + case ACCEPTED_OUTCOME: + return "Accepted"; case "client_discard": return "Client Discard"; case "filtered": @@ -30,29 +35,22 @@ function shareRatio(events: number, total: number): number { return total === 0 ? 0 : events / total; } -function sumCounts( - buckets: ListOrganizationEventsDroppedResponse["droppedEvents"], -): number { +type Buckets = ListOrganizationEventsDroppedResponse["droppedEvents"]; + +function sumCounts(buckets: Buckets): number { return buckets.reduce((sum, bucket) => sum + bucket.count, 0); } /** - * Collapse the bucketed API response into flat rows, one per (outcome, reason), - * sorted by outcome volume then reason volume within each outcome. Share is each - * slice's fraction of total volume (dropped + accepted), matching the Explore - * flyout. + * Collapse buckets into one row per (outcome, reason), sorted by outcome volume + * then reason volume within each outcome. */ -export function summarizeDroppedEvents( - response: ListOrganizationEventsDroppedResponse, -): DroppedEventRow[] { - const total = - sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); - +function collapseBuckets(buckets: Buckets, total: number): EventOutcomeRow[] { const byOutcome = new Map< string, Map >(); - for (const bucket of response.droppedEvents) { + for (const bucket of buckets) { const reasons = byOutcome.get(bucket.outcome) ?? new Map(); byOutcome.set(bucket.outcome, reasons); const existing = reasons.get(bucket.reason); @@ -72,7 +70,7 @@ export function summarizeDroppedEvents( return [...byOutcome.entries()] .map(([outcome, reasons]) => { - const rows: DroppedEventRow[] = [...reasons.entries()] + const rows: EventOutcomeRow[] = [...reasons.entries()] .map(([reason, r]) => ({ outcome, label: outcomeLabel(outcome), @@ -90,6 +88,23 @@ export function summarizeDroppedEvents( .flatMap((outcome) => outcome.rows); } +/** + * Collapse the bucketed API response into flat rows: the accepted volume first + * (Sentry reports it as outcome and reason `accepted`), then one row per drop + * (outcome, reason). Share is each slice's fraction of total volume (dropped + + * accepted), matching the Explore flyout. + */ +export function summarizeEventOutcomes( + response: ListOrganizationEventsDroppedResponse, +): EventOutcomeRow[] { + const total = + sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); + return [ + ...collapseBuckets(response.acceptedEvents, total), + ...collapseBuckets(response.droppedEvents, total), + ]; +} + function formatShare(ratio: number): string { if (ratio > 0 && ratio < 0.0001) { return "<0.01%"; @@ -101,7 +116,7 @@ function formatTags(tags: string[]): string { return tags.length > 0 ? tags.join(", ") : "—"; } -const REASON_COLUMNS: Column[] = [ +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" }, @@ -110,19 +125,19 @@ const REASON_COLUMNS: Column[] = [ ]; /** - * Render one block per outcome (header line + reason table). Rows arrive grouped - * by outcome (contiguous), so split on outcome change rather than re-grouping. - * Empty input yields an empty string — the command prints the "no dropped - * events" hint instead. + * Render one block per outcome. Accepted volume is a single header line; each + * drop outcome gets a header line plus a reason table. Rows arrive grouped by + * outcome (contiguous), so split on outcome change rather than re-grouping. + * Empty input yields an empty string — the command prints a hint instead. */ -export function formatDroppedEvents(rows: DroppedEventRow[]): string { +export function formatEventOutcomes(rows: EventOutcomeRow[]): string { if (rows.length === 0) { return ""; } const sections: Array<{ outcome: string; label: string; - rows: DroppedEventRow[]; + rows: EventOutcomeRow[]; }> = []; for (const row of rows) { const last = sections.at(-1); @@ -133,12 +148,15 @@ export function formatDroppedEvents(rows: DroppedEventRow[]): string { } } return sections - .map(({ label, rows: section }) => { + .map(({ outcome, label, rows: section }) => { const events = section.reduce((sum, row) => sum + row.events, 0); - const share = section.reduce((sum, row) => sum + row.share, 0); - const header = bold( - `${label}: ${fmtCount(events)} dropped (${formatShare(share)})`, + const share = formatShare( + section.reduce((sum, row) => sum + row.share, 0), ); + if (outcome === ACCEPTED_OUTCOME) { + return bold(`${label}: ${fmtCount(events)} (${share})`); + } + const header = bold(`${label}: ${fmtCount(events)} dropped (${share})`); return `${header}\n${formatTable(section, REASON_COLUMNS)}`; }) .join("\n\n"); diff --git a/packages/cli/src/output-schemas/event.ts b/packages/cli/src/output-schemas/event.ts index e3c30e647..86969e2fc 100644 --- a/packages/cli/src/output-schemas/event.ts +++ b/packages/cli/src/output-schemas/event.ts @@ -149,35 +149,41 @@ const _DroppedEventsBucket = vListOrganizationEventsDroppedResponse.entries.droppedEvents.item.entries; /** - * One (outcome, reason) slice of dropped volume in `event analytics`: one - * JSON row, one table line. + * One (outcome, reason) slice of event volume in `event analytics`: the + * accepted total or one kind of drop. One JSON row each. */ -export const DroppedEventRowSchema = pipe( +export const EventOutcomeRowSchema = pipe( object({ outcome: pipe( _DroppedEventsBucket.outcome, - description("Drop outcome code (e.g. rate_limited, filtered)"), + description( + "Outcome code: accepted, or a drop such as rate_limited or filtered", + ), ), label: pipe(string(), description("Human-readable outcome label")), reason: pipe( _DroppedEventsBucket.reason, - description("Drop reason within the outcome (e.g. spike_protection)"), + description( + "Reason within the outcome (e.g. spike_protection); accepted for accepted volume", + ), ), category: pipe( _DroppedEventsBucket.category, description("Data category (e.g. error, span)"), ), - events: pipe(number(), description("Events dropped for this slice")), + events: pipe(number(), description("Events in this slice")), share: pipe( number(), description("Fraction of total volume (0-1), not a percentage string"), ), tags: pipe( _DroppedEventsBucket.tags, - description("Drop classification: intentional or unintentional"), + description( + "Drop classification: intentional or unintentional; empty for accepted", + ), ), }), - description("A dropped-events row: one outcome/reason slice"), + description("An event-outcome row: one outcome/reason slice"), ); -export type DroppedEventRow = InferOutput; +export type EventOutcomeRow = InferOutput; diff --git a/packages/cli/test/commands/event/analytics.func.test.ts b/packages/cli/test/commands/event/analytics.func.test.ts index e42a7c4b4..c92997d93 100644 --- a/packages/cli/test/commands/event/analytics.func.test.ts +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -100,6 +100,37 @@ describe("event analytics", () => { expect(output).toContain("No dropped events for my-org/my-proj"); }); + test("hints that nothing was dropped when only accepted volume exists", async () => { + getDroppedEventsSpy.mockResolvedValue( + response({ + acceptedEvents: [ + { + type: "error", + category: "error", + outcome: "accepted", + reason: "accepted", + start: 0, + end: 1, + count: 7, + tags: [], + }, + ], + }), + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await analyticsCommand.loader(); + await func.call( + context, + { dataset: "errors", period: parsePeriod("90d"), json: false }, + boundTarget("my-org", "my-proj"), + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Accepted: 7 (100.00%)"); + expect(output).toContain("No dropped events for my-org/my-proj"); + }); + test("emits summarized rows as JSON", async () => { getDroppedEventsSpy.mockResolvedValue( response({ diff --git a/packages/cli/test/lib/formatters/dropped-events.test.ts b/packages/cli/test/lib/formatters/dropped-events.test.ts index 9e8958a5b..57c889ee1 100644 --- a/packages/cli/test/lib/formatters/dropped-events.test.ts +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -1,8 +1,8 @@ import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; import { describe, expect, test } from "vitest"; import { - formatDroppedEvents, - summarizeDroppedEvents, + formatEventOutcomes, + summarizeEventOutcomes, } from "../../../src/lib/formatters/dropped-events.js"; function bucket( @@ -23,7 +23,13 @@ function bucket( }; } -describe("summarizeDroppedEvents", () => { +function accepted( + count: number, +): ListOrganizationEventsDroppedResponse["acceptedEvents"][number] { + return bucket({ outcome: "accepted", reason: "accepted", count, tags: [] }); +} + +describe("summarizeEventOutcomes", () => { test("collapses buckets into one row per (outcome, reason)", () => { const response: ListOrganizationEventsDroppedResponse = { meta: { @@ -41,7 +47,7 @@ describe("summarizeDroppedEvents", () => { acceptedEvents: [], }; - const rows = summarizeDroppedEvents(response); + const rows = summarizeEventOutcomes(response); expect(rows).toHaveLength(2); const quota = rows.find((r) => r.reason === "key_quota"); @@ -65,7 +71,7 @@ describe("summarizeDroppedEvents", () => { acceptedEvents: [], }; - const rows = summarizeDroppedEvents(response); + const rows = summarizeEventOutcomes(response); expect(rows.map((r) => r.reason)).toEqual(["b", "c", "a"]); }); @@ -80,12 +86,39 @@ describe("summarizeDroppedEvents", () => { suppressedDrops: [], }, droppedEvents: [bucket({ count: 25 })], - acceptedEvents: [bucket({ outcome: "accepted", count: 75 })], + acceptedEvents: [accepted(75)], + }; + + const rows = summarizeEventOutcomes(response); + + expect(rows.map((r) => [r.outcome, r.share])).toEqual([ + ["accepted", 0.75], + ["rate_limited", 0.25], + ]); + }); + + test("lists accepted volume first, even when drops outnumber it", () => { + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, + droppedEvents: [bucket({ count: 90 })], + acceptedEvents: [accepted(4), accepted(6)], }; - const rows = summarizeDroppedEvents(response); + const rows = summarizeEventOutcomes(response); - expect(rows[0]!.share).toBeCloseTo(0.25); + expect(rows[0]).toMatchObject({ + outcome: "accepted", + label: "Accepted", + events: 10, + tags: [], + }); + expect(rows[1]!.outcome).toBe("rate_limited"); }); test("defaults tags to [] when an older server omits them", () => { @@ -102,16 +135,16 @@ describe("summarizeDroppedEvents", () => { acceptedEvents: [], } as unknown as ListOrganizationEventsDroppedResponse; - const rows = summarizeDroppedEvents(response); + const rows = summarizeEventOutcomes(response); expect(rows[0]!.tags).toEqual([]); - expect(formatDroppedEvents(rows)).toContain("—"); + expect(formatEventOutcomes(rows)).toContain("—"); }); }); -describe("formatDroppedEvents", () => { +describe("formatEventOutcomes", () => { test("returns empty string for no rows", () => { - expect(formatDroppedEvents([])).toBe(""); + expect(formatEventOutcomes([])).toBe(""); }); test("renders one section per outcome with a reason table", () => { @@ -130,10 +163,31 @@ describe("formatDroppedEvents", () => { acceptedEvents: [], }; - const out = formatDroppedEvents(summarizeDroppedEvents(response)); + const out = formatEventOutcomes(summarizeEventOutcomes(response)); expect(out).toContain("Rate Limited"); expect(out).toContain("Inbound Filter"); expect(out).toContain("key_quota"); }); + + test("renders accepted volume as a header line without a table", () => { + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, + droppedEvents: [bucket({ reason: "key_quota", count: 1 })], + acceptedEvents: [accepted(3)], + }; + + const out = formatEventOutcomes(summarizeEventOutcomes(response)); + const [acceptedSection, droppedSection] = out.split("\n\n"); + + expect(acceptedSection).toContain("Accepted: 3 (75.00%)"); + expect(acceptedSection).not.toContain("Reason"); + expect(droppedSection).toContain("Rate Limited: 1 dropped (25.00%)"); + }); }); From 0a6ce7d1b6a0ad970fd7bb2b10f313077fd6f582 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:28:34 +0000 Subject: [PATCH 5/5] ref(cli): Rename the event analytics formatter to event-outcomes The formatter renders accepted and dropped volume now, so the dropped-events file name was stale next to the EventOutcome* exports. lib/api/dropped-events.ts keeps its name: it wraps the events-dropped endpoint (listOrganizationEventsDropped). Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/src/commands/event/analytics.ts | 2 +- .../src/lib/formatters/{dropped-events.ts => event-outcomes.ts} | 0 .../{dropped-events.test.ts => event-outcomes.test.ts} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/cli/src/lib/formatters/{dropped-events.ts => event-outcomes.ts} (100%) rename packages/cli/test/lib/formatters/{dropped-events.test.ts => event-outcomes.test.ts} (98%) diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index 75a51548a..f628aecdc 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -14,7 +14,7 @@ import { type EventOutcomeRow, formatEventOutcomes, summarizeEventOutcomes, -} from "../../lib/formatters/dropped-events.js"; +} from "../../lib/formatters/event-outcomes.js"; import { EventOutcomeRowSchema } from "../../generated/output-schemas/event.js"; import { withProgress } from "../../lib/polling.js"; import { fetchProjectId } from "../../lib/resolve-target.js"; diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/event-outcomes.ts similarity index 100% rename from packages/cli/src/lib/formatters/dropped-events.ts rename to packages/cli/src/lib/formatters/event-outcomes.ts diff --git a/packages/cli/test/lib/formatters/dropped-events.test.ts b/packages/cli/test/lib/formatters/event-outcomes.test.ts similarity index 98% rename from packages/cli/test/lib/formatters/dropped-events.test.ts rename to packages/cli/test/lib/formatters/event-outcomes.test.ts index 57c889ee1..81843f224 100644 --- a/packages/cli/test/lib/formatters/dropped-events.test.ts +++ b/packages/cli/test/lib/formatters/event-outcomes.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { formatEventOutcomes, summarizeEventOutcomes, -} from "../../../src/lib/formatters/dropped-events.js"; +} from "../../../src/lib/formatters/event-outcomes.js"; function bucket( overrides: Partial<