diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index ff85eff65..deb084419 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -155,13 +155,82 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - 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. - Structured dataset tools carry the same states as data: `timeSeries.buckets[].incomplete` (still receiving data) and `outsideRetention`, `totalIncludesIncompleteBuckets` for a total "so far", and `timeSeries.ingestion` for the ingestion line. +### Explicit transformed time series + +`search_traces`, `search_logs`, and `search_metrics` accept `timeSeries` with an +ordered `axes` array. Each axis has an aggregate `yAxis` and an optional ordered +`transformations` array. Duplicate aggregates retain their own ordered transformations. + +```typescript +search_traces({ + organizationSlug: "my-org", + query: "span.op:db", + period: "24h", + timeSeries: { + axes: [ + { yAxis: "count()" }, + { + yAxis: "count()", + transformations: ["fill(zero)", "smooth(sma,3)"], + }, + ], + interval: "1h", + }, +}); +``` + +Supported operations are `fill(zero)`, `fill(locf)`, `fill(linear)`, +`smooth(sma,window)`, `smooth(ema,window[,factor])`, and +`smooth(asap[,maxWindow])`. Windows are positive integers. The optional EMA +factor is greater than zero and at most one. Each axis accepts at most one fill +and one smoothing operation, in either order. Smoothing operates on aggregate +buckets. It does not compute rolling percentiles over underlying events. + +An explicit configuration with a structured or empty query executes without an +AI provider or table fields. `fields` and `sort` are incompatible with explicit +time series. A natural-language query can still use translation, but explicit +axes, transformations, interval, and period take precedence and survive provider +fallback. Omission retains the existing search behavior. + +Translation responses normalize null transformations to omission. Explicit +inputs require an array when `transformations` is supplied. + +The API request repeats `yAxis` in axis order and repeats +`transformations[index]` in operation order. Explicit results use the dataset tool's structured result envelope with equivalent +generated JSON text. Ordered axes are in `series[]`, alongside `requestedInterval`; +`timeSeries` remains the single-axis inferred chart field. Each returned axis +includes its executed interval, units, nullable bucket values, comparisons, +transformation flags. Null is a gap rather than zero. Transformed axes omit totals and label their maximum as `derivedMaximum`. + +Errors and profiles retain their existing single-axis inferred charts. +Seer-inferred time series include only charts with the selected non-null +interval; unbucketed aggregate charts are excluded. + +Grouped output, cross-event filters on transformed searches, and mismatched +returned axes produce actionable errors. Sentry deployments that ignore +transformations do not return the required bucket flags and produce an error. +Empty transformed results also cannot confirm execution. Retry with a range +containing data or explicitly remove the operations to request raw data. + +Explore links preserve each axis and its transformations through repeated `aggregateField` +entries for traces and logs, or `metric` query state for metrics. The interval +is a global URL parameter. Metrics require a unique metric identity in a +qualified aggregate such as `sum(value,tokens,counter,-)` or exact +`metric.name` and `metric.type` filters. Ambiguous metric identities produce an +error instead of an incomplete link. + +When Sentry chooses the interval automatically, links retain the returned +interval so reopening a transformed chart uses the same bucket width. Explore +rejects intervals outside its supported range for the selected period. MCP +returns an actionable error when Explore would replace the applied interval. + ### Key Technical Constraints - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs - **Seer translation**: Seer translates natural-language queries when the organization has the `mcp-search-events-seer-translate` rollout flag and has not hidden AI features. If Seer is unavailable or cannot translate the query, the tool falls back to the configured embedded agent. - **Untranslated queries**: With no Seer translation and no embedded agent, the query runs as literal text. When a multi-word query without Sentry filters or quotes then finds nothing, the response adds a note that the query was not translated and that the caller should retry with Sentry search syntax. `search_issues` adds the same note in direct mode. Queries in Sentry syntax and searches that return results are unchanged. -- **Seer cross-event filters**: Time series results do not apply cross-event filters. When Seer returns those filters for a time series, the response always begins with a warning identifying the omitted filters and the broader results, even when `includeExplanation` is false. Structured dataset tools put that warning first in `notes[]`. +- **Seer cross-event filters**: Legacy untransformed time series do not apply cross-event filters and begin with a warning, even when `includeExplanation` is false. Explicit or transformed time series reject these unsupported filters. Structured dataset tools put that warning first in `notes[]`. - **Seer project scope**: For a successful Seer translation without `projectSlug`, search and Explorer links use `project=-1` to match the all-accessible-project scope sent to Seer. Other unscoped searches retain their existing default scope. - **Parallel attribute fetching**: For spans/logs/metrics, fetches both string and number attribute types in parallel for better performance - **itemType specification**: Must use `logs` and `tracemetrics` exactly for the trace-items attributes API diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index ae3c79026..e31ad8b21 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -32,6 +32,7 @@ import type { SentryNamedResource, } from "@sentry/toolkit-core/resource-identity"; import { z } from "zod"; +import { TransformationsSchema } from "../utils/time-series"; export const UserSchema = z .object({ @@ -1398,6 +1399,9 @@ export const SearchAgentQuerySchema = z y_axes: z.array(z.string()).default([]), // Only set when the user asks for a time bucket, e.g. "per hour". interval: z.string().nullable().optional(), + transformations: TransformationsSchema.nullish().transform( + (transformations) => transformations ?? undefined, + ), }) .passthrough(), ) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 8876e1d16..2b5cbf19d 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -12117,6 +12117,50 @@ "description": "Include explanation of how the query was translated or repaired", "type": "boolean" }, + "timeSeries": { + "description": "Explicit time series. Supply ordered axes with aggregate yAxis and optional ordered transformations, such as fill(zero), fill(locf), fill(linear), smooth(sma,3), smooth(ema,5,0.3), or smooth(asap). Works without an AI provider with Sentry query syntax or an empty query. Omit fields and sort. Grouping is unsupported. Explicit axes, transformations, interval and period override translation.", + "type": "object", + "properties": { + "axes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "yAxis": { + "type": "string", + "minLength": 1, + "pattern": "^[\\w]+\\(.*\\)$" + }, + "transformations": { + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "description": "Operation in execution order: fill(zero|locf|linear), smooth(sma,window), smooth(ema,window[,factor]), or smooth(asap[,maxWindow])." + }, + "description": "Ordered transformations with at most one fill and one smooth operation. An empty array requests raw values." + } + }, + "required": ["yAxis"], + "additionalProperties": false + } + }, + "interval": { + "anyOf": [ + { + "type": "string", + "pattern": "^[1-9]\\d*[smhdw]$" + }, + { + "type": "null" + } + ] + } + }, + "required": ["axes"], + "additionalProperties": false + }, "fresh": { "description": "Bypass cached reads.", "type": "boolean" @@ -12359,6 +12403,124 @@ "required": ["type", "toolName", "arguments", "reason"], "additionalProperties": false } + }, + "requestedInterval": { + "type": ["string", "null"] + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "axisIndex": { + "type": "number" + }, + "yAxis": { + "type": "string" + }, + "transformations": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number" + }, + "valueType": { + "type": "string" + }, + "valueUnit": { + "type": ["string", "null"] + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "peak": { + "type": "object", + "properties": { + "value": { + "type": ["number", "string"] + }, + "timestamp": { + "type": "number" + } + }, + "required": ["value", "timestamp"], + "additionalProperties": false + }, + "derivedMaximum": { + "type": "object", + "properties": { + "value": { + "type": ["number", "string"] + }, + "timestamp": { + "type": "number" + } + }, + "required": ["value", "timestamp"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "buckets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "value": { + "type": ["number", "string", "null"] + }, + "incomplete": { + "type": "boolean" + }, + "incompleteReason": { + "type": "string" + }, + "comparisonValue": { + "type": ["number", "string", "null"] + }, + "transformation": { + "type": "object", + "properties": { + "filled": { + "type": "boolean" + }, + "smoothed": { + "type": "boolean" + }, + "comparisonFilled": { + "type": "boolean" + }, + "comparisonSmoothed": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["timestamp", "value", "incomplete"], + "additionalProperties": false + } + } + }, + "required": [ + "axisIndex", + "yAxis", + "transformations", + "intervalMs", + "buckets" + ], + "additionalProperties": false + } } }, "required": [ @@ -12453,6 +12615,50 @@ "description": "Include explanation of how the query was translated or repaired", "type": "boolean" }, + "timeSeries": { + "description": "Explicit time series. Supply ordered axes with aggregate yAxis and optional ordered transformations, such as fill(zero), fill(locf), fill(linear), smooth(sma,3), smooth(ema,5,0.3), or smooth(asap). Works without an AI provider with Sentry query syntax or an empty query. Omit fields and sort. Grouping is unsupported. Explicit axes, transformations, interval and period override translation.", + "type": "object", + "properties": { + "axes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "yAxis": { + "type": "string", + "minLength": 1, + "pattern": "^[\\w]+\\(.*\\)$" + }, + "transformations": { + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "description": "Operation in execution order: fill(zero|locf|linear), smooth(sma,window), smooth(ema,window[,factor]), or smooth(asap[,maxWindow])." + }, + "description": "Ordered transformations with at most one fill and one smooth operation. An empty array requests raw values." + } + }, + "required": ["yAxis"], + "additionalProperties": false + } + }, + "interval": { + "anyOf": [ + { + "type": "string", + "pattern": "^[1-9]\\d*[smhdw]$" + }, + { + "type": "null" + } + ] + } + }, + "required": ["axes"], + "additionalProperties": false + }, "fresh": { "description": "Bypass cached reads.", "type": "boolean" @@ -12692,6 +12898,124 @@ "required": ["type", "toolName", "arguments", "reason"], "additionalProperties": false } + }, + "requestedInterval": { + "type": ["string", "null"] + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "axisIndex": { + "type": "number" + }, + "yAxis": { + "type": "string" + }, + "transformations": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number" + }, + "valueType": { + "type": "string" + }, + "valueUnit": { + "type": ["string", "null"] + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "peak": { + "type": "object", + "properties": { + "value": { + "type": ["number", "string"] + }, + "timestamp": { + "type": "number" + } + }, + "required": ["value", "timestamp"], + "additionalProperties": false + }, + "derivedMaximum": { + "type": "object", + "properties": { + "value": { + "type": ["number", "string"] + }, + "timestamp": { + "type": "number" + } + }, + "required": ["value", "timestamp"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "buckets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "value": { + "type": ["number", "string", "null"] + }, + "incomplete": { + "type": "boolean" + }, + "incompleteReason": { + "type": "string" + }, + "comparisonValue": { + "type": ["number", "string", "null"] + }, + "transformation": { + "type": "object", + "properties": { + "filled": { + "type": "boolean" + }, + "smoothed": { + "type": "boolean" + }, + "comparisonFilled": { + "type": "boolean" + }, + "comparisonSmoothed": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["timestamp", "value", "incomplete"], + "additionalProperties": false + } + } + }, + "required": [ + "axisIndex", + "yAxis", + "transformations", + "intervalMs", + "buckets" + ], + "additionalProperties": false + } } }, "required": [ @@ -13432,6 +13756,50 @@ "description": "Include explanation of how the query was translated or repaired", "type": "boolean" }, + "timeSeries": { + "description": "Explicit time series. Supply ordered axes with aggregate yAxis and optional ordered transformations, such as fill(zero), fill(locf), fill(linear), smooth(sma,3), smooth(ema,5,0.3), or smooth(asap). Works without an AI provider with Sentry query syntax or an empty query. Omit fields and sort. Grouping is unsupported. Explicit axes, transformations, interval and period override translation.", + "type": "object", + "properties": { + "axes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "yAxis": { + "type": "string", + "minLength": 1, + "pattern": "^[\\w]+\\(.*\\)$" + }, + "transformations": { + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "description": "Operation in execution order: fill(zero|locf|linear), smooth(sma,window), smooth(ema,window[,factor]), or smooth(asap[,maxWindow])." + }, + "description": "Ordered transformations with at most one fill and one smooth operation. An empty array requests raw values." + } + }, + "required": ["yAxis"], + "additionalProperties": false + } + }, + "interval": { + "anyOf": [ + { + "type": "string", + "pattern": "^[1-9]\\d*[smhdw]$" + }, + { + "type": "null" + } + ] + } + }, + "required": ["axes"], + "additionalProperties": false + }, "fresh": { "description": "Bypass cached reads.", "type": "boolean" @@ -13671,6 +14039,124 @@ "required": ["type", "toolName", "arguments", "reason"], "additionalProperties": false } + }, + "requestedInterval": { + "type": ["string", "null"] + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "axisIndex": { + "type": "number" + }, + "yAxis": { + "type": "string" + }, + "transformations": { + "type": "array", + "items": { + "type": "string" + } + }, + "intervalMs": { + "type": "number" + }, + "valueType": { + "type": "string" + }, + "valueUnit": { + "type": ["string", "null"] + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "peak": { + "type": "object", + "properties": { + "value": { + "type": ["number", "string"] + }, + "timestamp": { + "type": "number" + } + }, + "required": ["value", "timestamp"], + "additionalProperties": false + }, + "derivedMaximum": { + "type": "object", + "properties": { + "value": { + "type": ["number", "string"] + }, + "timestamp": { + "type": "number" + } + }, + "required": ["value", "timestamp"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "buckets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "value": { + "type": ["number", "string", "null"] + }, + "incomplete": { + "type": "boolean" + }, + "incompleteReason": { + "type": "string" + }, + "comparisonValue": { + "type": ["number", "string", "null"] + }, + "transformation": { + "type": "object", + "properties": { + "filled": { + "type": "boolean" + }, + "smoothed": { + "type": "boolean" + }, + "comparisonFilled": { + "type": "boolean" + }, + "comparisonSmoothed": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["timestamp", "value", "incomplete"], + "additionalProperties": false + } + } + }, + "required": [ + "axisIndex", + "yAxis", + "transformations", + "intervalMs", + "buckets" + ], + "additionalProperties": false + } } }, "required": [ diff --git a/packages/mcp-core/src/tools/catalog/search-errors.structured.test.ts b/packages/mcp-core/src/tools/catalog/search-errors.structured.test.ts index da883a03e..3376574e1 100644 --- a/packages/mcp-core/src/tools/catalog/search-errors.structured.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-errors.structured.test.ts @@ -215,13 +215,84 @@ describe("search_errors structured output", () => { expect(structuredContent.suggestedActions).toEqual([]); }); + it("keeps the first inferred time-series axis from Seer in the errors schema", async () => { + const seer = await vi.importActual< + typeof import("../support/search-events/seer") + >("../support/search-events/seer"); + vi.mocked(translateWithSeer).mockImplementationOnce(seer.translateWithSeer); + let requestedAxes: string[] = []; + const baseUrl = "https://sentry.io/api/0/organizations/test-org"; + mswServer.use( + http.get(`${baseUrl}/`, () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + features: ["mcp-search-events-seer-translate"], + }), + ), + http.post(`${baseUrl}/search-agent/start/`, () => + HttpResponse.json({ run_id: 1, sentry_run_id: "run-legacy-errors" }), + ), + http.get(`${baseUrl}/search-agent/state/run-legacy-errors/`, () => + HttpResponse.json({ + session: { + status: "completed", + final_response: { + responses: [ + { + query: "level:error", + group_by: [], + visualization: [ + { y_axes: ["count_unique(user)"], interval: null }, + { + y_axes: ["count()", "count_unique(user)"], + interval: "1h", + }, + { y_axes: ["count()"], interval: "1d" }, + ], + sort: "-count()", + stats_period: "24h", + mode: "aggregates", + }, + ], + }, + }, + }), + ), + http.get(`${baseUrl}/events-timeseries/`, ({ request }) => { + const params = new URL(request.url).searchParams; + requestedAxes = params.getAll("yAxis"); + expect(params.get("interval")).toBe("1h"); + return HttpResponse.json({ + timeSeries: requestedAxes.map((yAxis) => ({ + yAxis, + meta: { interval: 3600000 }, + values: [{ timestamp: 1757548800000, value: 2, incomplete: false }], + })), + }); + }), + ); + + const result = await runSearch({ + query: "errors and affected users per hour", + }); + + expect(requestedAxes).toEqual(["count()"]); + expect(result.timeSeries).toMatchObject({ + yAxis: "count()", + buckets: [{ value: 2 }], + }); + expect(result).not.toHaveProperty("series"); + }); + it("returns a time series with totals, peak, and bucket states", async () => { vi.mocked(translateWithSeer).mockResolvedValue({ query: "level:error", fields: [], sort: "-count()", timeParams: { statsPeriod: "24h" }, - timeSeries: { yAxis: "count()", interval: "1h" }, + timeSeries: { axes: [{ yAxis: "count()" }], interval: "1h" }, crossEventQueries: undefined, suggestedProjectIds: [], explanation: "Bucketed errors hourly", diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 5f6a82246..1e61dad39 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -7,6 +7,7 @@ import { ApiValidationError } from "../../api-client"; import { UserInputError } from "../../errors"; import { runSearchEvents } from "../support/search-events/search"; import searchEvents from "./search-events"; +import searchTraces from "./search-traces"; // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { @@ -4916,6 +4917,74 @@ describe("search_events", () => { ), ]; + it.each([ + { confirmed: true, outcome: "has_results", resultCount: 1 }, + { confirmed: false, outcome: "error_on_load", resultCount: 0 }, + ])( + "reports $outcome after validating a transformed time series", + async ({ confirmed, outcome, resultCount }) => { + mswServer.use( + mockOrganization(["mcp-search-events-seer-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + group_by: [], + visualization: [ + { + y_axes: ["p95(span.duration)"], + interval: "1h", + transformations: ["smooth(sma,2)"], + }, + ], + }, + ], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", + () => + HttpResponse.json({ + timeSeries: [ + { + yAxis: "p95(span.duration)", + meta: { interval: 3600000 }, + values: [ + { + timestamp: 1757548800000, + value: 10, + incomplete: false, + ...(confirmed + ? { filled: false, smoothed: true } + : {}), + }, + ], + }, + ], + }), + ), + ); + + const result = searchTraces.handler(seerParams, context); + if (confirmed) { + await expect(result).resolves.toHaveProperty("structuredContent"); + } else { + await expect(result).rejects.toThrow( + "Sentry did not confirm that the requested transformations ran", + ); + } + + expect(mockDistribution).toHaveBeenCalledTimes(1); + expect(mockDistribution).toHaveBeenCalledWith( + "assisted_query.outcome", + resultCount, + { attributes: expect.objectContaining({ outcome }) }, + ); + }, + ); + it("should report has_results when the Seer query returns rows", async () => { mswServer.use( ...mockSeerEvents(() => diff --git a/packages/mcp-core/src/tools/catalog/search-logs.test.ts b/packages/mcp-core/src/tools/catalog/search-logs.test.ts index 82e417180..eaa8aa84b 100644 --- a/packages/mcp-core/src/tools/catalog/search-logs.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-logs.test.ts @@ -28,6 +28,7 @@ it("search_logs runs the shared handler locked to logs", async () => { "regionUrl", "limit", "includeExplanation", + "timeSeries", ], "options": { "lockDataset": true, diff --git a/packages/mcp-core/src/tools/catalog/search-logs.ts b/packages/mcp-core/src/tools/catalog/search-logs.ts index 2aafd0e78..c98357aa9 100644 --- a/packages/mcp-core/src/tools/catalog/search-logs.ts +++ b/packages/mcp-core/src/tools/catalog/search-logs.ts @@ -1,13 +1,13 @@ import { defineTool } from "../../internal/tool-helpers/define"; import type { ServerContext } from "../../types"; import { - buildDatasetSearchInputSchema, + buildTimeSeriesSearchInputSchema, runSearchEvents, searchToolBase, } from "../support/search-events/search"; -import { buildSearchEventsOutputSchema } from "../support/search-events/structured"; +import { buildTimeSeriesSearchOutputSchema } from "../support/search-events/structured"; -export const searchLogsOutputSchema = buildSearchEventsOutputSchema("logs"); +export const searchLogsOutputSchema = buildTimeSeriesSearchOutputSchema("logs"); export default defineTool({ ...searchToolBase("search_logs"), @@ -34,7 +34,7 @@ export default defineTool({ "- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.", "", ].join("\n"), - inputSchema: buildDatasetSearchInputSchema(), + inputSchema: buildTimeSeriesSearchInputSchema(), outputSchema: searchLogsOutputSchema, async handler(params, context: ServerContext) { return await runSearchEvents({ ...params, dataset: "logs" }, context, { diff --git a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts index 2b8d895a2..ce2ae199b 100644 --- a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts @@ -28,6 +28,7 @@ it("search_metrics runs the shared handler locked to metrics", async () => { "regionUrl", "limit", "includeExplanation", + "timeSeries", ], "options": { "lockDataset": true, diff --git a/packages/mcp-core/src/tools/catalog/search-metrics.ts b/packages/mcp-core/src/tools/catalog/search-metrics.ts index c79016c0c..6b44b9c39 100644 --- a/packages/mcp-core/src/tools/catalog/search-metrics.ts +++ b/packages/mcp-core/src/tools/catalog/search-metrics.ts @@ -1,14 +1,14 @@ import { defineTool } from "../../internal/tool-helpers/define"; import type { ServerContext } from "../../types"; import { - buildDatasetSearchInputSchema, + buildTimeSeriesSearchInputSchema, runSearchEvents, searchToolBase, } from "../support/search-events/search"; -import { buildSearchEventsOutputSchema } from "../support/search-events/structured"; +import { buildTimeSeriesSearchOutputSchema } from "../support/search-events/structured"; export const searchMetricsOutputSchema = - buildSearchEventsOutputSchema("metrics"); + buildTimeSeriesSearchOutputSchema("metrics"); export default defineTool({ ...searchToolBase("search_metrics"), @@ -33,7 +33,7 @@ export default defineTool({ "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", "", ].join("\n"), - inputSchema: buildDatasetSearchInputSchema(), + inputSchema: buildTimeSeriesSearchInputSchema(), outputSchema: searchMetricsOutputSchema, async handler(params, context: ServerContext) { return await runSearchEvents({ ...params, dataset: "metrics" }, context, { diff --git a/packages/mcp-core/src/tools/catalog/search-time-series.test.ts b/packages/mcp-core/src/tools/catalog/search-time-series.test.ts new file mode 100644 index 000000000..baed04d69 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-time-series.test.ts @@ -0,0 +1,532 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { LLMProviderError } from "../../errors"; +import * as provider from "../../internal/agents/provider-factory"; +import { createTestContext } from "../../test-utils/context"; +import { buildServer } from "../../server"; +import * as agent from "../support/search-events/agent"; +import { runSearchEvents } from "../support/search-events/search"; +import searchLogs from "./search-logs"; +import searchMetrics from "./search-metrics"; +import searchTraces from "./search-traces"; + +vi.mock("@sentry/core/server", () => ({ + wrapMcpServerWithSentry: vi.fn((server) => server), +})); + +const endpoint = + "https://sentry.io/api/0/organizations/test-org/events-timeseries/"; +const context = createTestContext({ sentryHost: "sentry.io" }); +const configuration = { + axes: [ + { yAxis: "count()", transformations: ["fill(zero)", "smooth(sma,3)"] }, + { yAxis: "count()", transformations: ["smooth(asap)", "fill(locf)"] }, + ], + interval: "1h", +}; +const baseParams = { + organizationSlug: "test-org", + projectSlug: null, + regionUrl: null, + limit: 10, + includeExplanation: false, + period: "24h", +}; + +function responseFor(axes = configuration.axes.map((axis) => axis.yAxis)) { + return { + timeSeries: axes.map((yAxis, index) => ({ + yAxis, + meta: { + interval: 3600000, + valueType: "number", + valueUnit: index === 0 ? null : "millisecond", + }, + values: [ + { + timestamp: 1757548800000, + value: index === 0 ? null : 0, + incomplete: false, + filled: false, + smoothed: false, + comparisonValue: null, + comparisonFilled: false, + comparisonSmoothed: false, + }, + { + timestamp: 1757552400000, + value: 4 + index, + incomplete: index > 0, + ...(index > 0 ? { incompleteReason: "INGESTION_PENDING" } : {}), + filled: true, + smoothed: true, + comparisonValue: 0, + comparisonFilled: false, + comparisonSmoothed: true, + }, + ], + })), + }; +} + +beforeEach(() => { + vi.spyOn(provider, "hasAgentProvider").mockReturnValue(false); + mswServer.use( + http.get("https://sentry.io/api/0/organizations/test-org/", () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + features: [], + }), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + () => HttpResponse.json([]), + ), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("explicit time series", () => { + it("preserves inferred single-axis results when explicit settings are omitted", async () => { + vi.mocked(provider.hasAgentProvider).mockReturnValue(true); + vi.spyOn(agent, "searchEventsAgent").mockResolvedValue({ + result: { + dataset: "spans", + query: "span.op:db", + fields: [], + sort: "", + environment: null, + timeSeries: { axes: [{ yAxis: "count()" }], interval: "1m" }, + timeRange: { statsPeriod: "24h" }, + explanation: "translated", + }, + toolCalls: [], + }); + mswServer.use( + http.get(endpoint, () => + HttpResponse.json({ + timeSeries: [ + { + yAxis: "count()", + meta: { interval: 60000 }, + values: [ + { timestamp: 1757548800000, value: 8, incomplete: false }, + ], + }, + ], + }), + ), + ); + const result = await searchTraces.handler( + { ...baseParams, query: "database traffic per minute" }, + context, + ); + expect(result.structuredContent).toMatchObject({ + resultType: "timeSeries", + timeSeries: { yAxis: "count()", total: 8 }, + }); + }); + it("exposes and dispatches the schema through MCP with equivalent structured and text results", async () => { + mswServer.use(http.get(endpoint, () => HttpResponse.json(responseFor()))); + const server = buildServer({ + context: { ...context, constraints: { organizationSlug: "test-org" } }, + tools: { search_traces: searchTraces }, + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "time-series-test", version: "1" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const tools = await client.listTools(); + const traceTool = tools.tools.find( + (tool) => tool.name === "search_traces", + ); + expect(traceTool?.inputSchema.properties).toHaveProperty("timeSeries"); + const result = await client.callTool({ + name: "search_traces", + arguments: { + query: "span.op:db", + period: "24h", + timeSeries: configuration, + }, + }); + expect(result.structuredContent).toMatchObject({ + executedSearch: { dataset: "spans", query: "span.op:db" }, + series: [{ axisIndex: 0 }, { axisIndex: 1 }], + }); + const parsed = z + .object({ + content: z.array( + z.object({ type: z.literal("text"), text: z.string() }), + ), + structuredContent: z.record(z.string(), z.unknown()), + }) + .parse(result); + expect(JSON.parse(parsed.content[0]!.text)).toEqual( + parsed.structuredContent, + ); + } finally { + await client.close(); + await server.close(); + } + }); + + it("puts the executed auto interval in Explore links without changing the requested interval", async () => { + const timeSeries = { + axes: [{ yAxis: "count()", transformations: ["smooth(sma,3)"] }], + }; + mswServer.use( + http.get(endpoint, ({ request }) => { + expect(new URL(request.url).searchParams.has("interval")).toBe(false); + const response = responseFor(["count()"]); + response.timeSeries[0]!.meta.interval = 900000; + return HttpResponse.json(response); + }), + ); + const result = await searchTraces.handler( + { ...baseParams, query: "span.op:db", period: "1h", timeSeries }, + context, + ); + expect(result).toMatchObject({ + structuredContent: { + requestedInterval: null, + series: [{ intervalMs: 900000 }], + }, + }); + const parsed = z + .object({ structuredContent: z.object({ url: z.string() }) }) + .parse(result); + expect( + new URL(parsed.structuredContent.url).searchParams.get("interval"), + ).toBe("900s"); + }); + + it("rejects intervals that Explore would silently replace", async () => { + mswServer.use( + http.get(endpoint, () => { + const response = responseFor(["count()"]); + response.timeSeries[0]!.meta.interval = 60000; + return HttpResponse.json(response); + }), + ); + await expect( + searchTraces.handler( + { + ...baseParams, + query: "span.op:db", + timeSeries: { axes: [configuration.axes[0]!], interval: "1m" }, + }, + context, + ), + ).rejects.toThrow("Explore cannot preserve this bucket interval"); + }); + + it("applies explicit settings before validating Seer's inferred chart", async () => { + mswServer.use( + http.get("https://sentry.io/api/0/organizations/test-org/", () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + features: ["mcp-search-events-seer-translate"], + }), + ), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + () => HttpResponse.json({ run_id: 1, sentry_run_id: "run-id" }), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-id/", + () => + HttpResponse.json({ + session: { + status: "completed", + final_response: { + responses: [ + { + query: "span.op:db", + group_by: [], + mode: "aggregates", + stats_period: "7d", + visualization: [ + { + y_axes: ["avg(span.duration)"], + interval: null, + transformations: ["fill(locf)"], + }, + ], + }, + ], + }, + }, + }), + ), + http.get(endpoint, ({ request }) => { + const params = new URL(request.url).searchParams; + expect(params.get("interval")).toBe("1h"); + expect(params.get("statsPeriod")).toBe("24h"); + expect(params.getAll("yAxis")).toEqual(["count()", "count()"]); + expect(params.getAll("transformations[0]")).toEqual([ + "fill(zero)", + "smooth(sma,3)", + ]); + return HttpResponse.json(responseFor()); + }), + ); + const result = await searchTraces.handler( + { + ...baseParams, + query: "database traffic over time", + timeSeries: configuration, + }, + context, + ); + expect(result).toMatchObject({ + structuredContent: { + executedSearch: { query: "span.op:db" }, + requestedInterval: "1h", + }, + }); + }); + it.each([ + { + tool: searchTraces, + dataset: "spans", + query: "span.op:db", + page: "traces", + }, + { + tool: searchLogs, + dataset: "logs", + query: "severity:error", + page: "logs", + }, + { + tool: searchMetrics, + dataset: "tracemetrics", + query: "metric.name:tokens metric.type:counter metric.unit:-", + page: "metrics", + }, + ])( + "executes ordered axes through $dataset without AI", + async ({ tool, dataset, query, page }) => { + const axes = + dataset === "tracemetrics" + ? configuration.axes.map((axis) => ({ + ...axis, + yAxis: "sum(value,tokens,counter,-)", + })) + : configuration.axes; + const timeSeries = { ...configuration, axes }; + const agentCall = vi.spyOn(agent, "searchEventsAgent"); + mswServer.use( + http.get(endpoint, ({ request }) => { + const params = new URL(request.url).searchParams; + expect(params.get("dataset")).toBe(dataset); + expect(params.get("query")).toBe(query); + expect(params.getAll("yAxis")).toEqual( + axes.map((axis) => axis.yAxis), + ); + expect(params.getAll("transformations[0]")).toEqual([ + "fill(zero)", + "smooth(sma,3)", + ]); + expect(params.getAll("transformations[1]")).toEqual([ + "smooth(asap)", + "fill(locf)", + ]); + expect(params.get("interval")).toBe("1h"); + expect(params.get("statsPeriod")).toBe("24h"); + expect(params.has("field")).toBe(false); + return HttpResponse.json(responseFor(axes.map((axis) => axis.yAxis))); + }), + ); + + const params = z + .object(tool.inputSchema) + .parse({ ...baseParams, query, timeSeries }); + const result = await tool.handler(params, context); + expect(result).toMatchObject({ + structuredContent: { + executedSearch: { + dataset: dataset === "tracemetrics" ? "metrics" : dataset, + query, + }, + requestedInterval: "1h", + series: [ + { + axisIndex: 0, + yAxis: axes[0]?.yAxis, + transformations: ["fill(zero)", "smooth(sma,3)"], + valueUnit: null, + buckets: [ + { + value: null, + comparisonValue: null, + transformation: { + filled: false, + smoothed: false, + comparisonFilled: false, + comparisonSmoothed: false, + }, + }, + { value: 4, comparisonValue: 0 }, + ], + summary: { derivedMaximum: { value: 4 } }, + }, + { + axisIndex: 1, + yAxis: axes[1]?.yAxis, + transformations: ["smooth(asap)", "fill(locf)"], + valueUnit: "millisecond", + buckets: [ + { value: 0 }, + { + value: 5, + incomplete: true, + transformation: { filled: true, smoothed: true }, + }, + ], + summary: { derivedMaximum: { value: 0 } }, + }, + ], + }, + }); + expect(agentCall).not.toHaveBeenCalled(); + const parsed = z + .object({ structuredContent: z.object({ url: z.string() }) }) + .parse(result); + expect(JSON.stringify(result)).not.toContain('"total"'); + const url = new URL(parsed.structuredContent.url); + expect(url.pathname).toBe(`/explore/${page}/`); + expect(url.searchParams.get("interval")).toBe("1h"); + const charts = + page === "metrics" + ? url.searchParams + .getAll("metric") + .flatMap((value) => JSON.parse(value).aggregateFields) + : url.searchParams + .getAll("aggregateField") + .map((value) => JSON.parse(value)); + expect(charts).toEqual( + axes.map((axis) => ({ + yAxes: [axis.yAxis], + transformations: axis.transformations, + })), + ); + }, + ); + + it("preserves explicit configuration after provider failure and on retry", async () => { + vi.mocked(provider.hasAgentProvider).mockReturnValue(true); + vi.spyOn(agent, "searchEventsAgent").mockRejectedValue( + new LLMProviderError("provider unavailable"), + ); + const requests: string[][] = []; + mswServer.use( + http.get(endpoint, ({ request }) => { + const params = new URL(request.url).searchParams; + requests.push(params.getAll("transformations[0]")); + expect(params.get("interval")).toBe("1h"); + expect(params.get("statsPeriod")).toBe("24h"); + return HttpResponse.json(responseFor()); + }), + ); + const params = { + ...baseParams, + query: "request volume over time", + timeSeries: configuration, + }; + const first = await searchTraces.handler(params, context); + const retry = await searchTraces.handler(params, context); + expect(first).toEqual(retry); + expect(requests).toEqual([ + ["fill(zero)", "smooth(sma,3)"], + ["fill(zero)", "smooth(sma,3)"], + ]); + }); + + it("does not let translation replace explicit axes, interval, period, or dataset", async () => { + vi.mocked(provider.hasAgentProvider).mockReturnValue(true); + vi.spyOn(agent, "searchEventsAgent").mockResolvedValue({ + result: { + dataset: "logs", + query: "span.op:db", + fields: [], + sort: "", + environment: null, + timeSeries: { axes: [{ yAxis: "avg(span.duration)" }], interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "translated", + }, + toolCalls: [], + }); + mswServer.use( + http.get(endpoint, ({ request }) => { + const params = new URL(request.url).searchParams; + expect(params.get("dataset")).toBe("spans"); + expect(params.getAll("yAxis")).toEqual(["count()", "count()"]); + expect(params.get("interval")).toBe("1h"); + expect(params.get("statsPeriod")).toBe("24h"); + return HttpResponse.json(responseFor()); + }), + ); + const result = await searchTraces.handler( + { ...baseParams, query: "database traffic", timeSeries: configuration }, + context, + ); + expect(result).toMatchObject({ + structuredContent: { + executedSearch: { query: "span.op:db" }, + requestedInterval: "1h", + }, + }); + }); + + it("rejects unsupported datasets and table settings", async () => { + await expect( + runSearchEvents( + { ...baseParams, dataset: "errors", timeSeries: configuration }, + context, + ), + ).rejects.toThrow(/search_traces, search_logs, and search_metrics/); + await expect( + searchTraces.handler( + { ...baseParams, fields: ["span.op"], timeSeries: configuration }, + context, + ), + ).rejects.toThrow(/Grouped time series/); + }); + + it("rejects feature-off raw success rather than calling it transformed", async () => { + mswServer.use( + http.get(endpoint, () => + HttpResponse.json({ + timeSeries: configuration.axes.map(({ yAxis }) => ({ + yAxis, + meta: { interval: 3600000 }, + values: [ + { timestamp: 1757548800000, value: 42, incomplete: false }, + ], + })), + }), + ), + ); + await expect( + searchTraces.handler( + { ...baseParams, query: "span.op:db", timeSeries: configuration }, + context, + ), + ).rejects.toThrow(/transformation|feature|unavailable/i); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-traces.test.ts b/packages/mcp-core/src/tools/catalog/search-traces.test.ts index 806ac386e..ac6eb2294 100644 --- a/packages/mcp-core/src/tools/catalog/search-traces.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-traces.test.ts @@ -28,6 +28,7 @@ it("search_traces runs the shared handler locked to spans", async () => { "regionUrl", "limit", "includeExplanation", + "timeSeries", ], "options": { "lockDataset": true, diff --git a/packages/mcp-core/src/tools/catalog/search-traces.ts b/packages/mcp-core/src/tools/catalog/search-traces.ts index fdcfe8711..effed4ae1 100644 --- a/packages/mcp-core/src/tools/catalog/search-traces.ts +++ b/packages/mcp-core/src/tools/catalog/search-traces.ts @@ -1,13 +1,14 @@ import { defineTool } from "../../internal/tool-helpers/define"; import type { ServerContext } from "../../types"; import { - buildDatasetSearchInputSchema, + buildTimeSeriesSearchInputSchema, runSearchEvents, searchToolBase, } from "../support/search-events/search"; -import { buildSearchEventsOutputSchema } from "../support/search-events/structured"; +import { buildTimeSeriesSearchOutputSchema } from "../support/search-events/structured"; -export const searchTracesOutputSchema = buildSearchEventsOutputSchema("spans"); +export const searchTracesOutputSchema = + buildTimeSeriesSearchOutputSchema("spans"); export default defineTool({ ...searchToolBase("search_traces"), @@ -35,7 +36,7 @@ export default defineTool({ "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", "", ].join("\n"), - inputSchema: buildDatasetSearchInputSchema(), + inputSchema: buildTimeSeriesSearchInputSchema(), outputSchema: searchTracesOutputSchema, async handler(params, context: ServerContext) { return await runSearchEvents({ ...params, dataset: "spans" }, context, { diff --git a/packages/mcp-core/src/tools/support/search-events/agent.test.ts b/packages/mcp-core/src/tools/support/search-events/agent.test.ts index bc747a7b0..9b58d2792 100644 --- a/packages/mcp-core/src/tools/support/search-events/agent.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/agent.test.ts @@ -1,5 +1,131 @@ import { expect, it } from "vitest"; -import { hasSuccessfulSearchValidation } from "./agent"; +import { + appendTimeSeriesParams, + normalizeTimeSeriesConfig, + TimeSeriesConfigSchema, +} from "../../../utils/time-series"; +import { + hasSuccessfulSearchValidation, + searchEventsAgentOutputSchema, +} from "./agent"; + +it.each([ + { + shape: "legacy", + timeSeries: { yAxis: "count()", transformations: null, interval: "1h" }, + }, + { + shape: "ordered", + timeSeries: { + axes: [{ yAxis: "count()", transformations: null }], + interval: "1h", + }, + }, +])( + "normalizes null transformations in $shape embedded time series", + ({ timeSeries }) => { + const translated = searchEventsAgentOutputSchema.parse({ + dataset: "logs", + query: "severity:error", + fields: [], + sort: "", + timeRange: { statsPeriod: "24h" }, + timeSeries, + }); + const configuration = normalizeTimeSeriesConfig(translated.timeSeries!); + + expect(configuration.axes[0]?.transformations).toBeUndefined(); + const params = new URLSearchParams(); + appendTimeSeriesParams(params, configuration); + expect(Object.fromEntries(params)).toEqual({ + yAxis: "count()", + interval: "1h", + }); + }, +); + +it("keeps null transformations invalid for explicit caller input", () => { + expect( + TimeSeriesConfigSchema.safeParse({ + axes: [{ yAxis: "count()", transformations: null }], + interval: "1h", + }).success, + ).toBe(false); +}); + +it.each([ + { shape: "ordered", legacy: {} }, + { + shape: "mixed legacy and ordered", + legacy: { yAxis: "count()", transformations: ["fill(locf)"] }, + }, +])("preserves ordered axes in $shape embedded translation", ({ legacy }) => { + const translated = searchEventsAgentOutputSchema.parse({ + dataset: "spans", + query: "span.op:db", + fields: [], + sort: "", + timeRange: { statsPeriod: "24h" }, + timeSeries: { + ...legacy, + axes: [ + { yAxis: "count()", transformations: ["fill(zero)", "smooth(sma,3)"] }, + { yAxis: "count()", transformations: ["smooth(asap)"] }, + ], + interval: "1h", + }, + }); + + expect(normalizeTimeSeriesConfig(translated.timeSeries!)).toEqual({ + axes: [ + { yAxis: "count()", transformations: ["fill(zero)", "smooth(sma,3)"] }, + { yAxis: "count()", transformations: ["smooth(asap)"] }, + ], + interval: "1h", + }); +}); + +it("rejects invalid ordered axes instead of falling back to a legacy series", () => { + expect( + searchEventsAgentOutputSchema.safeParse({ + dataset: "spans", + query: "span.op:db", + fields: [], + sort: "", + timeRange: null, + timeSeries: { + yAxis: "count()", + axes: [{ yAxis: "count()", transformations: ["smooth(sma,0)"] }], + interval: "1h", + }, + }).success, + ).toBe(false); +}); + +it("normalizes legacy embedded time series without dropping its transformations", () => { + const translated = searchEventsAgentOutputSchema.parse({ + dataset: "logs", + query: "severity:error", + fields: [], + sort: "", + timeRange: null, + timeSeries: { + yAxis: "count()", + transformations: ["smooth(ema,5,0.3)", "fill(locf)"], + interval: "1m", + }, + }); + + expect(normalizeTimeSeriesConfig(translated.timeSeries!)).toEqual({ + axes: [ + { + yAxis: "count()", + transformations: ["smooth(ema,5,0.3)", "fill(locf)"], + }, + ], + interval: "1m", + }); +}); it("identifies a passing validateSearch result", () => { expect(hasSuccessfulSearchValidation([])).toBe(false); diff --git a/packages/mcp-core/src/tools/support/search-events/agent.ts b/packages/mcp-core/src/tools/support/search-events/agent.ts index 8c727cf14..8ab776c33 100644 --- a/packages/mcp-core/src/tools/support/search-events/agent.ts +++ b/packages/mcp-core/src/tools/support/search-events/agent.ts @@ -8,6 +8,12 @@ import { createDatasetFieldsTool } from "../../../internal/agents/tools/dataset- import { createOtelLookupTool } from "../../../internal/agents/tools/otel-semantics"; import { createWhoamiTool } from "../../../internal/agents/tools/whoami"; import { PUBLIC_EVENTS_DATASETS } from "../../../utils/events-datasets"; +import { + normalizeTimeSeriesConfig, + TimeSeriesConfigSchema, + TransformationsSchema, + type TimeSeriesConfig, +} from "../../../utils/time-series"; import { systemPrompt } from "./config"; import { createDatasetAttributesTool, @@ -30,6 +36,22 @@ export function hasSuccessfulSearchValidation( ); } +// Models may emit null for optional transformations; public input stays strict. +const agentTransformationsSchema = TransformationsSchema.nullish().transform( + (transformations) => transformations ?? undefined, +); +// Prefer ordered axes when a model also emits legacy top-level fields. +const agentTimeSeriesConfigSchema = z.object({ + ...TimeSeriesConfigSchema.shape, + axes: z + .array( + TimeSeriesConfigSchema.shape.axes.element.extend({ + transformations: agentTransformationsSchema, + }), + ) + .min(1), +}); + // .default("") on explanation is safe because structuredOutputs: false is set via providerOptions. // If structuredOutputs is re-enabled, remove .default() calls (OpenAI requires all fields in 'required'). // Tracking: https://github.com/getsentry/sentry-mcp/issues/623 @@ -51,20 +73,26 @@ export const searchEventsAgentOutputSchema = z "Environment filter for replays only, when requested. Otherwise null; non-replay filters belong in query.", ), timeSeries: z - .object({ - yAxis: z - .string() - .describe( - "The aggregate to plot over time, e.g. 'count()', 'count_unique(user)', 'sum(span.duration)'.", - ), - interval: z - .string() - .nullable() - .default(null) - .describe( - "Bucket size like '1h' or '1d'. Set ONLY when the user names a granularity (e.g. 'per hour' -> '1h'); otherwise null so Sentry picks a sensible bucket for the time range.", - ), - }) + .union([ + agentTimeSeriesConfigSchema, + z.object({ + // Never downgrade malformed ordered output to a legacy series. + axes: z.never().optional(), + yAxis: z + .string() + .describe( + "The aggregate to plot over time, e.g. 'count()', 'count_unique(user)', 'sum(span.duration)'.", + ), + interval: z + .string() + .nullable() + .default(null) + .describe( + "Bucket size like '1h' or '1d'. Set ONLY when the user names a granularity (e.g. 'per hour' -> '1h'); otherwise null so Sentry picks a sensible bucket for the time range.", + ), + transformations: agentTransformationsSchema, + }), + ]) .nullable() .default(null) .describe( @@ -193,7 +221,9 @@ export async function fetchEnvironmentNames(options: { export async function searchEventsAgent( options: SearchEventsAgentOptions, ): Promise<{ - result: z.output; + result: Omit, "timeSeries"> & { + timeSeries: TimeSeriesConfig | null; + }; toolCalls: ToolCall[]; }> { // Provider check happens in callEmbeddedAgent via getAgentProvider() @@ -228,7 +258,7 @@ export async function searchEventsAgent( options.environmentNames ?? (await fetchEnvironmentNames(options)); // Use callEmbeddedAgent to translate the query with tool call capture - return await callEmbeddedAgent< + const response = await callEmbeddedAgent< z.output, typeof searchEventsAgentOutputSchema >({ @@ -245,4 +275,13 @@ export async function searchEventsAgent( isReadyToFinalize: ({ toolResults }) => hasSuccessfulSearchValidation(toolResults), }); + return { + ...response, + result: { + ...response.result, + timeSeries: response.result.timeSeries + ? normalizeTimeSeriesConfig(response.result.timeSeries) + : null, + }, + }; } diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index 2e26e3af5..c4a08094f 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -104,7 +104,8 @@ QUERY MODES: TIME SERIES (data over time): - When the user asks for a metric OVER TIME ("over time", "per hour", "by day", "trend", "time series"), set the \`timeSeries\` field instead of returning an error. -- \`timeSeries.yAxis\` = the aggregate to plot (e.g. "count()", "count_unique(user)", "sum(span.duration)"). Put the aggregate here, NOT in \`fields\`. +- \`timeSeries.axes\` = an ordered array of series, e.g. [{"yAxis":"count()"}, {"yAxis":"sum(span.duration)"}]. Include every requested series, even when aggregates repeat. Put aggregates here, NOT in \`fields\`. +- Each axis may include its own ordered \`transformations\` array when requested, e.g. {"yAxis":"count()","transformations":["fill(zero)","smooth(sma,3)"]}. - \`timeSeries.interval\` = a bucket size like "1h" or "1d" ONLY when the user names a granularity ("per hour" → "1h", "daily" → "1d"). Otherwise leave it null so Sentry picks a sensible bucket for the time range — never require the user to specify it. - Leave \`timeSeries\` null for normal queries and single-total aggregates. diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 2f070b83f..80814792b 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -20,6 +20,11 @@ import type { Scope } from "../../../permissions"; import type { Skill } from "../../../skills"; import type { ServerContext } from "../../../types"; import type { StructuredToolOutput } from "../../types"; +import { + TimeSeriesConfigSchema, + getTimeSeriesExploreInterval, + type TimeSeriesConfig, +} from "../../../utils/time-series"; import { isMetricsDataset, normalizeEventsDataset, @@ -27,17 +32,14 @@ import { type PublicEventsDataset, } from "../../../utils/events-datasets"; import { extractConversationIdFromSearchQuery } from "../../../utils/url-utils"; -import { - fetchEnvironmentNames, - searchEventsAgent, - type searchEventsAgentOutputSchema, -} from "./agent"; +import { fetchEnvironmentNames, searchEventsAgent } from "./agent"; import { RECOMMENDED_FIELDS, TRACE_METRICS_SAMPLE_IDENTITY_FIELDS, } from "./config"; import { buildDroppedEventsNote } from "./dropped-events-note"; import { getFieldRecoveryHints } from "./field-hints"; +import { buildTimeSeriesResult } from "./time-series-results"; import { formatErrorResults, formatLogResults, @@ -57,6 +59,7 @@ import { import { buildStructuredEventsResult, buildStructuredTimeSeriesResult, + buildStructuredOrderedTimeSeriesResult, type DatasetSearchStructuredContent, type SearchEventsStructuredContent, type StructuredSearchDataset, @@ -84,7 +87,9 @@ export const SEARCH_EVENTS_DATASETS = [ ] as const; const DEFAULT_EVENTS_SORT = "-timestamp"; -type SearchEventsAgentResult = z.output; +type SearchEventsAgentResult = Awaited< + ReturnType +>["result"]; function defaultSortForDataset(dataset: PublicEventsDataset | "replays") { return dataset === "replays" ? DEFAULT_REPLAY_SORT : DEFAULT_EVENTS_SORT; @@ -426,6 +431,7 @@ export interface SearchEventsParams { regionUrl: string | null; limit: number; includeExplanation: boolean; + timeSeries?: TimeSeriesConfig; } export interface MarkdownSearchEventsOptions { @@ -556,6 +562,15 @@ export function buildDatasetSearchInputSchema() { }; } +export function buildTimeSeriesSearchInputSchema() { + return { + ...buildDatasetSearchInputSchema(), + timeSeries: TimeSeriesConfigSchema.optional().describe( + "Explicit time series. Supply ordered axes with aggregate yAxis and optional ordered transformations, such as fill(zero), fill(locf), fill(linear), smooth(sma,3), smooth(ema,5,0.3), or smooth(asap). Works without an AI provider with Sentry query syntax or an empty query. Omit fields and sort. Grouping is unsupported. Explicit axes, transformations, interval and period override translation.", + ), + }; +} + /** * Input schema for search_replays. Replays return lists only, so there are no * fields, and environment is a separate parameter. @@ -740,6 +755,26 @@ async function executeSearchEvents( setTargetTagsAndAttributes(params); const inputDataset = params.dataset ?? "errors"; + let explicitTimeSeries = params.timeSeries; + if (explicitTimeSeries) { + const validation = TimeSeriesConfigSchema.safeParse(explicitTimeSeries); + if (!validation.success) { + throw new UserInputError( + `Invalid timeSeries configuration. ${validation.error.issues.map((issue) => issue.message).join(" ")}`, + ); + } + explicitTimeSeries = validation.data; + if (!isTraceItemDataset(inputDataset)) { + throw new UserInputError( + "Explicit time series are supported by search_traces, search_logs, and search_metrics. Choose one of those tools.", + ); + } + if (hasFields(params.fields) || params.sort?.trim()) { + throw new UserInputError( + "Use timeSeries.axes for aggregates and omit fields and sort. Grouped time series and table output settings are not supported.", + ); + } + } const hasStructuredQuery = looksLikeSentrySearchSyntax( params.query, inputDataset, @@ -765,7 +800,7 @@ async function executeSearchEvents( let timeParams: { statsPeriod?: string; start?: string; end?: string }; let explanation: string | undefined; let environment: string | string[] | null | undefined = params.environment; - let timeSeries: { yAxis: string; interval: string | null } | null = null; + let timeSeries: TimeSeriesConfig | null = null; // Set only when the query ran as-is; shown when it then matches nothing. let untranslatedQueryNote: string | null = null; @@ -777,13 +812,16 @@ async function executeSearchEvents( const hasExplicitTraceItemDataset = hasExplicitDataset && isTraceItemDataset(inputDataset); const shouldTrustStructuredTraceSearch = - hasStructuredQuery && hasExplicitTraceItemDataset; + (hasStructuredQuery || (explicitTimeSeries && !params.query?.trim())) && + hasExplicitTraceItemDataset; const environmentFilter = formatEnvironmentFilter(params.environment); const explicitStructuredTraceQuery = shouldTrustStructuredTraceSearch ? appendSearchFilter(params.query ?? "", environmentFilter) : (params.query ?? ""); const canRunWithoutAgent = - shouldTrustStructuredTraceSearch && hasExplicitFields && hasExplicitSort; + shouldTrustStructuredTraceSearch && + (explicitTimeSeries !== undefined || + (hasExplicitFields && hasExplicitSort)); // Fetch the org's real environments once: used to ground the agent prompt // (below) and to flag any requested environment that doesn't exist. Skipped @@ -797,6 +835,7 @@ async function executeSearchEvents( const seerTranslation = params.query && isSeerSearchDataset(params.dataset) && + !canRunWithoutAgent && !hasStructuredQuery && !hasExplicitFields && !hasExplicitSort @@ -806,6 +845,7 @@ async function executeSearchEvents( projectId, dataset: params.dataset, query: params.query, + timeSeriesOverride: explicitTimeSeries, }) : null; seerOutcome.translation = seerTranslation; @@ -897,6 +937,7 @@ async function executeSearchEvents( // Time series requests use yAxis/interval, so sort is not required. if ( !timeSeries && + !explicitTimeSeries && !parsed.sort?.trim() && !(shouldTrustExplicitSearchParams && hasExplicitSort) ) { @@ -907,7 +948,7 @@ async function executeSearchEvents( // Dataset-specific tools never switch datasets, even if the agent asks. dataset = - lockDataset || shouldTrustStructuredTraceSearch + lockDataset || shouldTrustStructuredTraceSearch || explicitTimeSeries ? inputDataset : parsed.dataset; sentryQuery = shouldTrustStructuredTraceSearch @@ -968,6 +1009,27 @@ async function executeSearchEvents( : null; } + if (explicitTimeSeries) { + timeSeries = explicitTimeSeries; + if (hasExplicitPeriod) { + timeParams = { statsPeriod: params.period }; + } + const { columns } = getColumnsAndAggregates(fields); + if (isAggregateQuery(fields) && columns.length > 0) { + throw new UserInputError( + "Grouped time series are not supported. Remove grouping from the query and retry with timeSeries.axes.", + ); + } + if ( + seerTranslation && + Object.values(seerTranslation.crossEventQueries).some(Boolean) + ) { + throw new UserInputError( + "Explicit time series do not support cross-event filters. Remove filters on other events in the trace and retry.", + ); + } + } + // Flag any requested environment that doesn't exist (checking both the // separate field and `environment:` tokens in the query) so the caller can // retry with a valid name instead of silently getting zero results. @@ -1081,6 +1143,18 @@ async function executeSearchEvents( } if (timeSeries) { + const hasTransformations = timeSeries.axes.some( + (axis) => (axis.transformations?.length ?? 0) > 0, + ); + if (hasTransformations && !isTraceItemDataset(dataset)) { + throw new UserInputError( + "Transformations are supported for traces, logs, and metrics only. Choose search_traces, search_logs, or search_metrics.", + ); + } + // Errors and profiles retain the legacy single-axis response contract. + if (!isTraceItemDataset(dataset)) { + timeSeries = { ...timeSeries, axes: [timeSeries.axes[0]!] }; + } const timeSeriesQuery = applyEnvironmentToEventsQuery( dataset, sentryQuery, @@ -1097,8 +1171,7 @@ async function executeSearchEvents( apiService.getEventsTimeSeries({ organizationSlug, query: timeSeriesQuery, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval ?? undefined, + timeSeries, projectId, dataset, ...timeParams, @@ -1108,7 +1181,7 @@ async function executeSearchEvents( organizationSlug, projectId, dataset: dataset as PublicEventsDataset, - interval: timeSeries.interval, + interval: timeSeries.interval ?? null, timeParams, toolAvailable: context.availableToolNames?.has("find_dropped_events") ?? false, @@ -1118,53 +1191,68 @@ async function executeSearchEvents( const pointsWithData = series.timeSeries .flatMap((s) => s.values) .filter((point) => point.value != null && point.value !== 0).length; - trackSeerOutcome(seerOutcome, params, pointsWithData); + const needsStructuredSeries = + explicitTimeSeries !== undefined || + hasTransformations || + timeSeries.axes.length > 1; + const exploreConfiguration = + isTraceItemDataset(dataset) && needsStructuredSeries + ? { + ...timeSeries, + interval: getTimeSeriesExploreInterval( + timeSeries, + series.timeSeries[0]?.meta.interval, + timeParams, + ), + } + : undefined; const statsUrl = apiService.getEventsExplorerUrl( organizationSlug, timeSeriesQuery, projectId, dataset, - [timeSeries.yAxis], - `-${timeSeries.yAxis}`, - [timeSeries.yAxis], + timeSeries.axes.map((axis) => axis.yAxis), + `-${timeSeries.axes[0]?.yAxis}`, + timeSeries.axes.map((axis) => axis.yAxis), [], timeParams.statsPeriod, timeParams.start, timeParams.end, + undefined, + exploreConfiguration, ); - if (structured) { - return buildStructuredTimeSeriesResult({ - dataset, - series, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval, - inputQuery: params.query || timeSeriesQuery, - includeExplanation: params.includeExplanation, - explanation, - query: timeSeriesQuery, - timeRange: timeParams, - url: statsUrl, - notes: droppedEventsNote - ? [...notesFor(pointsWithData), droppedEventsNote] - : notesFor(pointsWithData), - }); - } - const timeSeriesOutput = formatTimeSeriesResults({ + const resultParams = { + dataset, series, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval, + configuration: timeSeries, + yAxis: timeSeries.axes[0]!.yAxis, + interval: timeSeries.interval ?? null, inputQuery: params.query || timeSeriesQuery, includeExplanation: params.includeExplanation, - explanation, + explanation: params.includeExplanation ? explanation : undefined, + query: timeSeriesQuery, timeRange: timeParams, url: statsUrl, - }); - return withLeadingNote( - droppedEventsNote - ? `${timeSeriesOutput}\n\n${droppedEventsNote}` - : timeSeriesOutput, - pointsWithData, - ); + notes: droppedEventsNote + ? [...notesFor(pointsWithData), droppedEventsNote] + : notesFor(pointsWithData), + }; + const timeSeriesOutput = needsStructuredSeries + ? structured + ? buildStructuredOrderedTimeSeriesResult(resultParams) + : JSON.stringify(buildTimeSeriesResult(resultParams), null, 2) + : structured + ? buildStructuredTimeSeriesResult(resultParams) + : formatTimeSeriesResults(resultParams); + trackSeerOutcome(seerOutcome, params, pointsWithData); + return typeof timeSeriesOutput === "string" && !needsStructuredSeries + ? withLeadingNote( + droppedEventsNote + ? `${timeSeriesOutput}\n\n${droppedEventsNote}` + : timeSeriesOutput, + pointsWithData, + ) + : timeSeriesOutput; } // Sentry rejects the request if the sort column isn't in the selected diff --git a/packages/mcp-core/src/tools/support/search-events/seer.test.ts b/packages/mcp-core/src/tools/support/search-events/seer.test.ts new file mode 100644 index 000000000..e0b4d74b0 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/seer.test.ts @@ -0,0 +1,189 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { SentryApiService } from "../../../api-client"; +import { type SeerSearchDataset, translateWithSeer } from "./seer"; + +const baseUrl = "https://sentry.io/api/0/organizations/test-org"; +const apiService = new SentryApiService({ accessToken: "test-token" }); + +function mockTranslation( + visualization: unknown[], + groupBy: string[] = [], + logQuery?: string, +) { + mswServer.use( + http.get(`${baseUrl}/`, () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + features: ["mcp-search-events-seer-translate"], + }), + ), + http.post(`${baseUrl}/search-agent/start/`, () => + HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }), + ), + http.get(`${baseUrl}/search-agent/state/run-uuid/`, () => + HttpResponse.json({ + session: { + status: "completed", + final_response: { + responses: [ + { + query: "span.op:http.client", + group_by: groupBy, + visualization, + sort: "-count()", + stats_period: "24h", + mode: "aggregates", + log_query: logQuery, + }, + ], + }, + }, + }), + ), + ); +} + +const translate = async (dataset: SeerSearchDataset = "spans") => + await translateWithSeer({ + apiService, + organizationSlug: "test-org", + dataset, + query: "requests per hour", + }); + +describe("Seer time series translation", () => { + const datasets: SeerSearchDataset[] = ["spans", "logs", "metrics"]; + it.each(datasets)( + "excludes non-bucketed Seer charts from %s time series", + async (dataset) => { + mockTranslation([ + { y_axes: ["count_unique(user)"], interval: null }, + { y_axes: ["count()"], interval: "1h" }, + { y_axes: ["count()"] }, + ]); + + expect((await translate(dataset))?.timeSeries).toEqual({ + axes: [{ yAxis: "count()" }], + interval: "1h", + }); + }, + ); + + it("rejects transformations on a non-bucketed chart beside a time series", async () => { + mockTranslation([ + { y_axes: ["count()"], interval: null, transformations: ["fill(zero)"] }, + { y_axes: ["count()"], interval: "1h" }, + ]); + + await expect(translate()).rejects.toThrow("supported time-series request"); + }); + + it.each([null, "1h"])( + "accepts null transformations with interval %s", + async (interval) => { + mockTranslation([ + { y_axes: ["count()"], interval, transformations: null }, + ]); + + expect(await translate()).toMatchObject({ + fields: ["count()"], + timeSeries: interval + ? { axes: [{ yAxis: "count()" }], interval } + : null, + }); + }, + ); + + it.each([ + { interval: "0m", transformations: ["fill(zero)"] }, + { interval: "1h", transformations: ["smooth(sma,0)"] }, + ])( + "rejects invalid transformed configuration $interval instead of raw fallback", + async (chart) => { + mockTranslation([{ y_axes: ["count()"], ...chart }]); + await expect(translate()).rejects.toThrow( + "unsupported time-series configuration", + ); + }, + ); + it("preserves duplicate axes and their ordered transformations", async () => { + mockTranslation([ + { + y_axes: ["count()", "sum(span.duration)"], + interval: "1h", + transformations: ["fill(zero)", "smooth(sma,3)"], + }, + { + y_axes: ["count()"], + interval: "1h", + transformations: ["smooth(ema,5,0.3)", "fill(locf)"], + }, + ]); + + expect((await translate())?.timeSeries).toEqual({ + axes: [ + { + yAxis: "count()", + transformations: ["fill(zero)", "smooth(sma,3)"], + }, + { + yAxis: "sum(span.duration)", + transformations: ["fill(zero)", "smooth(sma,3)"], + }, + { + yAxis: "count()", + transformations: ["smooth(ema,5,0.3)", "fill(locf)"], + }, + ], + interval: "1h", + }); + }); + + it("rejects transformed grouped output instead of returning a raw table", async () => { + mockTranslation( + [ + { + y_axes: ["count()"], + interval: "1h", + transformations: ["fill(zero)"], + }, + ], + ["span.op"], + ); + + await expect(translate()).rejects.toThrow( + "Grouped transformed time series", + ); + }); + + it("rejects incompatible chart intervals instead of dropping axes", async () => { + mockTranslation([ + { y_axes: ["count()"], interval: "1h", transformations: ["fill(zero)"] }, + { y_axes: ["count()"], interval: "1d" }, + ]); + + await expect(translate()).rejects.toThrow("different intervals"); + }); + + it("rejects transformed cross-event output instead of broadening the filter", async () => { + mockTranslation( + [ + { + y_axes: ["count()"], + interval: "1h", + transformations: ["fill(zero)"], + }, + ], + [], + "severity:error", + ); + + await expect(translate()).rejects.toThrow( + "do not support cross-event filters", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 965196cd0..c24f220a4 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -1,7 +1,7 @@ import { logger, metrics } from "@sentry/core"; import { isAggregateFieldOrEquation } from "@sentry/toolkit-core/events-fields"; import { getSortField } from "@sentry/toolkit-core/events-sort"; -import type { z } from "zod"; +import { ZodError, type z } from "zod"; import type { CrossEventQueries, SentryApiService } from "../../../api-client"; import { ApiAuthenticationError, @@ -9,12 +9,17 @@ import { type SearchAgentQuerySchema, } from "../../../api-client/index"; import { logWarn } from "../../../telem/logging"; +import { UserInputError } from "../../../errors"; import { type EventsApiDataset, normalizeEventsDataset, type PublicEventsDataset, } from "../../../utils/events-datasets"; import type { ExploreMode } from "../../../utils/url-utils"; +import { + TimeSeriesConfigSchema, + type TimeSeriesConfig, +} from "../../../utils/time-series"; import { RECOMMENDED_FIELDS } from "./config"; export const SEER_SEARCH_AGENT_POLLING_INTERVAL = 1000; // 1 second @@ -51,7 +56,7 @@ export interface SeerSearchTranslation { fields: string[]; sort: string; timeParams: { statsPeriod?: string; start?: string; end?: string }; - timeSeries: { yAxis: string; interval: string } | null; + timeSeries: TimeSeriesConfig | null; crossEventQueries: CrossEventQueries; // Other projects Seer suggested searching beyond the requested one. suggestedProjectIds: number[]; @@ -154,6 +159,7 @@ function toSearchTranslation( dataset: SeerSearchDataset, suggestedProjectIds: number[], run: { runId: string; organizationFeatures: string[] }, + timeSeriesOverride?: TimeSeriesConfig, ): SeerSearchTranslation { const aggregates = result.visualization.flatMap((chart) => chart.y_axes); const fields = @@ -187,16 +193,70 @@ function toSearchTranslation( const chartWithInterval = result.visualization.find( (chart) => chart.interval && chart.y_axes[0], ); + const hasTransformations = timeSeriesOverride + ? timeSeriesOverride.axes.some( + (axis) => (axis.transformations?.length ?? 0) > 0, + ) + : result.visualization.some( + (chart) => (chart.transformations?.length ?? 0) > 0, + ); + if ( + (timeSeriesOverride || hasTransformations) && + result.group_by.length > 0 + ) { + throw new UserInputError( + "Grouped transformed time series are not supported. Remove grouping and retry with timeSeries.axes.", + ); + } + if ( + !timeSeriesOverride && + hasTransformations && + (result.mode !== "aggregates" || + !chartWithInterval || + result.visualization.some( + (chart) => (chart.transformations?.length ?? 0) > 0 && !chart.interval, + )) + ) { + throw new UserInputError( + "The translated transformations need a supported time-series request. Supply explicit timeSeries.axes and interval instead.", + ); + } + if ( + dataset !== "errors" && + !timeSeriesOverride && + chartWithInterval && + result.visualization.some( + (chart) => + chart.y_axes.length > 0 && + chart.interval && + chart.interval !== chartWithInterval.interval, + ) + ) { + throw new UserInputError( + "Time-series charts with different intervals cannot be combined. Retry with one explicit timeSeries.interval.", + ); + } const timeSeries = - result.mode === "aggregates" && + timeSeriesOverride ?? + (result.mode === "aggregates" && result.group_by.length === 0 && chartWithInterval?.interval && chartWithInterval.y_axes[0] - ? { - yAxis: chartWithInterval.y_axes[0], + ? TimeSeriesConfigSchema.parse({ + axes: (dataset === "errors" && !hasTransformations + ? [chartWithInterval] + : result.visualization.filter( + (chart) => chart.interval === chartWithInterval.interval, + ) + ).flatMap((chart) => + chart.y_axes.map((yAxis) => ({ + yAxis, + transformations: chart.transformations, + })), + ), interval: chartWithInterval.interval, - } - : null; + }) + : null); let timeParams: SeerSearchTranslation["timeParams"]; if (result.stats_period) { @@ -224,6 +284,11 @@ function toSearchTranslation( let explanation = SEER_EXPLANATION; let warning: string | undefined; if (crossEventFilters.length > 0) { + if (timeSeriesOverride || hasTransformations) { + throw new UserInputError( + "Transformed time series do not support cross-event filters. Remove filters on other events in the trace and retry.", + ); + } if (timeSeries) { // The time series endpoint doesn't support cross-event filters. warning = `**Warning:** Time series results are unfiltered by the requested cross-event filters (${crossEventFilters.join(", ")}). Counts and other values may include events outside the requested subset.`; @@ -333,8 +398,11 @@ async function runSearchAgent({ }); return null; } catch (error) { - // An invalid token should surface to the user rather than fall back. - if (error instanceof ApiAuthenticationError) { + if ( + error instanceof ApiAuthenticationError || + error instanceof UserInputError || + (strategy !== "Issues" && error instanceof ZodError) + ) { throw error; } logWarn(error, { extra: { organizationSlug } }); @@ -355,30 +423,63 @@ export async function translateWithSeer({ projectId, dataset, query, + timeSeriesOverride, }: { apiService: SentryApiService; organizationSlug: string; projectId?: string; dataset: SeerSearchDataset; query: string; + timeSeriesOverride?: TimeSeriesConfig; }): Promise { - const run = await runSearchAgent({ - apiService, - organizationSlug, - projectId, - strategy: SEER_STRATEGIES[dataset], - query, - }); - if (!run) { + try { + const run = await runSearchAgent({ + apiService, + organizationSlug, + projectId, + strategy: SEER_STRATEGIES[dataset], + query, + }); + if (!run) { + return null; + } + // Seer can suggest broadening a project-scoped search, e.g. to other + // services in the same trace. With all projects requested there is nothing + // to add. + const suggestedProjectIds = projectId + ? run.projectIds.filter((id) => id !== Number(projectId)) + : []; + return toSearchTranslation( + run.result, + dataset, + suggestedProjectIds, + run, + timeSeriesOverride, + ); + } catch (error) { + if ( + !timeSeriesOverride && + error instanceof ZodError && + error.issues.some((issue) => + issue.path.some((part) => + ["axes", "interval", "transformations"].includes(String(part)), + ), + ) + ) { + throw new UserInputError( + "Seer returned an unsupported time-series configuration. Retry with explicit timeSeries.axes, supported transformations, and a valid interval.", + { cause: error }, + ); + } + if ( + error instanceof ApiAuthenticationError || + error instanceof UserInputError + ) { + throw error; + } + logWarn(error, { extra: { organizationSlug } }); return null; } - // Seer can suggest broadening a project-scoped search, e.g. to other - // services in the same trace. With all projects requested there is nothing - // to add. - const suggestedProjectIds = projectId - ? run.projectIds.filter((id) => id !== Number(projectId)) - : []; - return toSearchTranslation(run.result, dataset, suggestedProjectIds, run); } export interface SeerIssueSearchTranslation { diff --git a/packages/mcp-core/src/tools/support/search-events/structured.ts b/packages/mcp-core/src/tools/support/search-events/structured.ts index bcc44c084..4f3613e16 100644 --- a/packages/mcp-core/src/tools/support/search-events/structured.ts +++ b/packages/mcp-core/src/tools/support/search-events/structured.ts @@ -31,6 +31,53 @@ import { summarizeTimeSeries, } from "./formatters"; import { type FlexibleEventData, isAggregateQuery } from "./utils"; +import { buildTimeSeriesResult } from "./time-series-results"; + +const seriesMaximumSchema = z.object({ + value: z.union([z.number(), z.string()]), + timestamp: z.number(), +}); + +const orderedTimeSeriesSchema = z.object({ + requestedInterval: z.string().nullable(), + series: z.array( + z.object({ + axisIndex: z.number(), + yAxis: z.string(), + transformations: z.array(z.string()), + intervalMs: z.number(), + valueType: z.string().optional(), + valueUnit: z.string().nullish(), + summary: z + .object({ + total: z.number().optional(), + peak: seriesMaximumSchema.optional(), + derivedMaximum: seriesMaximumSchema.optional(), + }) + .optional(), + buckets: z.array( + z.object({ + timestamp: z.number(), + value: z.union([z.number(), z.string()]).nullable(), + incomplete: z.boolean(), + incompleteReason: z.string().optional(), + comparisonValue: z + .union([z.number(), z.string()]) + .nullable() + .optional(), + transformation: z + .object({ + filled: z.boolean().optional(), + smoothed: z.boolean().optional(), + comparisonFilled: z.boolean().optional(), + comparisonSmoothed: z.boolean().optional(), + }) + .optional(), + }), + ), + }), + ), +}); export const timeRangeSchema = z.object({ statsPeriod: z.string().nullable(), @@ -340,6 +387,41 @@ export function buildSearchEventsOutputSchema< return searchEventsOutputSchema(z.object(STRUCTURED_DATASETS[dataset].links)); } +export function buildTimeSeriesSearchOutputSchema< + TDataset extends "spans" | "logs" | "metrics", +>(dataset: TDataset) { + return buildSearchEventsOutputSchema(dataset).extend( + orderedTimeSeriesSchema.partial().shape, + ); +} + +export function buildStructuredOrderedTimeSeriesResult( + params: Parameters[0], +): StructuredToolOutput< + SearchEventsStructuredContent & z.output +> { + const result = buildTimeSeriesResult(params); + return structuredResult({ + query: params.inputQuery, + explanation: params.explanation ?? null, + executedSearch: toExecutedSearch({ + dataset: params.dataset, + query: params.query, + fields: params.configuration.axes.map((axis) => axis.yAxis), + timeRange: params.timeRange, + }), + url: params.url, + presentationHint: DASHBOARD_LINK_HINT, + notes: params.notes ?? [], + resultType: "timeSeries", + results: [], + timeSeries: null, + suggestedActions: [], + requestedInterval: result.requestedInterval, + series: result.series, + }); +} + /** The payload of the dataset search tool that searches `TDataset`. */ export type DatasetSearchStructuredContent< TDataset extends StructuredSearchDataset,