From aacefbf435f43347b7637f9686326eab81e08dc5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:33:54 +0000 Subject: [PATCH 1/2] fix(cli): Scope explore to the project by ID, not project: `sentry explore /` scoped requests with a project: query term and no project param. The Events API validates that term against the requester's member projects, so a valid project the user is not a member of failed with "Project(s) do not exist or are not actively selected" (CLI-10). Resolve the numeric ID like trace/span/log list (#1317) and send it as project= for tables, time series, and --metric discovery. Fall back to project: only when the ID cannot be resolved. Replays keep using projectSlugs. Co-Authored-By: Miguel Betegon --- .../src/fragments/commands/explore.md | 2 +- packages/cli/src/commands/explore.ts | 33 +++++-- packages/cli/src/lib/api/explore.ts | 20 ++++- packages/cli/test/commands/explore.test.ts | 88 ++++++++++++++++++- packages/cli/test/lib/api/explore.test.ts | 9 ++ 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index bd65903c2..565bbaf2c 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -73,7 +73,7 @@ by one dataset may be unsupported by another. | Target | Behavior | |--------|----------| -| `/` | Auto-adds `project:` to query | +| `/` | Scopes the query to that project | | `/` | All projects in org (no project filter) | | `` | Searches for project across all accessible orgs | | (omitted) | Auto-detect from DSN/config | diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index b01b3798a..90b2521c5 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -74,6 +74,7 @@ import { listSupportedReplayFields, parseReplayEnvironmentFilter, } from "../lib/replay-search.js"; +import { resolveLogProjectId } from "../lib/resolve-target.js"; import { sanitizeQuery } from "../lib/search-query.js"; import { shellQuote } from "../lib/utils.js"; import { @@ -485,17 +486,24 @@ function buildEnvironmentQuery( return `environment:[${environment.join(",")}]`; } -/** Resolve the shared dataset and filters for tabular and temporal queries. */ +/** + * Resolve the shared dataset and filters for tabular and temporal queries. + * + * A resolved `projectId` scopes the request through the `project` param; + * `project:` is only the fallback because it matches just the projects + * the requester is a member of (#1317). + */ function resolveEventsQuery( dataset: string, query: string | undefined, project: string | undefined, environment: string[] | undefined, + projectId: number | undefined, ) { const userQuery = sanitizeQuery(query, { allowBoolean: true }); const legacyTransactions = LEGACY_TRANSACTION_DATASETS.has(dataset); const scope = [ - project ? `project:${project}` : undefined, + project && projectId === undefined ? `project:${project}` : undefined, buildEnvironmentQuery(environment), legacyTransactions ? IS_TRANSACTION_FILTER : undefined, ] @@ -550,8 +558,8 @@ function validateTimeSeriesFlags(flags: ExploreFlags): void { * sort, and returns a fetch function that calls `listReplays`. For all other * datasets it translates `--environment` values into `environment:...` query * filter terms, resolves the sort with {@link resolveEventsSort} (which also - * selects the sort column), prepends `project:` to the query, and - * returns a `queryEvents` fetch. + * selects the sort column), scopes the project by ID (falling back to a + * `project:` query term), and returns a `queryEvents` fetch. */ function resolveDatasetConfig(params: { dataset: string; @@ -559,9 +567,11 @@ function resolveDatasetConfig(params: { flags: ExploreFlags; org: string; project: string | undefined; + projectId: number | undefined; environment: string[] | undefined; }): DatasetConfig { - const { dataset, fieldList, flags, org, project, environment } = params; + const { dataset, fieldList, flags, org, project, projectId, environment } = + params; if (dataset === "replays") { const userQuery = sanitizeQuery(flags.query); const unsupportedField = fieldList.find( @@ -611,6 +621,7 @@ function resolveDatasetConfig(params: { flags.query, project, environment, + projectId, ); // Every Events dataset honors sort as long as the sort column is selected. @@ -630,6 +641,7 @@ function resolveDatasetConfig(params: { fields, dataset: resolved.dataset, query, + projectId, sort, limit, cursor, @@ -685,7 +697,7 @@ export const exploreCommand = buildListCommand("explore", { " replays Session replay search\n\n" + "Events queries support AND, OR, and parentheses. Project, environment, " + "and transaction scope apply to the entire query.\n\n" + - "A / target adds project: to the query. " + + "A / target scopes the query to that project. " + targetPatternExplanation(), examples: [ { @@ -912,6 +924,11 @@ export const exploreCommand = buildListCommand("explore", { ? parsePeriod(flags.period) : flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); + // Events queries scope by numeric ID; replays take the slug directly. + const projectId = + project && (flags.metric || dataset !== "replays") + ? await resolveLogProjectId(org, project) + : undefined; // --metric auto mode: resolve metric name → tracemetrics aggregate if (flags.metric) { @@ -931,6 +948,7 @@ export const exploreCommand = buildListCommand("explore", { queryMetricsMeta(org, { ...metaParams, project, + projectId, }), ); @@ -961,6 +979,7 @@ export const exploreCommand = buildListCommand("explore", { flags.query, project, environment, + projectId, ); const timeParams = timeRangeToApiParams(timeRange); const interval = resolveTimeSeriesInterval( @@ -977,6 +996,7 @@ export const exploreCommand = buildListCommand("explore", { fields: fieldList, dataset: resolved.dataset, query: resolved.query, + projectId, interval, ...timeParams, }).catch((error: unknown): never => { @@ -1007,6 +1027,7 @@ export const exploreCommand = buildListCommand("explore", { flags, org, project, + projectId, environment, }); diff --git a/packages/cli/src/lib/api/explore.ts b/packages/cli/src/lib/api/explore.ts index b660ad036..cb6527a61 100644 --- a/packages/cli/src/lib/api/explore.ts +++ b/packages/cli/src/lib/api/explore.ts @@ -28,6 +28,12 @@ export type ExploreQueryOptions = { dataset?: string; /** Sentry search query filter */ query?: string; + /** + * Numeric project ID, sent as the `project` param. `project:` in + * `query` only matches projects the requester is a member of, so a valid + * slug can 400 with "not actively selected" (#1317). + */ + projectId?: number; /** * Sort field. Prefix with `-` for descending. * Every Events dataset accepts it as long as the sort column is in @@ -48,10 +54,10 @@ export type ExploreQueryOptions = { end?: string; }; -/** Options for an ungrouped time series, with project and environment filters in `query`. */ +/** Options for an ungrouped time series, with environment filters in `query`. */ export type ExploreTimeSeriesQueryOptions = Pick< ExploreQueryOptions, - "fields" | "dataset" | "query" | "statsPeriod" | "start" | "end" + "fields" | "dataset" | "query" | "projectId" | "statsPeriod" | "start" | "end" > & { /** Bucket width as a duration, such as `5m`. Must be valid for the requested range. */ interval: string; @@ -98,6 +104,7 @@ export async function queryEventsTimeSeries( dataset: options.dataset ?? "errors", yAxis: options.fields, query: options.query || undefined, + project: options.projectId, interval: options.interval, statsPeriod: options.start || options.end @@ -137,6 +144,7 @@ async function fetchEventsPage( dataset: options.dataset ?? "errors", field: options.fields, query: options.query || undefined, + project: options.projectId, sort: options.sort || undefined, per_page: perPage, statsPeriod: @@ -178,15 +186,21 @@ export async function queryMetricsMeta( start?: string; end?: string; project?: string; + /** Numeric ID for `project`; when set, replaces the `project:` filter. */ + projectId?: number; }, ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); - const query = options?.project ? `project:${options.project}` : undefined; + const query = + options?.project && options.projectId === undefined + ? `project:${options.project}` + : undefined; const baseOptions: ExploreQueryOptions = { fields: ["metric.name", "metric.type", "metric.unit"], dataset: "tracemetrics", query, + projectId: options?.projectId, statsPeriod: options?.start || options?.end ? undefined diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index a742a83f1..239422a87 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -175,6 +175,7 @@ let queryEventsTimeSeriesSpy: ReturnType; let queryMetricsMetaSpy: ReturnType; let listReplaysSpy: ReturnType; let resolveTargetSpy: ReturnType; +let resolveProjectIdSpy: ReturnType; let resolveCursorSpy: ReturnType; let advancePaginationStateSpy: ReturnType; let hasPreviousPageSpy: ReturnType; @@ -205,6 +206,9 @@ beforeEach(async () => { // Default: resolveOrgOptionalTarget returns org-only (auto-detect) resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalTarget"); resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + // Default: the project ID can't be resolved, so queries fall back to slugs. + resolveProjectIdSpy = vi.spyOn(resolveTarget, "resolveLogProjectId"); + resolveProjectIdSpy.mockResolvedValue(undefined); resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ cursor: undefined, @@ -224,6 +228,7 @@ afterEach(() => { queryMetricsMetaSpy.mockRestore(); listReplaysSpy.mockRestore(); resolveTargetSpy.mockRestore(); + resolveProjectIdSpy.mockRestore(); resolveCursorSpy.mockRestore(); advancePaginationStateSpy.mockRestore(); hasPreviousPageSpy.mockRestore(); @@ -297,7 +302,7 @@ describe("sentry explore", () => { ); }); - test("`/` adds project: to query automatically", async () => { + test("`/` falls back to project: without a project ID", async () => { resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); const { context } = createContext(); @@ -377,6 +382,87 @@ describe("sentry explore", () => { }); }); + describe("project scoping", () => { + test("scopes table queries by project ID instead of project:", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); + resolveProjectIdSpy.mockResolvedValue(42); + const { context } = createContext(); + + await func.call( + context, + { ...DEFAULT_FLAGS, query: "level:error", environment: ["prod"] }, + "my-org/cli", + ); + + expect(resolveProjectIdSpy).toHaveBeenCalledWith("my-org", "cli"); + expect(queryEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ + projectId: 42, + query: "environment:prod (level:error)", + }), + ); + }); + + test("scopes time series by project ID", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); + resolveProjectIdSpy.mockResolvedValue(42); + const { context } = createContext(); + + await func.call(context, TIME_SERIES_FLAGS, "my-org/cli"); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ projectId: 42, query: undefined }), + ); + }); + + test("passes the project ID to --metric discovery", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); + resolveProjectIdSpy.mockResolvedValue(42); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "tracemetrics", + metric: "llm.token_usage", + }, + "my-org/cli", + ); + + expect(queryMetricsMetaSpy).toHaveBeenCalledWith("my-org", { + statsPeriod: "24h", + project: "cli", + projectId: 42, + }); + expect(queryEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ projectId: 42, query: undefined }), + ); + }); + + test("does not resolve a project ID for org-wide or replay queries", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org" }); + const { context } = createContext(); + await func.call(context, DEFAULT_FLAGS, "my-org/"); + + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); + await func.call( + context, + { ...DEFAULT_FLAGS, dataset: "replays" }, + "my-org/cli", + ); + + expect(resolveProjectIdSpy).not.toHaveBeenCalled(); + expect(queryEventsSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ projectId: undefined }), + ); + }); + }); + describe("API call parameters", () => { test.each(["errors", "spans", "logs"])( "accepts boolean queries through the %s command parser", diff --git a/packages/cli/test/lib/api/explore.test.ts b/packages/cli/test/lib/api/explore.test.ts index e18260833..837a014cb 100644 --- a/packages/cli/test/lib/api/explore.test.ts +++ b/packages/cli/test/lib/api/explore.test.ts @@ -108,6 +108,15 @@ describe("queryEvents", () => { ); }); + test("sends projectId as the project param, omitting it when unset", async () => { + mockOk({ data: [], meta: { fields: {} } }); + await queryEvents("my-org", { fields: ["title"], projectId: 42 }); + expect(new URL(capturedUrl).searchParams.get("project")).toBe("42"); + + await queryEvents("my-org", { fields: ["title"] }); + expect(new URL(capturedUrl).searchParams.has("project")).toBe(false); + }); + test("omits sort when not provided", async () => { mockOk({ data: [], meta: { fields: {} } }); From 70268862e70230de2d0a230a601c4af235aa2921 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:48:33 +0000 Subject: [PATCH 2/2] test(cli): Expect explore e2e to scope by project ID --- packages/cli/test/e2e/explore.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/e2e/explore.test.ts b/packages/cli/test/e2e/explore.test.ts index b0bd055fc..8eea09bad 100644 --- a/packages/cli/test/e2e/explore.test.ts +++ b/packages/cli/test/e2e/explore.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "vitest"; import { createE2EContext } from "../fixture.js"; import organizationFixture from "../fixtures/organization.json"; +import projectFixture from "../fixtures/project.json"; import { useTestConfigDir } from "../helpers.js"; import { TEST_ORG, TEST_PROJECT, TEST_TOKEN } from "../mocks/routes.js"; import { createMockServer } from "../mocks/server.js"; @@ -57,6 +58,11 @@ test("combines -b buckets with -t period and preserves metadata when projecting path: `/api/0/organizations/${TEST_ORG}/`, response: organizationFixture, }, + { + method: "GET", + path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/`, + response: projectFixture, + }, { method: "GET", path: `/api/0/organizations/${TEST_ORG}/events-timeseries/`, @@ -101,7 +107,9 @@ test("combines -b buckets with -t period and preserves metadata when projecting dataset: "spans", yAxis: "latest_event()", interval: "2m", - query: `project:${TEST_PROJECT} environment:[production,canary] (span.op:http.server OR span.status:internal_error)`, + project: projectFixture.id, + query: + "environment:[production,canary] (span.op:http.server OR span.status:internal_error)", start: new Date(start).toISOString(), end: new Date(end).toISOString(), });