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
1 change: 1 addition & 0 deletions docs/specs/search-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re
- 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` are marked in the table. Those still receiving data (`NOT_ELAPSED`, `INGESTION_PENDING`) get `*`, are excluded from the peak, and label the total "so far". Those that start before the retention window (`OUTSIDE_RETENTION`) get `†` and stay in the peak, since their value is final. 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
3 changes: 2 additions & 1 deletion packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5141,7 +5141,8 @@ export class SentryApiService {

/**
* Fetch a timeseries (events-timeseries) for a single yAxis, bucketed over
* time.
* 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
29 changes: 28 additions & 1 deletion packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2437,15 +2437,32 @@ export const AgenticOnboardingRunSchema = z.object({
stages: z.array(AgenticOnboardingStageStateSchema),
});

/**
* 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 IngestionMetaSchema = z
.object({
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.
* `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();

Expand All @@ -2464,11 +2481,21 @@ export const EventsTimeSeriesResponseSchema = z
.object({
// Bucket width in milliseconds
interval: z.number(),
valueType: z.string().optional(),
valueUnit: z.string().nullish(),
})
.passthrough(),
})
.passthrough(),
),
meta: z
.object({
start: z.number().optional(),
end: z.number().optional(),
ingestion: IngestionMetaSchema.optional(),
})
.passthrough()
.optional(),
})
.passthrough();

Expand Down
167 changes: 167 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,173 @@ describe("search_events", () => {
expect(result).toContain("**Peak**: 8");
});

it("marks incomplete buckets and reports ingestion delay", async () => {
const output = {
dataset: "errors" as const,
query: "",
fields: [] as string[],
sort: "-timestamp",
environment: null,
timeSeries: { yAxis: "count()", interval: "1h" },
timeRange: { statsPeriod: "24h" },
explanation: "",
};
mockGenerateText.mockResolvedValueOnce({
text: JSON.stringify(output),
experimental_output: output,
finishReason: "stop" as const,
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
warnings: [] as const,
} as any);

mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/events-timeseries/",
() =>
HttpResponse.json({
timeSeries: [
{
yAxis: "count()",
values: [
{
timestamp: 1757548800000,
value: 10,
incomplete: true,
incompleteReason: "OUTSIDE_RETENTION",
},
{ timestamp: 1757552400000, value: 8, incomplete: false },
{
timestamp: 1757556000000,
value: 9,
incomplete: true,
incompleteReason: "NOT_ELAPSED",
},
],
meta: {
interval: 3600000,
valueType: "integer",
valueUnit: null,
},
},
],
meta: {
dataset: "errors",
start: 1757462400000,
end: 1757548800000,
ingestion: {
status: "healthy",
delaySeconds: 95,
completeThrough: 1757556600000,
},
},
}),
),
);

const result = await searchEvents.handler(
{
organizationSlug: "test-org",
regionUrl: null,
projectSlug: null,
dataset: "errors",
query: "errors per hour",
fields: null,
sort: null,
period: "24h",
limit: 10,
includeExplanation: false,
},
{
accessToken: "test-token",
userId: "user-123",
clientId: "client-123",
grantedSkills: new Set(),
constraints: {},
sentryHost: "sentry.io",
},
);

// The still-filling bucket can't be the peak yet. The retention-partial
// bucket is final, so it still counts and wins here.
expect(result).toContain("**Peak**: 10 at 2025-09-11 00:00");
expect(result).toContain("**Total**: 27 (so far)");
expect(result).toContain("| 2025-09-11 00:00 | 10 † |");
expect(result).toContain("| 2025-09-11 02:00 | 9 * |");
expect(result).toContain("Incomplete bucket: data is still arriving");
expect(result).toContain(
"Partial bucket: it starts before the retention window",
);
expect(result).toContain(
"**Ingestion**: healthy (~1m 35s behind, data complete through 2025-09-11 02:10 UTC)",
);
});

it("omits the retention footnote when its buckets are cut from the table", async () => {
const output = {
dataset: "errors" as const,
query: "",
fields: [] as string[],
sort: "-timestamp",
environment: null,
timeSeries: { yAxis: "count()", interval: "1d" },
timeRange: { statsPeriod: "100d" },
explanation: "",
};
mockGenerateText.mockResolvedValueOnce({
text: JSON.stringify(output),
experimental_output: output,
finishReason: "stop" as const,
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
warnings: [] as const,
} as any);

// 60 daily buckets: the first is outside retention, but only the latest
// 48 rows are rendered, so no † row is visible.
const day = 86400000;
const values = Array.from({ length: 60 }, (_, i) => ({
timestamp: 1752364800000 + i * day,
value: i + 1,
incomplete: i === 0,
...(i === 0 ? { incompleteReason: "OUTSIDE_RETENTION" } : {}),
}));
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/events-timeseries/",
() =>
HttpResponse.json({
timeSeries: [{ yAxis: "count()", values, meta: { interval: day } }],
}),
),
);

const result = await searchEvents.handler(
{
organizationSlug: "test-org",
regionUrl: null,
projectSlug: null,
dataset: "errors",
query: "errors per day",
fields: null,
sort: null,
period: "100d",
limit: 10,
includeExplanation: false,
},
{
accessToken: "test-token",
userId: "user-123",
clientId: "client-123",
grantedSkills: new Set(),
constraints: {},
sentryHost: "sentry.io",
},
);

expect(result).toContain("## Buckets (most recent 48 of 60)");
expect(result).not.toContain("†");
expect(result).not.toContain("so far");
});

