Repository navigation
feat(cli): Add event analytics command #1579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8c296b2
28ed097
b974db5
c56bbb5
150eeb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,6 +136,20 @@ sentry event list PROJ-ABC -c prev | |
| sentry event list PROJ-ABC --json | ||
| ``` | ||
|
|
||
| ### `sentry event analytics [<org/project>]` | ||
|
|
||
| Analytics for events Sentry dropped | ||
|
|
||
| **Flags:** | ||
| - `--dataset <value> - Dropped-data type: errors, spans, logs, or metrics - (default: "errors")` | ||
| - `-t, --period <value> - 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 | | | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not a useful output shape. Array of what? If |
||
|
|
||
| ### `sentry event send <target-or-file...>` | ||
|
|
||
| Send a Sentry event | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to my comments in #1559 (review), we need a unified place for dataset list, not per-command. |
||
|
|
||
| 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 <org>/<project>, or omit it to detect from your " + | ||
| "config or a local DSN.\n\n" + | ||
| "Examples:\n" + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, similar to my comments in #1559 (review) the examples have their own dedicated place. |
||
| " 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: "<org>/<project> (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, | ||
| ); | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| // 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}.`, | ||
| }; | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
| } | ||
| return undefined; | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DroppedEventsResponse> { | ||
| 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<DroppedEventsResponse>( | ||
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof DroppedEventsOutputSchema>; | ||
| type ReasonRow = InferOutput<typeof ReasonRowSchema>; | ||
| 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<string, Map<string, ReasonRow>>(); | ||
| 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<ReasonRow>[] = [ | ||
| { 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"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's take a note to also add other kinds of analytics like accepted, rate limited etc so this is a real analytics command.