Skip to content
Merged
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 @@ -67,7 +67,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, tags, 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 @@ -452,6 +452,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 send <target-or-file...>` — Send a Sentry event
- `sentry event analytics [<org/project>]` — Analytics for events Sentry accepted and dropped

→ Full flags and examples: `references/event.md`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,46 @@ sentry event send my-project -m "Test"
sentry event send my-org/my-project -m "Test"
```

### `sentry event analytics [<org/project>]`

Analytics for events Sentry accepted and dropped

**Arguments:**

| Argument | Description |
|----------|-------------|
| `[<org/project>]` | &lt;org&gt;/&lt;project&gt; or &lt;project&gt; (search) |

**Flags:**
- `-d, --dataset <value> - Dropped-data type: errors, spans, logs, metrics - (default: "errors")`
- `-t, --period <value> - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "7d")`

**JSON Fields** (use `--json --fields` to select specific fields):

| Field | Type | Description |
|-------|------|-------------|
| `outcome` | string | Outcome code: accepted, or a drop such as rate_limited or filtered |
| `label` | string | Human-readable outcome label |
| `reason` | string | Reason within the outcome (e.g. spike_protection); accepted for accepted volume |
| `category` | string | Data category (e.g. error, span) |
| `events` | number | Events in this slice |
| `share` | number | Fraction of total volume (0-1), not a percentage string |
| `tags` | array | Drop classification: intentional or unintentional; empty for accepted |

**Examples:**

```bash
# Accepted and 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

# Widen the time range
sentry event analytics my-org/my-project --period 7d

# Select specific JSON fields
sentry event analytics my-org/my-project --json --fields outcome,reason,events
```

All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
132 changes: 132 additions & 0 deletions packages/cli/src/commands/event/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { SentryContext } from "../../context.js";
import { getDroppedEvents } from "../../lib/api-client.js";
import { buildCommand } from "../../lib/command.js";
import { choiceParser } from "../../lib/completion-target.js";
import { buildDatasetParser } from "../../lib/datasets.js";
import {
LIST_PERIOD_FLAG,
PERIOD_ALIASES,
PROJECT_TARGET_BRIEF,
} from "../../lib/list-command.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
ACCEPTED_OUTCOME,
type EventOutcomeRow,
formatEventOutcomes,
summarizeEventOutcomes,
} from "../../lib/formatters/event-outcomes.js";
import { EventOutcomeRowSchema } from "../../generated/output-schemas/event.js";
import { withProgress } from "../../lib/polling.js";
import { fetchProjectId } from "../../lib/resolve-target.js";
import {
type ProjectBoundTarget,
projectBoundTarget,
} from "../../lib/target-positional.js";
import { type TimeRange, timeRangeToApiParams } from "../../lib/time-range.js";

const { parse: parseDataset, names: DATASET_NAMES } = buildDatasetParser([
"errors",
"spans",
"logs",
"tracemetrics",
]);

type AnalyticsFlags = {
dataset: ReturnType<typeof parseDataset>;
period: TimeRange;
json?: boolean;
fields?: string[];
};

