From 8c296b2a6dbc5d5eff393171f171ca2426dcfce3 Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Wed, 21 Oct 2026 15:46:08 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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; From a2d437bc03308309777e9df5dbe9b010f86d3d3c Mon Sep 17 00:00:00 2001 From: Saraj Manes Date: Mon, 26 Oct 2026 13:58:11 -0400 Subject: [PATCH 6/6] refactor(cli): Flatten event analytics JSON and reuse house patterns Refs DAIN-1940 --- .../skills/sentry-cli/references/event.md | 26 +++- packages/cli/src/commands/event/analytics.ts | 97 +++++++++---- packages/cli/src/lib/api/dropped-events.ts | 13 +- .../cli/src/lib/formatters/dropped-events.ts | 127 +++++++++--------- .../commands/event/analytics.func.test.ts | 119 +++++++++++++--- .../cli/test/lib/api/dropped-events.test.ts | 15 +-- .../lib/formatters/dropped-events.test.ts | 56 ++++---- 7 files changed, 301 insertions(+), 152 deletions(-) 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 701016179..7b7915b95 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 @@ -142,13 +142,35 @@ Analytics for 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"` +- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "90d")` **JSON Fields** (use `--json --fields` to select specific fields): | Field | Type | Description | |-------|------|-------------| -| `outcomes` | array | | +| `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 + +# Narrow the time range +sentry event analytics my-org/my-project --period 24h + +# Select specific JSON fields +sentry event analytics my-org/my-project --json --fields outcome,reason,events +``` ### `sentry event send ` diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index f506af58d..d38e27db7 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -1,15 +1,19 @@ import type { SentryContext } from "../../context.js"; -import { getDroppedEvents, getProject } from "../../lib/api-client.js"; +import { getDroppedEvents } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { - type DroppedEventsResult, - DroppedEventsOutputSchema, + type DroppedEventRow, + DroppedEventRowSchema, formatDroppedEvents, summarizeDroppedEvents, } from "../../lib/formatters/dropped-events.js"; +import { ValidationError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { withProgress } from "../../lib/polling.js"; -import { resolveProjectBoundFromArg } from "../../lib/resolve-target.js"; +import { + resolveLogProjectId, + resolveProjectBoundFromArg, +} from "../../lib/resolve-target.js"; import { PERIOD_BRIEF, parsePeriod, @@ -20,11 +24,37 @@ import type { DroppedEventsResponse } from "../../types/index.js"; const COMMAND_NAME = "event analytics"; -const DATASETS = ["errors", "spans", "logs", "metrics"] as const; +/** + * Dataset aliases accepted for this command: user-facing name → API dataset. + * Mirrors the subset of `explore`'s table, so `error`/`span`/`log` singulars + * resolve the same way. Collapse into the shared dataset module when #1559 lands. + */ +const DATASET_ALIASES: Record = { + errors: "errors", + error: "errors", + spans: "spans", + span: "spans", + logs: "logs", + log: "logs", + metrics: "tracemetrics", +}; + +const VALID_DATASETS = ["errors", "spans", "logs", "metrics"] as const; + +export function parseDataset(value: string): string { + const resolved = DATASET_ALIASES[value.toLowerCase()]; + if (!resolved) { + throw new ValidationError( + `Invalid dataset "${value}". Must be one of: ${VALID_DATASETS.join(", ")}`, + "dataset", + ); + } + return resolved; +} type AnalyticsFlags = { - dataset: (typeof DATASETS)[number]; - period?: TimeRange; + dataset: string; + period: TimeRange; json?: boolean; fields?: string[]; }; @@ -35,7 +65,7 @@ export const analyticsCommand = buildCommand({ fullDescription: "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 " + + "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" + @@ -43,16 +73,30 @@ export const analyticsCommand = buildCommand({ "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 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", + "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: "Narrow the time range", + command: "sentry event analytics my-org/my-project --period 24h", + }, + { + description: "Select specific JSON fields", + command: + "sentry event analytics my-org/my-project --json --fields outcome,reason,events", + }, + ], }, output: { human: formatDroppedEvents, - schema: DroppedEventsOutputSchema, + schema: DroppedEventRowSchema, }, parameters: { positional: { @@ -68,8 +112,8 @@ export const analyticsCommand = buildCommand({ }, flags: { dataset: { - kind: "enum", - values: [...DATASETS], + kind: "parsed", + parse: parseDataset, brief: "Dropped-data type: errors, spans, logs, or metrics", default: "errors", }, @@ -77,7 +121,9 @@ export const analyticsCommand = buildCommand({ kind: "parsed", parse: parsePeriod, brief: PERIOD_BRIEF, - optional: true, + // The events-dropped endpoint defaults to 90d server-side; declare it + // here so --help shows it. + default: "90d", }, }, aliases: { t: "period" }, @@ -91,23 +137,22 @@ export const analyticsCommand = buildCommand({ // 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); + // can read; resolveLogProjectId is cache-first and hints on a 404. + const projectId = await resolveLogProjectId(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) : {}), + projectId: projectId === undefined ? undefined : String(projectId), + ...timeRangeToApiParams(flags.period), }), ); - const result: DroppedEventsResult = summarizeDroppedEvents(response); - yield new CommandOutput(result); - if (result.outcomes.length === 0) { + const rows: DroppedEventRow[] = summarizeDroppedEvents(response); + yield new CommandOutput(rows); + if (rows.length === 0) { // Plain string: buildCommand's writeFooter applies plainSafeMuted, which // mutes on a TTY and stays plain under SENTRY_PLAIN_OUTPUT=1. return { diff --git a/packages/cli/src/lib/api/dropped-events.ts b/packages/cli/src/lib/api/dropped-events.ts index 222102e44..06098b99f 100644 --- a/packages/cli/src/lib/api/dropped-events.ts +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -7,7 +7,7 @@ import { apiRequestToRegion } from "./infrastructure.js"; /** Options for {@link getDroppedEvents}. */ export type DroppedEventsOptions = { - /** Which dropped-data type to summarize. */ + /** 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; @@ -17,28 +17,24 @@ export type DroppedEventsOptions = { 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. + * and reason over the requested time range. Plain transport: `dataset` is sent + * as given, so callers must pass the API-level name (e.g. `tracemetrics`). */ export async function getDroppedEvents( orgSlug: string, 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: dataset === "metrics" ? "tracemetrics" : dataset, + dataset: options.dataset ?? "errors", project: options.projectId, statsPeriod: options.start || options.end @@ -46,7 +42,6 @@ export async function getDroppedEvents( : (options.statsPeriod ?? "90d"), start: options.start, end: options.end, - interval: options.interval, }, schema: DroppedEventsResponseSchema, }, diff --git a/packages/cli/src/lib/formatters/dropped-events.ts b/packages/cli/src/lib/formatters/dropped-events.ts index e54be61d9..331280a0d 100644 --- a/packages/cli/src/lib/formatters/dropped-events.ts +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -13,11 +13,20 @@ import { bold } from "./colors.js"; import { fmtCount } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; -const ReasonRowSchema = pipe( +/** One (outcome, reason) slice of dropped volume. One JSON row, one table line. */ +export const DroppedEventRowSchema = pipe( object({ - reason: pipe(string(), description("Drop reason (sub-classification)")), + 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 reason")), + events: pipe(number(), description("Events dropped for this slice")), share: pipe( number(), description("Fraction of total volume (0-1), not a percentage string"), @@ -27,50 +36,31 @@ const ReasonRowSchema = pipe( 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"), + description("A dropped-events row: one outcome/reason slice"), ); -export type DroppedEventsResult = InferOutput; -type ReasonRow = InferOutput; -type OutcomeSection = DroppedEventsResult["outcomes"][number]; +export type DroppedEventRow = InferOutput; 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. + * 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. The human formatter re-groups contiguous rows into outcome sections. */ export function summarizeDroppedEvents( response: DroppedEventsResponse, -): DroppedEventsResult { +): DroppedEventRow[] { const total = sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); - const byOutcome = new Map>(); + 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); @@ -78,35 +68,34 @@ export function summarizeDroppedEvents( 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, { - reason: bucket.reason, - category: bucket.category, events: bucket.count, - share: 0, - // Tags are per (outcome, reason), stable across a reason's buckets, so - // take them from the first bucket seen. + category: bucket.category, tags: bucket.tags, }); } } - const outcomes: OutcomeSection[] = [...byOutcome.entries()] + return [...byOutcome.entries()] .map(([outcome, reasons]) => { - const rows = [...reasons.values()] - .map((row) => ({ ...row, share: shareRatio(row.events, total) })) + 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 { - outcome, - label: outcomeLabel(outcome), - events, - share: shareRatio(events, total), - reasons: rows, - }; + return { events, rows }; }) - .sort((a, b) => b.events - a.events); - - return { outcomes }; + .sort((a, b) => b.events - a.events) + .flatMap((outcome) => outcome.rows); } function sumCounts(buckets: DroppedEventsResponse["droppedEvents"]): number { @@ -124,7 +113,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" }, @@ -133,20 +122,36 @@ const REASON_COLUMNS: Column[] = [ ]; /** - * 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. + * 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(result: DroppedEventsResult): string { - if (result.outcomes.length === 0) { +export function formatDroppedEvents(rows: DroppedEventRow[]): string { + if (rows.length === 0) { return ""; } - return result.outcomes - .map((section) => { + 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( - `${section.label}: ${fmtCount(section.events)} dropped (${formatShare(section.share)})`, + `${label}: ${fmtCount(events)} dropped (${formatShare(share)})`, ); - return `${header}\n${formatTable(section.reasons, REASON_COLUMNS)}`; + return `${header}\n${formatTable(section, REASON_COLUMNS)}`; }) .join("\n\n"); } diff --git a/packages/cli/test/commands/event/analytics.func.test.ts b/packages/cli/test/commands/event/analytics.func.test.ts index ba3e37f69..64b5536f0 100644 --- a/packages/cli/test/commands/event/analytics.func.test.ts +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -1,7 +1,31 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { analyticsCommand } from "../../../src/commands/event/analytics.js"; +import { + analyticsCommand, + parseDataset, +} from "../../../src/commands/event/analytics.js"; +import { ValidationError } from "../../../src/lib/errors.js"; import type { DroppedEventsResponse } from "../../../src/types/dropped-events.js"; +describe("event analytics parseDataset", () => { + test("maps the metrics alias to the tracemetrics API dataset", () => { + expect(parseDataset("metrics")).toBe("tracemetrics"); + }); + + test("accepts singular aliases like explore does", () => { + expect(parseDataset("error")).toBe("errors"); + expect(parseDataset("span")).toBe("spans"); + expect(parseDataset("log")).toBe("logs"); + }); + + test("is case-insensitive", () => { + expect(parseDataset("Metrics")).toBe("tracemetrics"); + }); + + test("rejects unknown datasets", () => { + expect(() => parseDataset("replays")).toThrow(ValidationError); + }); +}); + vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { const actual = await importOriginal(); @@ -80,7 +104,7 @@ const RESPONSE: DroppedEventsResponse = { describe("event analytics command func()", () => { let getDroppedEventsSpy: ReturnType; - let getProjectSpy: ReturnType; + let resolveProjectIdSpy: ReturnType; let resolveSpy: ReturnType; function createMockContext() { @@ -99,10 +123,9 @@ describe("event analytics command func()", () => { 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); + resolveProjectIdSpy = vi + .spyOn(resolveTarget, "resolveLogProjectId") + .mockResolvedValue(12345); getDroppedEventsSpy = vi .spyOn(apiClient, "getDroppedEvents") .mockResolvedValue(RESPONSE); @@ -110,7 +133,7 @@ describe("event analytics command func()", () => { afterEach(() => { resolveSpy.mockRestore(); - getProjectSpy.mockRestore(); + resolveProjectIdSpy.mockRestore(); getDroppedEventsSpy.mockRestore(); }); @@ -119,7 +142,11 @@ describe("event analytics command func()", () => { const func = await analyticsCommand.loader(); await func.call( context, - { dataset: "errors", json: true }, + { + dataset: "errors", + period: { type: "relative", period: "90d" }, + json: true, + }, "my-org/my-project", ); @@ -129,26 +156,76 @@ describe("event analytics command func()", () => { ); }); - test("JSON output carries outcomes, shares, and tags", async () => { + test("forwards the parsed dataset to the API unchanged", async () => { + const { context } = createMockContext(); + const func = await analyticsCommand.loader(); + // Mirror the flag pipeline: the dataset reaches func() already parsed, so + // feed parseDataset's output (metrics → tracemetrics) the same way stricli + // would. parseDataset's mapping is unit-tested separately above. + await func.call( + context, + { + dataset: parseDataset("metrics"), + period: { type: "relative", period: "90d" }, + json: true, + }, + "my-org/my-project", + ); + + expect(getDroppedEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ dataset: "tracemetrics" }), + ); + }); + + test("JSON output is a flat array of outcome/reason rows", async () => { const { context, stdoutWrite } = createMockContext(); const func = await analyticsCommand.loader(); await func.call( context, - { dataset: "errors", json: true }, + { + dataset: "errors", + period: { type: "relative", period: "90d" }, + 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([ + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.map((r: { outcome: string }) => r.outcome)).toEqual([ "client_discard", "rate_limited", + "rate_limited", ]); - const clientDiscard = parsed.outcomes[0]; - expect(clientDiscard.share).toBeCloseTo(0.1, 10); - expect(clientDiscard.reasons[0].tags).toEqual(["intentional"]); + expect(parsed[0].reason).toBe("sample_rate"); + expect(parsed[0].share).toBeCloseTo(0.1, 10); + expect(parsed[0].tags).toEqual(["intentional"]); // share is a raw ratio, not a formatted percentage string - expect(typeof clientDiscard.reasons[0].share).toBe("number"); + expect(typeof parsed[0].share).toBe("number"); + }); + + test("--fields selects a subset of columns on each row", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await analyticsCommand.loader(); + await func.call( + context, + { + dataset: "errors", + period: { type: "relative", period: "90d" }, + json: true, + fields: ["outcome", "reason", "events"], + }, + "my-org/my-project", + ); + + const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => c[0]).join("")); + expect(parsed[0]).toEqual({ + outcome: "client_discard", + reason: "sample_rate", + events: 100, + }); }); test("human output renders tables with a Tags column", async () => { @@ -156,7 +233,11 @@ describe("event analytics command func()", () => { const func = await analyticsCommand.loader(); await func.call( context, - { dataset: "errors", json: false }, + { + dataset: "errors", + period: { type: "relative", period: "90d" }, + json: false, + }, "my-org/my-project", ); @@ -178,7 +259,11 @@ describe("event analytics command func()", () => { const func = await analyticsCommand.loader(); await func.call( context, - { dataset: "errors", json: false }, + { + dataset: "errors", + period: { type: "relative", period: "90d" }, + json: false, + }, "my-org/my-project", ); diff --git a/packages/cli/test/lib/api/dropped-events.test.ts b/packages/cli/test/lib/api/dropped-events.test.ts index cbf82bdb9..3039612f9 100644 --- a/packages/cli/test/lib/api/dropped-events.test.ts +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -43,17 +43,14 @@ describe("getDroppedEvents", () => { expect(capturedUrl).toContain("dataset=errors"); }); - test("maps the metrics alias to the tracemetrics API dataset", async () => { + test("sends the dataset through unchanged and scopes by project id", async () => { mockOk(); - await getDroppedEvents("my-org", { dataset: "metrics" }); + // Plain transport: callers resolve aliases (metrics → tracemetrics) first. + await getDroppedEvents("my-org", { + dataset: "tracemetrics", + projectId: "12345", + }); 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 30fbea05e..0c3945b85 100644 --- a/packages/cli/test/lib/formatters/dropped-events.test.ts +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -46,48 +46,48 @@ const RESPONSE: DroppedEventsResponse = { }; describe("summarizeDroppedEvents", () => { - test("groups by outcome then reason, sorted by volume", () => { - const { outcomes } = summarizeDroppedEvents(RESPONSE); + test("flat rows grouped by outcome then reason, sorted by volume", () => { + const rows = summarizeDroppedEvents(RESPONSE); - // client_discard (100) > rate_limited (17) > filtered (3) - expect(outcomes.map((o) => o.outcome)).toEqual([ - "client_discard", - "rate_limited", - "filtered", + // Rows are contiguous by outcome: client_discard (100) > rate_limited (17) + // > filtered (3); reasons sorted by volume, same-reason buckets summed. + expect(rows.map((r) => [r.outcome, r.reason, r.events])).toEqual([ + ["client_discard", "sample_rate", 100], + ["rate_limited", "spike_protection", 15], + ["rate_limited", "usage_exceeded", 2], + ["filtered", "web-crawlers", 3], ]); + }); - 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("every row carries the outcome's human label", () => { + const rows = summarizeDroppedEvents(RESPONSE); + const byOutcome = Object.fromEntries(rows.map((r) => [r.outcome, r.label])); + expect(byOutcome.client_discard).toBe("Client Discard"); + expect(byOutcome.rate_limited).toBe("Rate Limited"); + expect(byOutcome.filtered).toBe("Inbound Filter"); }); test("share is a 0-1 ratio over total volume (dropped + accepted)", () => { - const { outcomes } = summarizeDroppedEvents(RESPONSE); + const rows = 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); + expect(rows[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("carries the drop-classification tag onto each row", () => { + const rows = summarizeDroppedEvents(RESPONSE); + const byOutcome = Object.fromEntries(rows.map((r) => [r.outcome, r.tags])); + expect(byOutcome.client_discard).toEqual(["intentional"]); + expect(byOutcome.rate_limited).toEqual(["unintentional"]); + expect(byOutcome.filtered).toEqual(["intentional"]); }); - test("empty response yields no outcomes", () => { - const { outcomes } = summarizeDroppedEvents({ + test("empty response yields no rows", () => { + const rows = summarizeDroppedEvents({ meta: { dataset: "errors", start: 0, end: 0, interval: 0 }, droppedEvents: [], acceptedEvents: [], }); - expect(outcomes).toEqual([]); + expect(rows).toEqual([]); }); }); @@ -103,6 +103,6 @@ describe("formatDroppedEvents", () => { }); test("empty result renders nothing", () => { - expect(formatDroppedEvents({ outcomes: [] })).toBe(""); + expect(formatDroppedEvents([])).toBe(""); }); });