Skip to content
Draft
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
3 changes: 2 additions & 1 deletion docs/specs/search-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,12 @@ absent. Existing final validation and unknown-environment notices remain in plac

### Time Series

Requests for a metric over time ("per hour", "per day", "trend", "over time") return a bucketed series via the `events-stats` endpoint instead of failing.
Requests for a metric over time ("per hour", "per day", "trend", "over time") return a bucketed series via the `events-timeseries` endpoint instead of failing.

- The embedded agent sets `timeSeries: { yAxis, interval }` on its output. `yAxis` is the aggregate to plot (e.g. `count()`); the query, environment, and time range are reused from the normal translation.
- **Interval is agent-decided, never a required input.** It is set only when the user names a granularity ("per hour" → `1h`); otherwise it is left `null` so Sentry picks a sensible bucket for the range (mirrors `get_interval_from_range`). Sentry rejects an interval that would produce too many buckets.
- The handler routes `timeSeries` to `SentryApiService.getEventsTimeSeries` and renders the buckets (with total and peak) via `formatTimeSeriesResults`.
- Buckets Sentry flags as `incomplete` (still receiving data) are marked with `*` in the table and excluded from the peak; the total is labelled "so far". When the response carries `meta.ingestion`, an **Ingestion** line reports the measured delay and the time data is complete through.

### Key Technical Constraints

Expand Down
52 changes: 23 additions & 29 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
ErrorsSearchResponseSchema,
EventAttachmentListSchema,
EventSchema,
EventsStatsResponseSchema,
EventsTimeSeriesResponseSchema,
ExternalIssueListSchema,
ExternalIssueSchema,
FlamegraphSchema,
Expand Down Expand Up @@ -423,26 +423,22 @@ const EventsValidationIssueSchema = z
valid: z.boolean(),
error: ValidationErrorSchema,
})
.transform(
({ valid, error }): EventsValidationIssue => ({
valid,
...(error ? { error } : {}),
}),
);
.transform(({ valid, error }): EventsValidationIssue => ({
valid,
...(error ? { error } : {}),
}));

const EventsNamedValidationIssueSchema = z
.object({
name: z.string(),
valid: z.boolean(),
error: ValidationErrorSchema,
})
.transform(
({ name, valid, error }): EventsNamedValidationIssue => ({
name,
valid,
...(error ? { error } : {}),
}),
);
.transform(({ name, valid, error }): EventsNamedValidationIssue => ({
name,
valid,
...(error ? { error } : {}),
}));

const EventsAttributeValidationSchema = z
.object({
Expand Down Expand Up @@ -473,13 +469,11 @@ const EventsQueryValidationSchema = z
error: ValidationErrorSchema,
fields: EventsAttributeValidationListSchema,
})
.transform(
({ valid, error, fields }): EventsQueryValidation => ({
valid,
fields,
...(error ? { error } : {}),
}),
);
.transform(({ valid, error, fields }): EventsQueryValidation => ({
valid,
fields,
...(error ? { error } : {}),
}));

const EventsValidationResponseSchema = z
.object({
Expand Down Expand Up @@ -1330,9 +1324,9 @@ export class SentryApiService {
private isAggregateExplorerQuery(params: ExplorerAggregateParams): boolean {
return Boolean(
params.aggregateFunctions?.length ||
params.fields?.some(
(field) => field.includes("(") && field.includes(")"),
),
params.fields?.some(
(field) => field.includes("(") && field.includes(")"),
),
);
}

Expand Down Expand Up @@ -5159,7 +5153,9 @@ export class SentryApiService {
}

/**
* Fetch a timeseries (events-stats) for a single yAxis, bucketed over time.
* Fetch a timeseries (events-timeseries) for a single yAxis, bucketed over
* time. Buckets that may still receive data are flagged `incomplete`, and
* `meta.ingestion` reports the measured ingestion delay when available.
*
* `interval` is optional: omit it to let Sentry pick a sensible bucket size
* for the range (mirrors get_interval_from_range in the Sentry source).
Expand Down Expand Up @@ -5201,15 +5197,13 @@ export class SentryApiService {
if (projectId) {
queryParams.set("project", projectId);
}
// partial=1 keeps the current (in-progress) bucket, matching Sentry's charts.
queryParams.set("partial", "1");
queryParams.set("referrer", SENTRY_MCP_SEARCH_EVENTS_REFERRER);

const apiUrl =
apiPath`/organizations/${organizationSlug}/events-stats/` +
apiPath`/organizations/${organizationSlug}/events-timeseries/` +
`?${queryParams.toString()}`;
const body = await this.requestJSON(apiUrl, undefined, opts);
return EventsStatsResponseSchema.parse(body);
return EventsTimeSeriesResponseSchema.parse(body);
}

// POST https://us.sentry.io/api/0/issues/5485083130/autofix/
Expand Down
70 changes: 59 additions & 11 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2438,19 +2438,67 @@ export const AgenticOnboardingRunSchema = z.object({
});

/**
* Response from the events-stats (timeseries) endpoint for a single yAxis:
* a series of `[unixTimestampSeconds, [{ count }]]` buckets. `count` holds the
* yAxis value for that bucket regardless of the aggregate function.
* Measured ingestion delay for the queried dataset. Only present for EAP
* datasets (spans, logs, trace metrics) on orgs with the feature enabled.
* `completeThrough` is the time (ms) up to which data is considered complete.
*/
export const EventsStatsResponseSchema = z
export const IngestionMetaSchema = z
.object({
data: z.array(
z.tuple([
z.number(),
z.array(z.object({ count: z.number().nullish() }).passthrough()),
]),
status: z.enum(["healthy", "stalled", "idle", "unknown"]),
delaySeconds: z.number().optional(),
completeThrough: z.number().optional(),
})
.passthrough();

export type IngestionMeta = z.infer<typeof IngestionMetaSchema>;

/**
* One bucket of an events-timeseries series. `timestamp` is in milliseconds.
* `incomplete` marks buckets that may still receive data (the current bucket,
* or anything after `meta.ingestion.completeThrough`).
*/
export const EventsTimeSeriesValueSchema = z
.object({
timestamp: z.number(),
value: z.number().nullish(),
incomplete: z.boolean(),
incompleteReason: z.string().optional(),
})
.passthrough();

/**
* Response from the events-timeseries endpoint. The MCP always requests a
* single yAxis without topEvents, so `timeSeries` holds exactly one series.
*/
export const EventsTimeSeriesResponseSchema = z
.object({
timeSeries: z.array(
z
.object({
yAxis: z.string(),
values: z.array(EventsTimeSeriesValueSchema),
meta: z
.object({
// Bucket width in milliseconds
interval: z.number(),
valueType: z.string().optional(),
valueUnit: z.string().nullish(),
})
.passthrough(),
})
.passthrough(),
),
start: z.number().optional(),
end: z.number().optional(),
meta: z
.object({
start: z.number().optional(),
end: z.number().optional(),
ingestion: IngestionMetaSchema.optional(),
})
.passthrough()
.optional(),
})
.passthrough();

export type EventsTimeSeriesResponse = z.infer<
typeof EventsTimeSeriesResponseSchema
>;
Loading
Loading