export const analyticsCommand = buildCommand({
docs: {
brief: "Analytics for events Sentry accepted and dropped",
fullDescription:
"Aggregate analytics for events Sentry received: the accepted volume, " +
"then the events 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: "Accepted and 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: "Widen the time range",
command: "sentry event analytics my-org/my-project --period 7d",
},
{
description: "Select specific JSON fields",
command:
"sentry event analytics my-org/my-project --json --fields outcome,reason,events",
},
],
},
output: {
human: formatEventOutcomes,
schema: EventOutcomeRowSchema,
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org/project",
brief: PROJECT_TARGET_BRIEF,
parse: projectBoundTarget,
optional: true,
},
],
},
flags: {
dataset: {
kind: "parsed",
parse: choiceParser(DATASET_NAMES, parseDataset),
brief: `Dropped-data type: ${DATASET_NAMES.join(", ")}`,
default: "errors",
},
period: LIST_PERIOD_FLAG,
},
aliases: { ...PERIOD_ALIASES, d: "dataset" },
},
async *func(
this: SentryContext,
flags: AnalyticsFlags,
target: ProjectBoundTarget,
) {
const { org, project } = await target.resolved;

// The events-dropped `project` param takes a numeric ID, and the command
// sends no query — a missing ID would widen the request to every project.
// strict resolution errors on a bad lookup instead of silently doing that.
const projectId = await fetchProjectId(org, project, { strict: true });

const response = await withProgress(
{ message: "Fetching event analytics...", json: flags.json },
() =>
getDroppedEvents(org, {
dataset: flags.dataset,
projectId: projectId === undefined ? undefined : String(projectId),
...timeRangeToApiParams(flags.period),
}),
);

const rows: EventOutcomeRow[] = summarizeEventOutcomes(response);
yield new CommandOutput(rows);
if (rows.every((row) => row.outcome === ACCEPTED_OUTCOME)) {
return { hint: `No dropped events for ${org}/${project}.` };
}
return undefined;
},
});
5 changes: 4 additions & 1 deletion packages/cli/src/commands/event/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { buildRouteMap } from "../../lib/route-map.js";
import { topLevelBrief } from "../../top-level-briefs.js";
import { analyticsCommand } from "./analytics.js";
import { listCommand } from "./list.js";
import { sendCommand } from "./send.js";
import { viewCommand } from "./view.js";
Expand All @@ -9,6 +10,7 @@ export const eventRoute = buildRouteMap({
view: viewCommand,
list: listCommand,
send: sendCommand,
analytics: analyticsCommand,
},
defaultCommand: "view",
docs: {
Expand All @@ -17,7 +19,8 @@ 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 send -m <message>' to send a test event.",
"Use 'sentry event send -m <message>' to send a test event.\n" +
"Use 'sentry event analytics' to see accepted and dropped event volume.",
hideRoute: {},
},
});
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 @@ -53,6 +53,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
57 changes: 57 additions & 0 deletions packages/cli/src/lib/api/dropped-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Dropped events API — events Sentry received but dropped before storage.
*
* Wraps the `events-dropped/` endpoint, which buckets dropped and accepted
* volume by outcome and reason over a time range.
*/

import {
type ListOrganizationEventsDroppedData,
type ListOrganizationEventsDroppedResponse,
listOrganizationEventsDropped,
} from "@sentry/api";
import { getOrgSdkConfig, unwrapResult } from "./infrastructure.js";

/** Options for {@link getDroppedEvents}. */
export type DroppedEventsOptions = {
/** API-level dataset name. Callers resolve user-facing aliases first. */
dataset?: ListOrganizationEventsDroppedData["query"]["dataset"];
/** Numeric project ID. Omit to span every project the token can read. */
projectId?: string;
/** Relative time period (e.g. "24h", "7d"). Mutually exclusive with start/end. */
statsPeriod?: string;
/** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */
start?: string;
/** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */
end?: string;
};

/**
* Fetch dropped and accepted events for an organization, bucketed by outcome
* and reason over the requested range. `dataset` is sent as given, so callers
* pass the API-level name (e.g. `tracemetrics`). When no time range is given,
* the endpoint defaults to 90d server-side.
*/
export async function getDroppedEvents(
orgSlug: string,
options: DroppedEventsOptions = {},
): Promise<ListOrganizationEventsDroppedResponse> {
const config = await getOrgSdkConfig(orgSlug);
const result = await listOrganizationEventsDropped({
...config,
path: { organization_id_or_slug: orgSlug },
query: {
dataset: options.dataset ?? "errors",
project:
options.projectId === undefined ? undefined : [options.projectId],
statsPeriod:
options.start || options.end ? undefined : options.statsPeriod,
start: options.start,
end: options.end,
},
});
return unwrapResult<ListOrganizationEventsDroppedResponse>(
result,
"Failed to fetch dropped events",
);
}
Loading
Loading