Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ View, list, and send Sentry events

- `sentry event view <org/project/event-id...>` — View details of one or more events
- `sentry event list <issue>` — List events for an issue
- `sentry event analytics [<org/project>]` — Analytics for events Sentry dropped
- `sentry event send <target-or-file...>` — Send a Sentry event

→ Full flags and examples: `references/event.md`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

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.


**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 | |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a useful output shape. Array of what? If outcomes is the only field that indicates that we should probably unwrap it and allow more granular selection?


### `sentry event send <target-or-file...>`

Send a Sentry event
Expand Down
119 changes: 119 additions & 0 deletions packages/cli/src/commands/event/analytics.ts
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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" +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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,
);
Comment thread
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}.`,
};
Comment thread
sentry-warden[bot] marked this conversation as resolved.
}
return undefined;
},
});
3 changes: 3 additions & 0 deletions packages/cli/src/commands/event/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,6 +8,7 @@ export const eventRoute = buildRouteMap({
routes: {
view: viewCommand,
list: listCommand,
analytics: analyticsCommand,
send: sendCommand,
},
defaultCommand: "view",
Expand All @@ -16,6 +18,7 @@ export const eventRoute = buildRouteMap({
"View, list, and send event data from Sentry.\n\n" +
"Use 'sentry event view <event-id>' to view a specific event.\n" +
"Use 'sentry event list <issue-id>' to list events for an issue.\n" +
"Use 'sentry event analytics <org>/<project>' to summarize dropped events.\n" +
"Use 'sentry event send -m <message>' to send a test event.",
hideRoute: {},
},
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/lib/api/dropped-events.ts
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;
}
152 changes: 152 additions & 0 deletions packages/cli/src/lib/formatters/dropped-events.ts
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");
}
Loading
Loading