From e5891616234a080b4bde75ed3f66747dcd45ecb9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 06:35:47 +0000 Subject: [PATCH] fix(trace): scope trace/span list queries by numeric project ID `sentry traces` (and project-mode `span list`) scoped slug targets only via `project:` search syntax on the Events API. That filter only matches actively selected projects and otherwise returns 400 Bad Request ("Project(s) X do not exist or are not actively selected"). Resolve the slug to a numeric project ID and pass `project=` instead, mirroring the logs fix in #1318 / #1317 and the issues fix in #312. Co-authored-by: Aditya Mathur --- packages/cli/src/commands/span/list.ts | 9 ++- packages/cli/src/commands/trace/list.ts | 10 ++- packages/cli/src/lib/api/traces.ts | 71 ++++++++++++++----- packages/cli/src/lib/resolve-target.ts | 4 +- packages/cli/test/commands/span/list.test.ts | 2 + packages/cli/test/commands/trace/list.test.ts | 2 + packages/cli/test/lib/api/traces.test.ts | 18 +++++ 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 838fc875d8..92fe53ec23 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -40,7 +40,10 @@ import { TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; +import { + resolveLogProjectId, + resolveOrgProjectFromArg, +} from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; import { appendPeriodHint, @@ -452,6 +455,9 @@ async function handleProjectMode( cwd, COMMAND_NAME ); + // Resolve slug → numeric ID so the Events query scopes via the `project` + // param. `project:` only matches actively-selected projects (#1317). + const projectId = await resolveLogProjectId(org, project); const apiQuery = flags.query ? translateSpanQuery(flags.query) : undefined; const contextKey = buildPaginationContextKey( @@ -473,6 +479,7 @@ async function handleProjectMode( sort: flags.sort, limit: flags.limit, cursor, + projectId, ...timeRangeToApiParams(timeRange), extraFields: extraApiFields, }).catch((error: unknown): never => { diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 061f56643b..3650366ae3 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -28,7 +28,10 @@ import { TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; +import { + resolveLogProjectId, + resolveOrgProjectFromArg, +} from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; import { appendPeriodHint, @@ -266,6 +269,10 @@ export const listCommand = buildListCommand("trace", { cwd, COMMAND_NAME ); + // Resolve slug → numeric ID so the Events query scopes via the `project` + // param. `project:` only matches actively-selected projects and can + // otherwise 400 with "not actively selected" (#1317). + const projectId = await resolveLogProjectId(org, project); // Build context key and resolve cursor for pagination const contextKey = buildPaginationContextKey("trace", `${org}/${project}`, { sort: flags.sort, @@ -289,6 +296,7 @@ export const listCommand = buildListCommand("trace", { limit: flags.limit, sort: flags.sort, cursor, + projectId, ...timeRangeToApiParams(timeRange), }).catch((error: unknown): never => { // An unparseable user --query is a user input mistake, not a CLI bug. diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 761b470b2e..41313eea54 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -332,6 +332,24 @@ const TRANSACTION_FIELDS = [ "project", ]; +/** + * Resolve the numeric project ID to send via the `project` query param. + * + * Prefers an explicit `projectId` from the caller; otherwise falls back to the + * slug when it is itself all-digits (a numeric project ID passed as the slug). + * Returns `undefined` when neither yields a numeric ID, signalling that the + * caller should scope via `project:` search syntax instead. + */ +function resolveNumericProjectId( + projectSlug: string, + projectId: number | undefined +): number | undefined { + if (projectId !== undefined) { + return projectId; + } + return isAllDigits(projectSlug) ? Number(projectSlug) : undefined; +} + type ListTransactionsOptions = { /** Search query using Sentry query syntax */ query?: string; @@ -347,6 +365,12 @@ type ListTransactionsOptions = { start?: string; /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ end?: string; + /** + * Numeric project ID. When provided, uses the `project` query param instead + * of `project:` search syntax, avoiding "not actively selected" errors + * (same class of bug as #1317 / #312). + */ + projectId?: number; }; /** @@ -363,8 +387,14 @@ async function fetchTransactionsPage( options: ListTransactionsOptions, perPage: number ): Promise> { - const isNumericProject = isAllDigits(projectSlug); - const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + // Prefer the numeric `project=` param — `project:` in the search query + // only matches projects that are actively selected (#1317). + const numericProjectId = resolveNumericProjectId( + projectSlug, + options.projectId + ); + const projectFilter = + numericProjectId === undefined ? `project:${projectSlug}` : ""; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); const { data: response, headers } = @@ -375,7 +405,10 @@ async function fetchTransactionsPage( params: { dataset: "transactions", field: TRANSACTION_FIELDS, - project: isNumericProject ? projectSlug : undefined, + project: + numericProjectId === undefined + ? undefined + : String(numericProjectId), // Convert empty string to undefined so ky omits the param entirely; // sending `query=` causes the Sentry API to behave differently than // omitting the parameter. @@ -406,8 +439,8 @@ async function fetchTransactionsPage( * Uses the Explore/Events API with dataset=transactions. * * Handles project slug vs numeric ID automatically: - * - Numeric IDs are passed as the `project` parameter - * - Slugs are added to the query string as `project:{slug}` + * - Numeric IDs (or `options.projectId`) are passed as the `project` parameter + * - Slugs fall back to `project:{slug}` in the query string when no ID is known * * When `limit` exceeds {@link API_MAX_PER_PAGE}, transparently fetches multiple * pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}). @@ -477,6 +510,12 @@ type ListSpansOptions = { end?: string; /** When true, search across all projects (sends project=-1). Used for trace mode. */ allProjects?: boolean; + /** + * Numeric project ID. When provided (and not `allProjects`), uses the + * `project` query param instead of `project:` search syntax, avoiding + * "not actively selected" errors (same class of bug as #1317 / #312). + */ + projectId?: number; }; /** @@ -493,15 +532,15 @@ async function fetchSpansPage( options: ListSpansOptions, perPage: number ): Promise> { - const isNumericProject = isAllDigits(projectSlug); - let projectFilter: string; - if (options.allProjects) { - projectFilter = ""; - } else if (isNumericProject) { - projectFilter = ""; - } else { - projectFilter = `project:${projectSlug}`; - } + // Prefer the numeric `project=` param — `project:` in the search query + // only matches projects that are actively selected (#1317). + const numericProjectId = options.allProjects + ? undefined + : resolveNumericProjectId(projectSlug, options.projectId); + const projectFilter = + options.allProjects || numericProjectId !== undefined + ? "" + : `project:${projectSlug}`; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); const fields = options.extraFields?.length @@ -511,8 +550,8 @@ async function fetchSpansPage( let projectParam: string | undefined; if (options.allProjects) { projectParam = "-1"; - } else if (isNumericProject) { - projectParam = projectSlug; + } else if (numericProjectId !== undefined) { + projectParam = String(numericProjectId); } const { data: response, headers } = await apiRequestToRegion( diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 0372c78e7b..2ec8549fae 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -952,9 +952,9 @@ export async function fetchProjectId( } /** - * Resolve a project slug to its numeric ID for log queries, tolerating failures. + * Resolve a project slug to its numeric ID for Events API queries, tolerating failures. * - * Log listing and lookup scope by the `project` query param instead of the + * Log/trace/span listing scopes by the `project` query param instead of the * `project:` search filter, which only matches projects that are actively * selected in the org (see #1317). This helper resolves the slug so callers can * pass a numeric ID. diff --git a/packages/cli/test/commands/span/list.test.ts b/packages/cli/test/commands/span/list.test.ts index 52645858bc..d8c112385b 100644 --- a/packages/cli/test/commands/span/list.test.ts +++ b/packages/cli/test/commands/span/list.test.ts @@ -538,6 +538,7 @@ describe("listCommand.func (project mode)", () => { return { org: "test-org", project: "test-project" }; } ); + vi.spyOn(resolveTarget, "resolveLogProjectId").mockResolvedValue(4242); resolveCursorSpy.mockReturnValue({ cursor: undefined, direction: "next" as const, @@ -820,6 +821,7 @@ describe("listCommand.func (project mode)", () => { const callArgs = listSpansSpy.mock.calls[0]; const options = callArgs[2]; expect(options.allProjects).toBeUndefined(); + expect(options.projectId).toBe(4242); }); test("hint shows -c next with project target when more pages available", async () => { diff --git a/packages/cli/test/commands/trace/list.test.ts b/packages/cli/test/commands/trace/list.test.ts index 4a1d09561a..89d58f08fd 100644 --- a/packages/cli/test/commands/trace/list.test.ts +++ b/packages/cli/test/commands/trace/list.test.ts @@ -303,6 +303,7 @@ describe("listCommand.func", () => { listTransactionsSpy = vi.spyOn(apiClient, "listTransactions"); findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); resolveOrgAndProjectSpy = vi.spyOn(resolveTarget, "resolveOrgAndProject"); + vi.spyOn(resolveTarget, "resolveLogProjectId").mockResolvedValue(4242); resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ cursor: undefined, direction: "next" as const, @@ -473,6 +474,7 @@ describe("listCommand.func", () => { limit: 50, sort: "duration", cursor: undefined, + projectId: 4242, statsPeriod: "7d", } ); diff --git a/packages/cli/test/lib/api/traces.test.ts b/packages/cli/test/lib/api/traces.test.ts index 36c28acfb2..5abcad0651 100644 --- a/packages/cli/test/lib/api/traces.test.ts +++ b/packages/cli/test/lib/api/traces.test.ts @@ -278,6 +278,15 @@ describe("listTransactions", () => { expect(capturedUrl).not.toMatch(/[?&]project=my-project/); }); + test("scopes via the project param when projectId is provided (#1317)", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { projectId: 4242 }); + + expect(capturedUrl).toContain("project=4242"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:my-project"); + }); + test("numeric project ID goes as project param", async () => { mockOk({ data: [], meta: TX_META }); @@ -470,6 +479,15 @@ describe("listSpans", () => { ); }); + test("scopes via the project param when projectId is provided (#1317)", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { projectId: 4242 }); + + expect(capturedUrl).toContain("project=4242"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:my-project"); + }); + test("auto-paginates when limit > 100", async () => { const { getCapturedUrls } = mockSequential([ {