it("should handle spans dataset queries", async () => {
// Mock AI response for spans dataset
mockGenerateText.mockResolvedValueOnce(
Expand Down
95 changes: 81 additions & 14 deletions packages/mcp-core/src/tools/support/search-events/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { SentryApiService } from "../../../api-client";
import type { EventsTimeSeriesResponse } from "../../../api-client/schema";
import type {
EventsTimeSeriesResponse,
IngestionMeta,
} from "../../../api-client/schema";
import { formatToolCallInstruction } from "../../../internal/tool-helpers/tool-call-formatting";
import { formatUserGeoSummary } from "../../../internal/user-formatting";
import { logInfo } from "../../../telem/logging";
Expand Down Expand Up @@ -939,9 +942,39 @@ function isAdditiveAggregate(yAxis: string): boolean {
return fn === "count()" || fn.startsWith("sum(");
}

function formatBucketTime(timestampMs: number): string {
return new Date(timestampMs).toISOString().slice(0, 16).replace("T", " ");
}

/**
* One line describing the measured ingestion delay, so the caller knows how
* far behind the data is before reading a trailing dip as a real drop.
*/
function formatIngestionStatus(ingestion: IngestionMeta): string {
const parts: string[] = [];
if (ingestion.delaySeconds !== undefined) {
const seconds = Math.round(ingestion.delaySeconds);
const delay =
seconds >= 60
? `${Math.floor(seconds / 60)}m ${seconds % 60}s`
: `${seconds}s`;
parts.push(`~${delay} behind`);
}
if (ingestion.completeThrough !== undefined) {
parts.push(
`data complete through ${formatBucketTime(ingestion.completeThrough)} UTC`,
);
}
const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
return `- **Ingestion**: ${ingestion.status}${detail}`;
}

/**
* Format an events-timeseries result: a metric bucketed over time.
* `interval` is null when Sentry chose the bucket size for the range.
* Buckets flagged `incomplete` by Sentry are marked in the table. Those still
* receiving data are also excluded from the peak; those that start before the
* retention window are permanently partial and will not change.
*/
export function formatTimeSeriesResults(params: {
series: EventsTimeSeriesResponse;
Expand All @@ -964,23 +997,34 @@ export function formatTimeSeriesResults(params: {
url,
} = params;

const points = (series.timeSeries[0]?.values ?? []).map((bucket) => ({
time: new Date(bucket.timestamp)
.toISOString()
.slice(0, 16)
.replace("T", " "),
value: bucket.value ?? 0,
}));
const points = (series.timeSeries[0]?.values ?? []).map((bucket) => {
// OUTSIDE_RETENTION buckets start before the retention window: their data
// is permanently partial. Every other reason means data is still arriving.
const outsideRetention =
bucket.incomplete && bucket.incompleteReason === "OUTSIDE_RETENTION";
return {
time: formatBucketTime(bucket.timestamp),
value: bucket.value ?? 0,
filling: bucket.incomplete && !outsideRetention,
outsideRetention,
};
});
const hasFilling = points.some((p) => p.filling);
const ingestion = series.meta?.ingestion;

// Total is only meaningful for additive aggregates; summing count_unique /
// avg / percentile buckets would be wrong, so omit it for those.
const total = isAdditiveAggregate(yAxis)
? points.reduce((sum, p) => sum + p.value, 0)
: null;
const peak = points.reduce<(typeof points)[number] | undefined>(
(max, p) => (max === undefined || p.value > max.value ? p : max),
undefined,
);
// Buckets still filling can't be the peak yet. Partial retention buckets are
// final, so if one still tops the rest it is a real peak.
const peak = points
.filter((p) => !p.filling)
.reduce<(typeof points)[number] | undefined>(
(max, p) => (max === undefined || p.value > max.value ? p : max),
undefined,
);

const MAX_ROWS = 48;
const shown = points.length > MAX_ROWS ? points.slice(-MAX_ROWS) : points;
Expand All @@ -1001,11 +1045,16 @@ export function formatTimeSeriesResults(params: {
);
lines.push(`- **Time range**: ${formatExecutedTimeRange(timeRange)}`);
if (total !== null) {
lines.push(`- **Total**: ${total.toLocaleString()}`);
lines.push(
`- **Total**: ${total.toLocaleString()}${hasFilling ? " (so far)" : ""}`,
);
}
if (peak) {
lines.push(`- **Peak**: ${peak.value.toLocaleString()} at ${peak.time}`);
}
if (ingestion) {
lines.push(formatIngestionStatus(ingestion));
}

if (shown.length > 0) {
lines.push(
Expand All @@ -1016,7 +1065,25 @@ export function formatTimeSeriesResults(params: {
"| --- | --- |",
);
for (const p of shown) {
lines.push(`| ${p.time} | ${p.value.toLocaleString()} |`);
const marker = p.filling ? " *" : p.outsideRetention ? " †" : "";
lines.push(`| ${p.time} | ${p.value.toLocaleString()}${marker} |`);
}
// Footnotes describe markers in the visible rows only; older retention
// buckets may have been cut by MAX_ROWS.
const shownFilling = shown.some((p) => p.filling);
const shownOutsideRetention = shown.some((p) => p.outsideRetention);
if (shownFilling || shownOutsideRetention) {
lines.push("");
}
if (shownFilling) {
lines.push(
"\\* Incomplete bucket: data is still arriving, so the value may rise.",
);
}
if (shownOutsideRetention) {
lines.push(
"† Partial bucket: it starts before the retention window, so older data is missing and the value will not change.",
);
Comment thread
cursor[bot] marked this conversation as resolved.
}
} else {
lines.push("", "No data points in this range.");
Expand Down
Loading