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..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,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 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 b470357e7..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 @@ -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 accepted and 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: "7d")` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `outcome` | string | Outcome code: accepted, or a drop such as rate_limited or filtered | +| `label` | string | Human-readable outcome label | +| `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 in this slice | +| `share` | number | Fraction of total volume (0-1), not a percentage string | +| `tags` | array | Drop classification: intentional or unintentional; empty for accepted | + +**Examples:** + +```bash +# Accepted and 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..f628aecdc --- /dev/null +++ b/packages/cli/src/commands/event/analytics.ts @@ -0,0 +1,132 @@ +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 { + LIST_PERIOD_FLAG, + PERIOD_ALIASES, + PROJECT_TARGET_BRIEF, +} from "../../lib/list-command.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + ACCEPTED_OUTCOME, + type EventOutcomeRow, + formatEventOutcomes, + summarizeEventOutcomes, +} 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"; +import { + type ProjectBoundTarget, + projectBoundTarget, +} from "../../lib/target-positional.js"; +import { type TimeRange, timeRangeToApiParams } from "../../lib/time-range.js"; + +const { parse: parseDataset, names: DATASET_NAMES } = buildDatasetParser([ + "errors", + "spans", + "logs", + "tracemetrics", +]); + +type AnalyticsFlags = { + dataset: ReturnType; + period: TimeRange; + json?: boolean; + fields?: string[]; +}; + +export const analyticsCommand = buildCommand({ + docs: { + brief: "Analytics for events Sentry accepted and dropped", + fullDescription: + "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 " + + "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: "Accepted and 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: formatEventOutcomes, + schema: EventOutcomeRowSchema, + }, + 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: LIST_PERIOD_FLAG, + }, + aliases: { ...PERIOD_ALIASES, d: "dataset" }, + }, + 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 event analytics...", json: flags.json }, + () => + getDroppedEvents(org, { + dataset: flags.dataset, + projectId: projectId === undefined ? undefined : String(projectId), + ...timeRangeToApiParams(flags.period), + }), + ); + + const rows: EventOutcomeRow[] = summarizeEventOutcomes(response); + yield new CommandOutput(rows); + 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 3a21469ac..6e2c5ae7e 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 accepted and dropped event volume.", 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..9c46b15c8 --- /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. + */ + +import { + 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?: 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. */ + 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 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", + ); +} diff --git a/packages/cli/src/lib/formatters/event-outcomes.ts b/packages/cli/src/lib/formatters/event-outcomes.ts new file mode 100644 index 000000000..cfcbd3782 --- /dev/null +++ b/packages/cli/src/lib/formatters/event-outcomes.ts @@ -0,0 +1,163 @@ +import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; +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 { EventOutcomeRow }; + +/** 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": + 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; +} + +type Buckets = ListOrganizationEventsDroppedResponse["droppedEvents"]; + +function sumCounts(buckets: Buckets): number { + return buckets.reduce((sum, bucket) => sum + bucket.count, 0); +} + +/** + * Collapse buckets into one row per (outcome, reason), sorted by outcome volume + * then reason volume within each outcome. + */ +function collapseBuckets(buckets: Buckets, total: number): EventOutcomeRow[] { + const byOutcome = new Map< + string, + Map + >(); + for (const bucket of buckets) { + 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. 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 ?? [], + }); + } + } + + return [...byOutcome.entries()] + .map(([outcome, reasons]) => { + const rows: EventOutcomeRow[] = [...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); +} + +/** + * 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%"; + } + 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. 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 formatEventOutcomes(rows: EventOutcomeRow[]): string { + if (rows.length === 0) { + return ""; + } + const sections: Array<{ + outcome: string; + label: string; + rows: EventOutcomeRow[]; + }> = []; + 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(({ outcome, label, rows: section }) => { + const events = section.reduce((sum, row) => sum + row.events, 0); + 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 d807f98e0..86969e2fc 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,46 @@ export const IssueEventSchema = pipe( }), description("Issue event (list endpoint)"), ); + +const _DroppedEventsBucket = + vListOrganizationEventsDroppedResponse.entries.droppedEvents.item.entries; + +/** + * One (outcome, reason) slice of event volume in `event analytics`: the + * accepted total or one kind of drop. One JSON row each. + */ +export const EventOutcomeRowSchema = pipe( + object({ + outcome: pipe( + _DroppedEventsBucket.outcome, + 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( + "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 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; empty for accepted", + ), + ), + }), + description("An event-outcome row: one outcome/reason slice"), +); + +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 new file mode 100644 index 000000000..c92997d93 --- /dev/null +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -0,0 +1,167 @@ +import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; +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"; + +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 = {}, +): ListOrganizationEventsDroppedResponse { + return { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, + 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("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({ + 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..c18ebd2d2 --- /dev/null +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -0,0 +1,91 @@ +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"); + }); +}); diff --git a/packages/cli/test/lib/formatters/event-outcomes.test.ts b/packages/cli/test/lib/formatters/event-outcomes.test.ts new file mode 100644 index 000000000..81843f224 --- /dev/null +++ b/packages/cli/test/lib/formatters/event-outcomes.test.ts @@ -0,0 +1,193 @@ +import type { ListOrganizationEventsDroppedResponse } from "@sentry/api"; +import { describe, expect, test } from "vitest"; +import { + formatEventOutcomes, + summarizeEventOutcomes, +} from "../../../src/lib/formatters/event-outcomes.js"; + +function bucket( + overrides: Partial< + ListOrganizationEventsDroppedResponse["droppedEvents"][number] + >, +): ListOrganizationEventsDroppedResponse["droppedEvents"][number] { + return { + type: "error", + category: "error", + outcome: "rate_limited", + reason: "key_quota", + start: 0, + end: 1, + count: 1, + tags: [], + ...overrides, + }; +} + +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: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, + droppedEvents: [ + bucket({ reason: "key_quota", count: 3 }), + bucket({ reason: "key_quota", count: 2 }), + bucket({ outcome: "filtered", reason: "error_message", count: 4 }), + ], + acceptedEvents: [], + }; + + const rows = summarizeEventOutcomes(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: 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 }), + bucket({ outcome: "rate_limited", reason: "c", count: 5 }), + ], + acceptedEvents: [], + }; + + const rows = summarizeEventOutcomes(response); + + expect(rows.map((r) => r.reason)).toEqual(["b", "c", "a"]); + }); + + test("share is the slice over total dropped plus accepted", () => { + const response: ListOrganizationEventsDroppedResponse = { + meta: { + dataset: "errors", + start: 0, + end: 10, + interval: 1, + suppressedDrops: [], + }, + droppedEvents: [bucket({ count: 25 })], + 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 = summarizeEventOutcomes(response); + + 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", () => { + 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 = summarizeEventOutcomes(response); + + expect(rows[0]!.tags).toEqual([]); + expect(formatEventOutcomes(rows)).toContain("—"); + }); +}); + +describe("formatEventOutcomes", () => { + test("returns empty string for no rows", () => { + expect(formatEventOutcomes([])).toBe(""); + }); + + test("renders one section per outcome with a reason table", () => { + 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 }), + ], + acceptedEvents: [], + }; + + 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%)"); + }); +});