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,42 @@ 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" - (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 <target-or-file...>`

Send a Sentry event
Expand Down
164 changes: 164 additions & 0 deletions packages/cli/src/commands/event/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 <org>/<project>, 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: "<org>/<project> (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,
);
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; 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),

Check warning on line 148 in packages/cli/src/commands/event/analytics.ts

View check run for this annotation

@sentry/warden / warden: code-review

Missing project ID silently returns org-wide dropped events

When project ID resolution fails transiently, the command omits `project` and fetches org-wide dropped events while still labeling the result as the requested project. Fail closed (e.g. `fetchProjectId(..., { strict: true })`) since this API has no slug fallback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Project ID fallback queries all projects

Medium Severity

resolveLogProjectId returns undefined on non-404 lookup failures, and getDroppedEvents treats a missing projectId as org-wide. A transient project lookup failure then returns every project's dropped events while the output still attributes them to the requested project.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2d437b. Configure here.

...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}.`,
};
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
50 changes: 50 additions & 0 deletions packages/cli/src/lib/api/dropped-events.ts
Original file line number Diff line number Diff line change
@@ -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<DroppedEventsResponse> {
const regionUrl = await resolveOrgRegion(orgSlug);
const { data } = await apiRequestToRegion<DroppedEventsResponse>(
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;
}
Loading
Loading