diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..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/ # 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 933993124..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,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 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 0c5da357d..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,6 +136,20 @@ sentry event list PROJ-ABC -c prev sentry event list PROJ-ABC --json ``` +### `sentry event analytics []` + +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"` + +**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/analytics.ts b/packages/cli/src/commands/event/analytics.ts new file mode 100644 index 000000000..f506af58d --- /dev/null +++ b/packages/cli/src/commands/event/analytics.ts @@ -0,0 +1,119 @@ +import type { SentryContext } from "../../context.js"; +import { getDroppedEvents, getProject } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { + type DroppedEventsResult, + DroppedEventsOutputSchema, + formatDroppedEvents, + 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 = "event analytics"; + +const DATASETS = ["errors", "spans", "logs", "metrics"] as const; + +type AnalyticsFlags = { + dataset: (typeof DATASETS)[number]; + period?: TimeRange; + json?: boolean; + fields?: string[]; +}; + +export const analyticsCommand = buildCommand({ + docs: { + brief: "Analytics for events Sentry dropped", + fullDescription: + "Aggregate analytics for events Sentry received but dropped before " + + "storage (rate-limited, filtered, invalid, abuse, client-discarded, " + + "cardinality-limited), 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 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, + 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: AnalyticsFlags, 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) { + // Plain string: buildCommand's writeFooter applies plainSafeMuted, which + // mutes on a TTY and stays plain under SENTRY_PLAIN_OUTPUT=1. + 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 70888d552..1343a05be 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 { analyticsCommand } from "./analytics.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, + analytics: analyticsCommand, 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 analytics /' 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..222102e44 --- /dev/null +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -0,0 +1,55 @@ +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); + // 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, + 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..e54be61d9 --- /dev/null +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -0,0 +1,152 @@ +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"; + +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]; + +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); + + 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), stable across a reason's buckets, so + // 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); +} + +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 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..db173dbab --- /dev/null +++ b/packages/cli/src/types/dropped-events.ts @@ -0,0 +1,83 @@ +import { + array, + description, + type InferOutput, + looseObject, + number, + object, + optional, + 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")), + // 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"), +); + +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 +>; + +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/analytics.func.test.ts b/packages/cli/test/commands/event/analytics.func.test.ts new file mode 100644 index 000000000..ba3e37f69 --- /dev/null +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +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) => { + 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 analytics 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 analyticsCommand.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 analyticsCommand.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 analyticsCommand.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 analyticsCommand.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/api/dropped-events.test.ts b/packages/cli/test/lib/api/dropped-events.test.ts new file mode 100644 index 000000000..cbf82bdb9 --- /dev/null +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -0,0 +1,92 @@ +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"); + }); + + 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([]); + }); +}); 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..30fbea05e --- /dev/null +++ b/packages/cli/test/lib/formatters/dropped-events.test.ts @@ -0,0 +1,108 @@ +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(""); + }); +});