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..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 @@ -136,6 +136,42 @@ 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" - (default: "90d")` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `outcome` | string | Drop outcome code (e.g. rate_limited, filtered) | +| `label` | string | Human-readable outcome label | +| `reason` | string | Drop reason within the outcome (e.g. spike_protection) | +| `category` | string | Data category (e.g. error, span) | +| `events` | number | Events dropped for this slice | +| `share` | number | Fraction of total volume (0-1), not a percentage string | +| `tags` | array | Drop classification: intentional or unintentional | + +**Examples:** + +```bash +# Dropped-event analytics for a project +sentry event analytics my-org/my-project + +# Analytics for a different dataset +sentry event analytics my-org/my-project --dataset spans + +# 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 ` 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..d38e27db7 --- /dev/null +++ b/packages/cli/src/commands/event/analytics.ts @@ -0,0 +1,164 @@ +import type { SentryContext } from "../../context.js"; +import { getDroppedEvents } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { + 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 { + resolveLogProjectId, + 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"; + +/** + * 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: string; + period: TimeRange; + json?: boolean; + fields?: string[]; +}; + +export const analyticsCommand = buildCommand({ + docs: { + brief: "Analytics for events Sentry dropped", + fullDescription: + "Aggregate analytics for events Sentry received but dropped before " + + "storage (rate-limited, filtered, invalid, abuse, client-discarded, " + + "cardinality-limited), one row per outcome and reason with each slice's " + + "share of total volume.\n\n" + + "Unlike 'event list', which returns individual stored events, this shows " + + "only aggregate counts — dropped events have no stored detail to list.\n\n" + + "Drops are either intentional (user-configured, e.g. sampling or inbound " + + "filters) or unintentional (e.g. quota, abuse) — shown in the Tags column " + + "and the JSON `tags` field.\n\n" + + "Target the project with /, or omit it to detect from your " + + "config or a local DSN.", + examples: [ + { + description: "Dropped-event analytics for a project", + command: "sentry event analytics my-org/my-project", + }, + { + description: "Analytics for a different dataset", + command: "sentry event analytics my-org/my-project --dataset spans", + }, + { + description: "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: DroppedEventRowSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "/ (omit to auto-detect)", + parse: String, + optional: true, + }, + ], + }, + flags: { + dataset: { + kind: "parsed", + parse: parseDataset, + brief: "Dropped-data type: errors, spans, logs, or metrics", + default: "errors", + }, + period: { + kind: "parsed", + parse: parsePeriod, + brief: PERIOD_BRIEF, + // The events-dropped endpoint defaults to 90d server-side; declare it + // here so --help shows it. + default: "90d", + }, + }, + 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; resolveLogProjectId is cache-first and hints on a 404. + const projectId = await resolveLogProjectId(org, project); + + const response: DroppedEventsResponse = await withProgress( + { message: "Fetching dropped events...", json: flags.json }, + () => + getDroppedEvents(org, { + dataset: flags.dataset, + projectId: projectId === undefined ? undefined : String(projectId), + ...timeRangeToApiParams(flags.period), + }), + ); + + const rows: DroppedEventRow[] = summarizeDroppedEvents(response); + yield new CommandOutput(rows); + if (rows.length === 0) { + // 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..06098b99f --- /dev/null +++ b/packages/cli/src/lib/api/dropped-events.ts @@ -0,0 +1,50 @@ +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 = { + /** API-level dataset name. Callers resolve user-facing aliases first. */ + dataset?: string; + /** Numeric project ID. Omit to span every project the token can read. */ + projectId?: string; + /** Relative time 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; +}; + +/** + * Fetch dropped and accepted events for an organization, bucketed by outcome + * 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); + 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, + }, + 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..331280a0d --- /dev/null +++ b/packages/cli/src/lib/formatters/dropped-events.ts @@ -0,0 +1,157 @@ +import { + array, + description, + type InferOutput, + number, + object, + pipe, + string, +} from "valibot"; +import type { DroppedEventsResponse } from "../../types/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"; + +/** One (outcome, reason) slice of dropped volume. One JSON row, one table line. */ +export const DroppedEventRowSchema = pipe( + object({ + outcome: pipe( + string(), + description("Drop outcome code (e.g. rate_limited, filtered)"), + ), + label: pipe(string(), description("Human-readable outcome label")), + reason: pipe( + string(), + description("Drop reason within the outcome (e.g. spike_protection)"), + ), + category: pipe(string(), description("Data category (e.g. error, span)")), + events: pipe(number(), description("Events dropped for this slice")), + share: pipe( + number(), + description("Fraction of total volume (0-1), not a percentage string"), + ), + tags: pipe( + array(string()), + description("Drop classification: intentional or unintentional"), + ), + }), + description("A dropped-events row: one outcome/reason slice"), +); + +export type DroppedEventRow = InferOutput; + +function shareRatio(events: number, total: number): number { + return total === 0 ? 0 : events / total; +} + +/** + * 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, +): DroppedEventRow[] { + const total = + sumCounts(response.droppedEvents) + sumCounts(response.acceptedEvents); + + const byOutcome = new Map< + string, + Map + >(); + for (const bucket of response.droppedEvents) { + const reasons = byOutcome.get(bucket.outcome) ?? new Map(); + byOutcome.set(bucket.outcome, reasons); + const existing = reasons.get(bucket.reason); + if (existing) { + existing.events += bucket.count; + } else { + // Tags are per (outcome, reason), stable across a reason's buckets, so + // take them from the first bucket seen. + reasons.set(bucket.reason, { + events: bucket.count, + category: bucket.category, + tags: bucket.tags, + }); + } + } + + return [...byOutcome.entries()] + .map(([outcome, reasons]) => { + const rows: DroppedEventRow[] = [...reasons.entries()] + .map(([reason, r]) => ({ + outcome, + label: outcomeLabel(outcome), + reason, + category: r.category, + events: r.events, + share: shareRatio(r.events, total), + tags: r.tags, + })) + .sort((a, b) => b.events - a.events); + const events = rows.reduce((sum, row) => sum + row.events, 0); + return { events, rows }; + }) + .sort((a, b) => b.events - a.events) + .flatMap((outcome) => outcome.rows); +} + +function 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 one block per outcome (header line + reason table). Rows arrive grouped + * by outcome (contiguous), so split on outcome change rather than re-grouping. + * Empty input yields an empty string — the command prints the "no dropped + * events" hint instead. + */ +export function formatDroppedEvents(rows: DroppedEventRow[]): string { + if (rows.length === 0) { + return ""; + } + const sections: Array<{ + outcome: string; + label: string; + rows: DroppedEventRow[]; + }> = []; + for (const row of rows) { + const last = sections.at(-1); + if (last && last.outcome === row.outcome) { + last.rows.push(row); + } else { + sections.push({ outcome: row.outcome, label: row.label, rows: [row] }); + } + } + return sections + .map(({ label, rows: section }) => { + const events = section.reduce((sum, row) => sum + row.events, 0); + const share = section.reduce((sum, row) => sum + row.share, 0); + const header = bold( + `${label}: ${fmtCount(events)} dropped (${formatShare(share)})`, + ); + return `${header}\n${formatTable(section, REASON_COLUMNS)}`; + }) + .join("\n\n"); +} diff --git a/packages/cli/src/types/dropped-events.ts b/packages/cli/src/types/dropped-events.ts new file mode 100644 index 000000000..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..64b5536f0 --- /dev/null +++ b/packages/cli/test/commands/event/analytics.func.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +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(); + 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 resolveProjectIdSpy: 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" }); + resolveProjectIdSpy = vi + .spyOn(resolveTarget, "resolveLogProjectId") + .mockResolvedValue(12345); + getDroppedEventsSpy = vi + .spyOn(apiClient, "getDroppedEvents") + .mockResolvedValue(RESPONSE); + }); + + afterEach(() => { + resolveSpy.mockRestore(); + resolveProjectIdSpy.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", + period: { type: "relative", period: "90d" }, + json: true, + }, + "my-org/my-project", + ); + + expect(getDroppedEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ dataset: "errors", projectId: "12345" }), + ); + }); + + 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", + 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(Array.isArray(parsed)).toBe(true); + expect(parsed.map((r: { outcome: string }) => r.outcome)).toEqual([ + "client_discard", + "rate_limited", + "rate_limited", + ]); + 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 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 () => { + const { context, stdoutWrite } = createMockContext(); + const func = await analyticsCommand.loader(); + await func.call( + context, + { + dataset: "errors", + period: { type: "relative", period: "90d" }, + 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", + period: { type: "relative", period: "90d" }, + 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..3039612f9 --- /dev/null +++ b/packages/cli/test/lib/api/dropped-events.test.ts @@ -0,0 +1,89 @@ +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("sends the dataset through unchanged and scopes by project id", async () => { + mockOk(); + // Plain transport: callers resolve aliases (metrics → tracemetrics) first. + await getDroppedEvents("my-org", { + dataset: "tracemetrics", + projectId: "12345", + }); + expect(capturedUrl).toContain("dataset=tracemetrics"); + 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..0c3945b85 --- /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("flat rows grouped by outcome then reason, sorted by volume", () => { + const rows = summarizeDroppedEvents(RESPONSE); + + // 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], + ]); + }); + + 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 rows = summarizeDroppedEvents(RESPONSE); + // total = 100 + 17 + 3 + 880 = 1000 + expect(rows[0].share).toBeCloseTo(0.1, 10); + }); + + 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 rows", () => { + const rows = summarizeDroppedEvents({ + meta: { dataset: "errors", start: 0, end: 0, interval: 0 }, + droppedEvents: [], + acceptedEvents: [], + }); + expect(rows).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([])).toBe(""); + }); +});