Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/fragments/commands/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ by one dataset may be unsupported by another.

| Target | Behavior |
|--------|----------|
| `<org>/<project>` | Auto-adds `project:<slug>` to query |
| `<org>/<project>` | Scopes the query to that project |
| `<org>/` | All projects in org (no project filter) |
| `<project>` | Searches for project across all accessible orgs |
| (omitted) | Auto-detect from DSN/config |
33 changes: 27 additions & 6 deletions packages/cli/src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:<slug>` 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,
]
Expand Down Expand Up @@ -550,18 +558,20 @@ 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:<slug>` to the query, and
* returns a `queryEvents` fetch.
* selects the sort column), scopes the project by ID (falling back to a
* `project:<slug>` query term), and returns a `queryEvents` fetch.
*/
function resolveDatasetConfig(params: {
dataset: string;
fieldList: string[];
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(
Expand Down Expand Up @@ -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.
Expand All @@ -630,6 +641,7 @@ function resolveDatasetConfig(params: {
fields,
dataset: resolved.dataset,
query,
projectId,
sort,
limit,
cursor,
Expand Down Expand Up @@ -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 <org>/<project> target adds project:<slug> to the query. " +
"A <org>/<project> target scopes the query to that project. " +
targetPatternExplanation(),
examples: [
{
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because why not? 🤣

const projectId =
project && (flags.metric || dataset !== "replays")
? await resolveLogProjectId(org, project)
: undefined;

// --metric auto mode: resolve metric name → tracemetrics aggregate
if (flags.metric) {
Expand All @@ -931,6 +948,7 @@ export const exploreCommand = buildListCommand("explore", {
queryMetricsMeta(org, {
...metaParams,
project,
projectId,
}),
);

Expand Down Expand Up @@ -961,6 +979,7 @@ export const exploreCommand = buildListCommand("explore", {
flags.query,
project,
environment,
projectId,
);
const timeParams = timeRangeToApiParams(timeRange);
const interval = resolveTimeSeriesInterval(
Expand All @@ -977,6 +996,7 @@ export const exploreCommand = buildListCommand("explore", {
fields: fieldList,
dataset: resolved.dataset,
query: resolved.query,
projectId,
interval,
...timeParams,
}).catch((error: unknown): never => {
Expand Down Expand Up @@ -1007,6 +1027,7 @@ export const exploreCommand = buildListCommand("explore", {
flags,
org,
project,
projectId,
environment,
});

Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/lib/api/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export type ExploreQueryOptions = {
dataset?: string;
/** Sentry search query filter */
query?: string;
/**
* Numeric project ID, sent as the `project` param. `project:<slug>` 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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -178,15 +186,21 @@ export async function queryMetricsMeta(
start?: string;
end?: string;
project?: string;
/** Numeric ID for `project`; when set, replaces the `project:<slug>` filter. */
projectId?: number;
},
): Promise<MetricMeta[]> {
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
Expand Down
88 changes: 87 additions & 1 deletion packages/cli/test/commands/explore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ let queryEventsTimeSeriesSpy: ReturnType<typeof spyOn>;
let queryMetricsMetaSpy: ReturnType<typeof spyOn>;
let listReplaysSpy: ReturnType<typeof spyOn>;
let resolveTargetSpy: ReturnType<typeof spyOn>;
let resolveProjectIdSpy: ReturnType<typeof spyOn>;
let resolveCursorSpy: ReturnType<typeof spyOn>;
let advancePaginationStateSpy: ReturnType<typeof spyOn>;
let hasPreviousPageSpy: ReturnType<typeof spyOn>;
Expand Down Expand Up @@ -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,
Expand All @@ -224,6 +228,7 @@ afterEach(() => {
queryMetricsMetaSpy.mockRestore();
listReplaysSpy.mockRestore();
resolveTargetSpy.mockRestore();
resolveProjectIdSpy.mockRestore();
resolveCursorSpy.mockRestore();
advancePaginationStateSpy.mockRestore();
hasPreviousPageSpy.mockRestore();
Expand Down Expand Up @@ -297,7 +302,7 @@ describe("sentry explore", () => {
);
});

test("`<org>/<project>` adds project:<slug> to query automatically", async () => {
test("`<org>/<project>` falls back to project:<slug> without a project ID", async () => {
resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" });
const { context } = createContext();

Expand Down Expand Up @@ -377,6 +382,87 @@ describe("sentry explore", () => {
});
});

describe("project scoping", () => {
test("scopes table queries by project ID instead of project:<slug>", 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",
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/test/e2e/explore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/`,
Expand Down Expand Up @@ -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(),
});
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/test/lib/api/explore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} } });

Expand Down
Loading