diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 4d760c3d5..6a9868c13 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -122,6 +122,9 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - **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 opt-in**: Seer translation runs only in experimental sessions (`--experimental` for stdio or `/mcp?experimental=1` for HTTP), when the organization has the required Seer features. Default sessions use the configured embedded agent for natural-language translation; if Seer is unavailable in an experimental session, the tool falls back to that agent. +- **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. +- **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 - **Tracemetrics sort handling**: Aggregate sort expressions like `-p95(value,...)` must be sent to the API unchanged diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 3c4e67ecc..4cb4daabd 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -94,6 +94,8 @@ import { ReplayListResponseSchema, ReplayRecordingSegmentsSchema, RepositoryListSchema, + SearchAgentStartSchema, + SearchAgentStateSchema, SentryAppComponentListSchema, SentryAppExternalRequestOptionsSchema, SentryAppInstallationListSchema, @@ -124,6 +126,8 @@ import type { AlertRuleUpdate, AutofixRun, AutofixRunState, + SearchAgentStart, + SearchAgentState, ClientKey, ClientKeyList, CommitList, @@ -189,6 +193,16 @@ import type { const SENTRY_MCP_SEARCH_EVENTS_REFERRER = "api.mcp.search-events"; +/** + * Filters on other events in the same trace, e.g. spans whose trace also has a + * matching log. Only the spans and logs datasets support them. + */ +export type CrossEventQueries = { + spanQuery?: string; + logQuery?: string; + metricQuery?: string; +}; + type ExplorerAggregateParams = { fields?: string[]; aggregateFunctions?: string[]; @@ -1662,12 +1676,28 @@ export class SentryApiService { * Gets a single organization by slug. * * @param organizationSlug Organization identifier + * @param params Query parameters + * @param params.includeFeatureFlags Include `features` in the response (omitted by Sentry otherwise) + * @param params.detailed Include projects and teams (Sentry defaults to true) * @param opts Request options including host override * @returns Organization data */ - async getOrganization(organizationSlug: string, opts?: RequestOptions) { + async getOrganization( + organizationSlug: string, + params?: { includeFeatureFlags?: boolean; detailed?: boolean }, + opts?: RequestOptions, + ) { + const queryParams = new URLSearchParams(); + if (params?.includeFeatureFlags) { + queryParams.set("include_feature_flags", "1"); + } + if (params?.detailed === false) { + queryParams.set("detailed", "0"); + } + const queryString = queryParams.toString(); + const organizationPath = apiPath`/organizations/${organizationSlug}/`; const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/`, + `${organizationPath}${queryString ? `?${queryString}` : ""}`, undefined, opts, ); @@ -4968,6 +4998,7 @@ export class SentryApiService { start?: string; end?: string; sort: string; + crossEventQueries?: CrossEventQueries; }): URLSearchParams { const queryParams = new URLSearchParams(); @@ -4992,6 +5023,11 @@ export class SentryApiService { queryParams.set("sampling", "NORMAL"); } + const { spanQuery, logQuery, metricQuery } = params.crossEventQueries ?? {}; + if (spanQuery) queryParams.set("spanQuery", spanQuery); + if (logQuery) queryParams.set("logQuery", logQuery); + if (metricQuery) queryParams.set("metricQuery", metricQuery); + queryParams.set("sort", params.sort); // Add fields @@ -5024,6 +5060,7 @@ export class SentryApiService { start, end, sort = "-timestamp", + crossEventQueries, }: { organizationSlug: string; query: string; @@ -5035,6 +5072,7 @@ export class SentryApiService { start?: string; end?: string; sort?: string; + crossEventQueries?: CrossEventQueries; }, opts?: RequestOptions, ) { @@ -5070,6 +5108,7 @@ export class SentryApiService { start, end, sort, + crossEventQueries, }); } @@ -5182,6 +5221,55 @@ export class SentryApiService { return AutofixRunStateSchema.parse(body); } + // POST https://us.sentry.io/api/0/organizations/my-org/search-agent/start/ + async startSearchAgent( + { + organizationSlug, + projectIds, + naturalLanguageQuery, + strategy, + }: { + organizationSlug: string; + projectIds: number[]; + naturalLanguageQuery: string; + strategy: "Traces" | "Issues" | "Logs" | "Errors" | "Metrics"; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + apiPath`/organizations/${organizationSlug}/search-agent/start/`, + { + method: "POST", + body: JSON.stringify({ + project_ids: projectIds, + natural_language_query: naturalLanguageQuery, + strategy, + }), + }, + opts, + ); + return SearchAgentStartSchema.parse(body); + } + + // GET https://us.sentry.io/api/0/organizations/my-org/search-agent/state/{runId}/ + async getSearchAgentState( + { + organizationSlug, + runId, + }: { + organizationSlug: string; + runId: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + apiPath`/organizations/${organizationSlug}/search-agent/state/${runId}/`, + undefined, + opts, + ); + return SearchAgentStateSchema.parse(body); + } + /** * Retrieves high-level metadata about a trace. * diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 15749266a..f7b44e3aa 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -84,6 +84,9 @@ export const OrganizationSchema = z organizationUrl: z.string().url(), }) .optional(), + // Only returned by the organization details endpoint, not the list endpoint. + features: z.array(z.string()).optional(), + hideAiFeatures: z.boolean().optional(), }) .passthrough(); @@ -1414,6 +1417,75 @@ export const AutofixRunStateSchema = z.object({ formatted: z.object({ format: z.string(), content: z.string() }).optional(), }); +/** + * Schemas for Seer's search agent, which translates natural language into + * Sentry search queries. + * + * Upstream source of truth in getsentry/sentry: + * - `src/sentry/seer/endpoints/search_agent_start.py` + * - `src/sentry/seer/endpoints/search_agent_state.py` + * - `src/sentry/seer/endpoints/search_agent_types.py` + */ +export const SearchAgentStartSchema = z + .object({ + // Null until Seer has picked up the run; poll with sentry_run_id instead. + run_id: z.number().nullable(), + sentry_run_id: z.string(), + }) + .passthrough(); + +export const SearchAgentQuerySchema = z + .object({ + query: z.string(), + group_by: z.array(z.string()).default([]), + visualization: z + .array( + z + .object({ + 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(), + }) + .passthrough(), + ) + .default([]), + sort: z.string().default(""), + // Empty when an absolute start/end range is used instead. + stats_period: z.string().default(""), + start: z.string().nullable().optional(), + end: z.string().nullable().optional(), + mode: z.string(), + // Cross-event filters, only set for the Traces strategy. + span_query: z.string().nullable().optional(), + log_query: z.string().nullable().optional(), + metric_query: z.string().nullable().optional(), + }) + .passthrough(); + +export const SearchAgentTranslateSchema = z + .object({ + responses: z.array(SearchAgentQuerySchema), + unsupported_reason: z.string().nullable().optional(), + // Projects Seer scoped the query to, a superset of the requested projects + // when it broadens scope. Absent when there's no expansion. + project_ids: z.array(z.number()).nullable().optional(), + }) + .passthrough(); + +export const SearchAgentStateSchema = z + .object({ + session: z + .object({ + // Only `status` is set while the run is still being created in Seer. + status: z.string(), + final_response: SearchAgentTranslateSchema.nullable().optional(), + unsupported_reason: z.string().nullable().optional(), + }) + .passthrough() + .nullable(), + }) + .passthrough(); + export const EventAttachmentSchema = z.object({ id: z.string(), name: z.string(), diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 9c896021b..1405480eb 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -54,6 +54,8 @@ import type { AssignedToSchema, AutofixRunSchema, AutofixRunStateSchema, + SearchAgentStartSchema, + SearchAgentStateSchema, ClientKeyListSchema, ClientKeySchema, CommitListSchema, @@ -239,6 +241,8 @@ export type EventAttachment = z.infer; export type Tag = z.infer; export type AutofixRun = z.infer; export type AutofixRunState = z.infer; +export type SearchAgentStart = z.infer; +export type SearchAgentState = z.infer; export type AssignedTo = z.infer; export type ReplayDetails = z.infer; export type ReplayList = z.infer["data"]; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 7fabf4bae..cd5c13e03 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -194,7 +194,7 @@ }, { "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "requiredScopes": ["event:read"] }, { @@ -269,7 +269,7 @@ }, { "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "requiredScopes": ["event:read"] }, { @@ -405,7 +405,7 @@ }, { "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 7924f263b..1b1c4eca4 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7137,7 +7137,7 @@ }, { "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "inputSchema": { "type": "object", "properties": { @@ -7146,7 +7146,7 @@ "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." }, "dataset": { - "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured.", + "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. Always pass it, including for natural language queries. The agent may correct it when configured.", "type": "string", "enum": ["spans", "errors", "logs", "metrics", "profiles", "replays"] }, 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 578d9b9ec..b3cfb3f40 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3338,4 +3338,702 @@ describe("search_events", () => { expect(mockGenerateText).not.toHaveBeenCalled(); }); + + describe("with Seer", () => { + const seerParams = { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: "test-project", + dataset: "spans" as const, + query: "slowest http requests in the last day", + fields: null, + sort: null, + period: undefined, + limit: 10, + includeExplanation: true, + }; + const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + experimentalMode: true, + }; + const seerQuery = { + query: "span.op:http.client", + group_by: ["span.description"], + visualization: [ + { chart_type: 1, y_axes: ["p95(span.duration)"], interval: null }, + ], + sort: "-p95(span.duration)", + stats_period: "24h", + start: null, + end: null, + mode: "aggregates", + result_count: 1, + span_query: null, + log_query: null, + metric_query: null, + }; + + const mockOrganization = (features: string[]) => + http.get( + "https://sentry.io/api/0/organizations/test-org/", + ({ request }) => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + // Sentry only serializes features when explicitly requested. + ...(new URL(request.url).searchParams.get( + "include_feature_flags", + ) === "1" + ? { features } + : {}), + hideAiFeatures: false, + }), + ); + const mockProject = http.get( + "https://sentry.io/api/0/projects/test-org/test-project/", + () => HttpResponse.json({ id: "42", slug: "test-project", name: "Test" }), + ); + const mockSeerStart = vi.fn(async ({ request }: { request: Request }) => { + expect(await request.json()).toEqual({ + project_ids: [42], + natural_language_query: "slowest http requests in the last day", + strategy: "Traces", + }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }); + const mockSeerState = (session: Record) => + http.get( + "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/", + () => HttpResponse.json({ session, sentry_run_id: "run-uuid" }), + ); + + beforeEach(() => { + mockSeerStart.mockClear(); + mswServer.use( + mockProject, + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + mockSeerStart, + ), + ); + }); + + it("uses the embedded agent without experimental opt-in", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + const result = await searchEvents.handler(seerParams, { + ...context, + experimentalMode: undefined, + }); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + expect(result).not.toContain("Translated by Seer's search agent."); + }); + + it("should translate natural language queries with Seer", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], unsupported_reason: null }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("query")).toBe("span.op:http.client"); + expect(url.searchParams.getAll("field")).toEqual([ + "span.description", + "p95(span.duration)", + ]); + expect(url.searchParams.get("sort")).toBe("-p95(span.duration)"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + return HttpResponse.json({ + data: [ + { + "span.description": "GET /api/users", + "p95(span.duration)": 1200, + }, + ], + }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("GET /api/users"); + expect(result).toContain("Translated by Seer's search agent."); + }); + + it("should return a time series when Seer sets an interval", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + query: "", + group_by: [], + visualization: [ + { chart_type: 1, y_axes: ["count()"], interval: "1d" }, + ], + sort: "-count()", + stats_period: "7d", + }, + ], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("yAxis")).toBe("count()"); + expect(url.searchParams.get("interval")).toBe("1d"); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + return HttpResponse.json({ + data: [ + [1757548800, [{ count: 5 }]], + [1757635200, [{ count: 8 }]], + ], + }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("## count() over time"); + expect(result).toContain("- **Total**: 13"); + expect(result).not.toContain("**Warning:**"); + }); + + it("should apply Seer's cross-event filters", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + span_query: "span.op:db", + log_query: "severity:error", + }, + ], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("spanQuery")).toBe("span.op:db"); + expect(url.searchParams.get("logQuery")).toBe("severity:error"); + expect(url.searchParams.has("metricQuery")).toBe(false); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(result).toContain( + "Only includes results whose trace also has matching spans `span.op:db`, logs `severity:error`.", + ); + expect(result).not.toContain("**Warning:**"); + }); + + it.each([false, undefined, true])( + "warns about unapplied time-series filters with includeExplanation=%s", + async (includeExplanation) => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + group_by: [], + visualization: [ + { chart_type: 1, y_axes: ["count()"], interval: "1h" }, + ], + sort: "-count()", + span_query: "span.op:db", + log_query: "severity:error", + metric_query: "metric.name:requests", + }, + ], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.has("spanQuery")).toBe(false); + expect(url.searchParams.has("logQuery")).toBe(false); + expect(url.searchParams.has("metricQuery")).toBe(false); + return HttpResponse.json({ + data: [[1757548800, [{ count: 100 }]]], + }); + }, + ), + ); + + const result = await searchEvents.handler( + { + ...seerParams, + includeExplanation: + searchEvents.inputSchema.includeExplanation.parse( + includeExplanation, + ), + }, + context, + ); + + const warning = + "**Warning:** Time series results are unfiltered by the requested cross-event filters (spans `span.op:db`, logs `severity:error`, metrics `metric.name:requests`). Counts and other values may include events outside the requested subset."; + expect(result.startsWith(`${warning}\n\n`)).toBe(true); + expect(result.split(warning)).toHaveLength(2); + expect(result.includes("Translated by Seer's search agent.")).toBe( + includeExplanation === true, + ); + expect(result).toContain("- **Total**: 100"); + if (includeExplanation === false) { + expect(result).toMatchInlineSnapshot(` + "**Warning:** Time series results are unfiltered by the requested cross-event filters (spans \`span.op:db\`, logs \`severity:error\`, metrics \`metric.name:requests\`). Counts and other values may include events outside the requested subset. + + # Search Results for "slowest http requests in the last day" + + ## count() over time + - **Interval**: \`1h\` + - **Time range**: Last 24h + - **Total**: 100 + - **Peak**: 100 at 2025-09-11 00:00 + + ## Buckets + + | Time (UTC) | Value | + | --- | --- | + | 2025-09-11 00:00 | 100 | + + **View these results in Sentry**: + https://test-org.sentry.io/explore/traces/?query=span.op%3Ahttp.client&project=42&aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D&mode=aggregate&sort=-count%28%29&statsPeriod=24h&table=span + Please tell the user this dashboard link is available if they want to open the results in Sentry." + `); + } + }, + ); + + it("should keep a grouped Seer query with an interval as a table", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + visualization: [ + { + chart_type: 1, + y_axes: ["p95(span.duration)"], + interval: "1h", + }, + ], + }, + ], + unsupported_reason: null, + }, + }), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(result).not.toContain("over time"); + }); + + it("should add an explicit environment to Seer's query", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], unsupported_reason: null }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + () => HttpResponse.json([{ id: "1", name: "production" }]), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe( + "span.op:http.client environment:production", + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler( + { ...seerParams, environment: "production" }, + context, + ); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + + it.each([ + ["suggest", context, true], + [ + "not suggest in a project-scoped session", + { + ...context, + constraints: { ...context.constraints, projectSlug: "test-project" }, + }, + false, + ], + ])( + "should keep the requested project and %s Seer's wider scope", + async (_, handlerContext, expectNote) => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [seerQuery], + unsupported_reason: null, + project_ids: [42, 43], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("project")).toEqual(["42"]); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, handlerContext); + + expect(mockSeerStart).toHaveBeenCalled(); + expect( + result.includes("Seer suggested also searching project IDs 43"), + ).toBe(expectNote); + }, + ); + + it("should keep the requested project when Seer does not broaden it", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [seerQuery], + unsupported_reason: null, + project_ids: [42], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("project")).toEqual(["42"]); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(result).not.toContain("Seer suggested also searching"); + }); + + it("should search all accessible projects without a projectSlug", async () => { + const mockAllProjectsStart = vi.fn( + async ({ request }: { request: Request }) => { + expect(await request.json()).toMatchObject({ project_ids: [-1] }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }, + ); + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + mockAllProjectsStart, + ), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], unsupported_reason: null }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json([{ id: "1", name: "production" }]); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json(validEventsValidationResponse); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json({ data: [] }); + }, + { once: true }, + ), + ); + + const result = await searchEvents.handler( + { ...seerParams, projectSlug: null, environment: "production" }, + context, + ); + + expect(mockAllProjectsStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("project=-1"); + }); + + it("should keep Seer's all-project scope for time series", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + async ({ request }) => { + expect(await request.json()).toMatchObject({ project_ids: [-1] }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }, + { once: true }, + ), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + group_by: [], + visualization: [{ y_axes: ["count()"], interval: "1d" }], + }, + ], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json({ data: [] }); + }, + { once: true }, + ), + ); + + const result = await searchEvents.handler( + { ...seerParams, projectSlug: null }, + context, + ); + + expect(result).toContain("project=-1"); + }); + + it("should prefer an explicit period over Seer's time range", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], unsupported_reason: null }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler({ ...seerParams, period: "7d" }, context); + + expect(mockSeerStart).toHaveBeenCalled(); + }); + + it("should not group by a non-aggregate Seer sort", async () => { + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [{ ...seerQuery, sort: "-timestamp" }], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("field")).toEqual([ + "span.description", + "p95(span.duration)", + ]); + expect(url.searchParams.get("sort")).toBe("-p95(span.duration)"); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + }); + + it.each([ + ["a structured query", { query: "span.op:http.client" }], + ["explicit fields", { fields: ["span.description", "count()"] }], + ["an explicit sort", { sort: "-count()" }], + ])("should skip Seer for %s", async (_, overrides) => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler({ ...seerParams, ...overrides }, context); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + + it("should fall back to the agent when Seer is not enabled", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization([]), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + ({ request }) => { + expect(new URL(request.url).searchParams.has("project")).toBe( + false, + ); + return HttpResponse.json([]); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + ({ request }) => { + expect(new URL(request.url).searchParams.has("project")).toBe( + false, + ); + return HttpResponse.json(validEventsValidationResponse); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + expect(new URL(request.url).searchParams.has("project")).toBe( + false, + ); + return HttpResponse.json({ data: [] }); + }, + { once: true }, + ), + ); + + await searchEvents.handler({ ...seerParams, projectSlug: null }, context); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + + it("should fall back to the agent when Seer cannot translate", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + mockSeerState({ status: "error", unsupported_reason: "Unsupported" }), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + + it("should fall back to the agent when Seer returns 403", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-search-agent-translate"]), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + () => + HttpResponse.json( + { detail: "Feature flag not enabled" }, + { status: 403 }, + ), + ), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockGenerateText).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index b52bcbe08..180c53d84 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -45,6 +45,10 @@ import { formatReplayResults, isValidReplaySort, } from "../support/search-events/replays"; +import { + isSeerSearchDataset, + translateWithSeer, +} from "../support/search-events/seer"; import { formatEventsValidationResults, isAggregateQuery, @@ -398,9 +402,9 @@ export default defineTool({ "NOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).", "", "", - "search_events(organizationSlug='my-org', query='how many errors today')", + "search_events(organizationSlug='my-org', dataset='errors', query='how many errors today')", "search_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')", - "search_events(organizationSlug='my-org', query='errors per hour last 24h')", + "search_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')", "search_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')", "search_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')", "", @@ -417,7 +421,7 @@ export default defineTool({ .enum(SEARCH_EVENTS_DATASETS) .optional() .describe( - "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured.", + "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. Always pass it, including for natural language queries. The agent may correct it when configured.", ), query: z .string() @@ -509,17 +513,6 @@ export default defineTool({ isTraceItemDataset(inputDataset) && hasStructuredQuery; - if ( - !hasAgentProvider() && - inputDataset !== "replays" && - params.environment && - !canApplyEnvironmentFilter - ) { - throw new UserInputError( - "The `environment` parameter is only supported for dataset='replays'. For other datasets, include environment filtering in the query string instead.", - ); - } - let projectId: string | undefined; if (params.projectSlug) { const project = await apiService.getProject({ @@ -558,7 +551,44 @@ export default defineTool({ // (below) and to flag any requested environment that doesn't exist. Skipped // only when nothing references an environment — including a structured query // that skips the agent but puts `environment:` in the query string. - const willRunAgent = hasAgentProvider() && !canRunWithoutAgent; + // Seer only translates into the dataset it is given, so it runs only when + // one is explicit. It only sees the natural language query, so skip it for + // structured queries and explicit fields or sort, which the embedded agent + // preserves. Like the UI, an explicit environment is added to Seer's query + // afterwards. + const seerTranslation = + context.experimentalMode && + params.query && + isSeerSearchDataset(params.dataset) && + !hasStructuredQuery && + !hasExplicitFields && + !hasExplicitSort + ? await translateWithSeer({ + apiService, + organizationSlug, + projectId, + dataset: params.dataset, + query: params.query, + }) + : null; + if (seerTranslation && !projectId) { + projectId = "-1"; + } + + if ( + !hasAgentProvider() && + inputDataset !== "replays" && + params.environment && + !canApplyEnvironmentFilter && + !seerTranslation + ) { + throw new UserInputError( + "The `environment` parameter is only supported for dataset='replays'. For other datasets, include environment filtering in the query string instead.", + ); + } + + const willRunAgent = + hasAgentProvider() && !canRunWithoutAgent && !seerTranslation; const inputReferencesEnvironment = params.environment != null || collectRequestedEnvironments(null, params.query ?? "").length > 0; @@ -574,7 +604,18 @@ export default defineTool({ environmentNames.map((name) => name.toLowerCase()), ); - if (willRunAgent) { + if (seerTranslation) { + dataset = inputDataset; + sentryQuery = seerTranslation.query; + fields = seerTranslation.fields; + sortParam = seerTranslation.sort; + // Seer never sees `period`, so an explicit one wins over its time range. + timeParams = hasExplicitPeriod + ? { statsPeriod: params.period } + : seerTranslation.timeParams; + explanation = seerTranslation.explanation; + timeSeries = seerTranslation.timeSeries; + } else if (willRunAgent) { const parsed = await withProviderFallback({ operation: "search_events.rewrite", fallback: () => ({ @@ -686,8 +727,24 @@ export default defineTool({ unknownEnvironments.length > 0 ? formatUnknownEnvironmentNote(unknownEnvironments, environmentNames) : ""; - const withEnvironmentNote = (text: string): string => - environmentNote ? `${environmentNote}\n\n${text}` : text; + // The caller chose the project (or the session is scoped to it), so Seer's + // wider scope is only suggested. Scoped sessions can't change the project. + const suggestedProjectIds = context.constraints.projectSlug + ? [] + : (seerTranslation?.suggestedProjectIds ?? []); + const projectSuggestionNote = + suggestedProjectIds.length > 0 + ? `**Note:** Seer suggested also searching project IDs ${suggestedProjectIds.join(", ")}, for example other services in the same trace. Omit \`projectSlug\` to search all accessible projects.` + : ""; + const leadingNote = [ + seerTranslation?.warning, + environmentNote, + projectSuggestionNote, + ] + .filter(Boolean) + .join("\n\n"); + const withLeadingNote = (text: string): string => + leadingNote ? `${leadingNote}\n\n${text}` : text; if (dataset === "replays") { const replaySort = sortParam || DEFAULT_REPLAY_SORT; @@ -756,7 +813,7 @@ export default defineTool({ availableToolNames: context.availableToolNames, directToolNames: context.directToolNames, }); - return withEnvironmentNote(replayOutput); + return withLeadingNote(replayOutput); } if (timeSeries) { @@ -791,7 +848,7 @@ export default defineTool({ timeParams.start, timeParams.end, ); - return withEnvironmentNote( + return withLeadingNote( formatTimeSeriesResults({ series, yAxis: timeSeries.yAxis, @@ -866,6 +923,7 @@ export default defineTool({ projectId, dataset, sort: sortParam, + crossEventQueries: seerTranslation?.crossEventQueries, ...timeParams, }); @@ -947,15 +1005,15 @@ export default defineTool({ switch (dataset) { case "errors": - return withEnvironmentNote(formatErrorResults(formatParams)); + return withLeadingNote(formatErrorResults(formatParams)); case "logs": - return withEnvironmentNote(formatLogResults(formatParams)); + return withLeadingNote(formatLogResults(formatParams)); case "spans": - return withEnvironmentNote(formatSpanResults(formatParams)); + return withLeadingNote(formatSpanResults(formatParams)); case "profiles": - return withEnvironmentNote(formatProfileResults(formatParams)); + return withLeadingNote(formatProfileResults(formatParams)); default: - return withEnvironmentNote(formatTraceMetricsResults(formatParams)); + return withLeadingNote(formatTraceMetricsResults(formatParams)); } }, }); diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts new file mode 100644 index 000000000..5ba7408d0 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -0,0 +1,238 @@ +import type { z } from "zod"; +import type { CrossEventQueries, SentryApiService } from "../../../api-client"; +import { + ApiAuthenticationError, + type SearchAgentQuerySchema, +} from "../../../api-client/index"; +import { logWarn } from "../../../telem/logging"; +import { + normalizeEventsDataset, + type PublicEventsDataset, +} from "../../../utils/events-datasets"; +import { RECOMMENDED_FIELDS } from "./config"; + +export const SEER_SEARCH_AGENT_POLLING_INTERVAL = 1000; // 1 second +export const SEER_SEARCH_AGENT_TIMEOUT = 60 * 1000; // 1 minute + +// Sentry's sentinel for all projects the user can access. +const ALL_ACCESSIBLE_PROJECTS = -1; + +// The search agent endpoints require this feature. `hideAiFeatures` is checked separately. +const SEARCH_AGENT_FEATURE = "gen-ai-search-agent-translate"; + +const SEER_STRATEGIES = { + errors: "Errors", + logs: "Logs", + spans: "Traces", + metrics: "Metrics", +} as const satisfies Partial>; + +export type SeerSearchDataset = keyof typeof SEER_STRATEGIES; + +export function isSeerSearchDataset( + dataset: string | undefined, +): dataset is SeerSearchDataset { + return dataset !== undefined && dataset in SEER_STRATEGIES; +} + +export interface SeerSearchTranslation { + query: string; + fields: string[]; + sort: string; + timeParams: { statsPeriod?: string; start?: string; end?: string }; + timeSeries: { yAxis: string; interval: string } | null; + crossEventQueries: CrossEventQueries; + // Other projects Seer suggested searching beyond the requested one. + suggestedProjectIds: number[]; + explanation: string; + // Correctness warnings must be shown even when explanations are disabled. + warning?: string; +} + +async function hasSeerSearchAgentAccess( + apiService: SentryApiService, + organizationSlug: string, +): Promise { + // Sentry omits `features` unless explicitly requested. + const organization = await apiService.getOrganization(organizationSlug, { + includeFeatureFlags: true, + detailed: false, + }); + if (organization.hideAiFeatures) { + return false; + } + const features = organization.features ?? []; + return features.includes(SEARCH_AGENT_FEATURE); +} + +function toSearchTranslation( + result: z.output, + dataset: SeerSearchDataset, + suggestedProjectIds: number[], +): SeerSearchTranslation { + const aggregates = result.visualization.flatMap((chart) => chart.y_axes); + const fields = + result.mode === "aggregates" + ? [...new Set([...result.group_by, ...aggregates])] + : []; + if (fields.length === 0) { + fields.push(...RECOMMENDED_FIELDS[normalizeEventsDataset(dataset)].basic); + } + + const defaultSort = + result.mode === "aggregates" && aggregates[0] + ? `-${aggregates[0]}` + : "-timestamp"; + let sort = result.sort.trim() || defaultSort; + // The handler adds the sort field to the selected fields, except for a + // non-aggregate sort in an aggregate query since that would change the + // grouping. Fall back to the default sort instead of letting Sentry reject it. + const sortField = sort.startsWith("-") ? sort.slice(1) : sort; + if ( + result.mode === "aggregates" && + !sortField.includes("(") && + !fields.includes(sortField) + ) { + sort = defaultSort; + } + + // Seer always returns a chart for aggregates since Explore shows one, but only + // sets an interval when the user asks for time buckets, e.g. "per day". The + // time series endpoint can't group, so grouped queries stay a table. + const chartWithInterval = result.visualization.find( + (chart) => chart.interval && chart.y_axes[0], + ); + const timeSeries = + result.mode === "aggregates" && + result.group_by.length === 0 && + chartWithInterval?.interval && + chartWithInterval.y_axes[0] + ? { + yAxis: chartWithInterval.y_axes[0], + interval: chartWithInterval.interval, + } + : null; + + let timeParams: SeerSearchTranslation["timeParams"]; + if (result.stats_period) { + timeParams = { statsPeriod: result.stats_period }; + } else if (result.start && result.end) { + timeParams = { start: result.start, end: result.end }; + } else { + timeParams = { statsPeriod: "14d" }; + } + + // Filters on other events in the same trace, only returned for spans. + const crossEventQueries: CrossEventQueries = { + spanQuery: result.span_query || undefined, + logQuery: result.log_query || undefined, + metricQuery: result.metric_query || undefined, + }; + const crossEventFilters = [ + ["spans", crossEventQueries.spanQuery], + ["logs", crossEventQueries.logQuery], + ["metrics", crossEventQueries.metricQuery], + ] + .filter(([, query]) => query) + .map(([type, query]) => `${type} \`${query}\``); + + let explanation = "Translated by Seer's search agent."; + let warning: string | undefined; + if (crossEventFilters.length > 0) { + 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.`; + } else { + explanation += ` Only includes results whose trace also has matching ${crossEventFilters.join(", ")}.`; + } + } + + return { + query: result.query, + fields, + sort, + timeParams, + timeSeries, + crossEventQueries, + suggestedProjectIds, + explanation, + warning, + }; +} + +/** + * Translates a natural language query with Seer's search agent. + * + * Returns null when Seer is unavailable for the organization, cannot translate + * the query, or does not finish in time, so the caller can fall back to the + * embedded agent or direct query syntax. + */ +export async function translateWithSeer({ + apiService, + organizationSlug, + projectId, + dataset, + query, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectId?: string; + dataset: SeerSearchDataset; + query: string; +}): Promise { + try { + if (!(await hasSeerSearchAgentAccess(apiService, organizationSlug))) { + return null; + } + + const run = await apiService.startSearchAgent({ + organizationSlug, + projectIds: [projectId ? Number(projectId) : ALL_ACCESSIBLE_PROJECTS], + naturalLanguageQuery: query, + strategy: SEER_STRATEGIES[dataset], + }); + + const deadline = Date.now() + SEER_SEARCH_AGENT_TIMEOUT; + while (Date.now() < deadline) { + const { session } = await apiService.getSearchAgentState({ + organizationSlug, + runId: run.sentry_run_id, + }); + + if (session?.status === "completed") { + const result = session.final_response?.responses[0]; + if (!result) { + 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 + ? (session.final_response?.project_ids ?? []).filter( + (id) => id !== Number(projectId), + ) + : []; + return toSearchTranslation(result, dataset, suggestedProjectIds); + } + if (session?.status === "error") { + return null; + } + + await new Promise((resolve) => + setTimeout(resolve, SEER_SEARCH_AGENT_POLLING_INTERVAL), + ); + } + + logWarn("Seer search agent timed out", { + extra: { organizationSlug, runId: run.sentry_run_id }, + }); + return null; + } catch (error) { + // An invalid token should surface to the user rather than fall back. + if (error instanceof ApiAuthenticationError) { + throw error; + } + logWarn(error, { extra: { organizationSlug } }); + return null; + } +}