diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 2f47439ea..e9ba50443 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -24,6 +24,7 @@ import { terminalPixelWidth, } from "../lib/sixel.js"; import { imageBytesToSixel } from "../lib/sixel-image.js"; +import { setOrgProjectContext } from "../lib/telemetry.js"; const log = logger.withTag("api"); @@ -227,6 +228,70 @@ function resolveApiTarget(endpoint: string): { }; } +/** + * Org/project slugs or numeric IDs in API paths. Rejects schema placeholders + * (`{organization_id_or_slug}`) and empty/dot segments so we never tag junk. + */ +const API_PATH_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function isApiPathSlug(segment: string | undefined): segment is string { + return segment !== undefined && API_PATH_SLUG_RE.test(segment); +} + +/** + * Parse org (and project when present) from a Sentry API endpoint path. + * + * `sentry api` never goes through target resolve, so the slug lives only in + * the URL. `organizations/`, `projects/`, and `teams/` prefixes carry the + * org; `projects/` and `organizations/{org}/projects/{project}/` also carry + * a project when that segment is a real slug. + * + * @param endpoint - Path relative to `/api/0/`, optionally with a query string + * or a leading `/api/0/` prefix + * @returns Org (and project when present), or `undefined` when the path is + * unscoped, a list endpoint, or a schema placeholder + * @internal Exported for testing + */ +export function parseOrgProjectFromApiPath( + endpoint: string +): { org: string; project?: string } | undefined { + const path = endpoint.split("?", 1)[0] ?? ""; + const segments = path.split("/").filter((segment) => segment.length > 0); + + if (segments[0] === "api" && segments[1] === "0") { + segments.splice(0, 2); + } + + if (segments[0] === "organizations" && isApiPathSlug(segments[1])) { + const org = segments[1]; + if (segments[2] === "projects" && isApiPathSlug(segments[3])) { + return { org, project: segments[3] }; + } + return { org }; + } + + if (segments[0] === "projects" && isApiPathSlug(segments[1])) { + const org = segments[1]; + if (isApiPathSlug(segments[2])) { + return { org, project: segments[2] }; + } + return { org }; + } + + if (segments[0] === "teams" && isApiPathSlug(segments[1])) { + return { org: segments[1] }; + } + + return; +} + +function tagOrgFromApiPath(endpoint: string): void { + const parsed = parseOrgProjectFromApiPath(endpoint); + if (parsed) { + setOrgProjectContext([parsed.org], parsed.project ? [parsed.project] : []); + } +} + /** * Parse a field value, attempting JSON parse first. * @@ -1560,6 +1625,7 @@ export const apiCommand = buildCommand({ const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } = resolveApiTarget(endpoint); + tagOrgFromApiPath(normalizedEndpoint); if (strippedApiPrefix) { // Silent auto-fix — not a warning. Users commonly copy/paste URLs // that include the /api/0/ prefix; we strip it transparently and diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index aa80c5c9a..5c451e101 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -70,6 +70,7 @@ import { } from "../../lib/sentry-url-parser.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; +import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; import { EventViewOutputSchema, type SentryEvent } from "../../types/index.js"; @@ -571,6 +572,8 @@ type ResolveTargetOptions = { * * Handles all target types (explicit, search, org-all, auto-detect) * including cross-project fallback via the eventids endpoint. + * Tags `sentry.org` / `sentry.project` on success because several of + * those paths skip `resolve-target`'s telemetry helper. * * @internal Exported for testing */ @@ -579,15 +582,17 @@ export async function resolveEventTarget( ): Promise { const { parsed, eventId, cwd } = options; + let target: ResolvedEventTarget | null; switch (parsed.type) { case ProjectSpecificationType.Explicit: { const org = await resolveEffectiveOrg(parsed.org); - return { + target = { org, project: parsed.project, orgDisplay: parsed.org, projectDisplay: parsed.project, }; + break; } case ProjectSpecificationType.ProjectSearch: { @@ -597,25 +602,36 @@ export async function resolveEventTarget( `sentry event view /${parsed.projectSlug} ${eventId}`, parsed.originalSlug ); - return { + target = { org: resolved.org, project: resolved.project, orgDisplay: resolved.org, projectDisplay: resolved.project, }; + break; } case ProjectSpecificationType.OrgAll: { const org = await resolveEffectiveOrg(parsed.org); - return resolveOrgAllTarget(org, eventId, cwd); + target = await resolveOrgAllTarget(org, eventId, cwd); + break; } case ProjectSpecificationType.AutoDetect: - return resolveAutoDetectTarget(eventId, cwd); + target = await resolveAutoDetectTarget(eventId, cwd); + break; + + default: { + const _exhaustiveCheck: never = parsed; + target = null; + break; + } + } - default: - return null; + if (target) { + setOrgProjectContext([target.org], [target.project]); } + return target; } /** diff --git a/packages/cli/test/commands/api.test.ts b/packages/cli/test/commands/api.test.ts index cba975154..bba18c3b6 100644 --- a/packages/cli/test/commands/api.test.ts +++ b/packages/cli/test/commands/api.test.ts @@ -7,7 +7,9 @@ import { writeFile } from "node:fs/promises"; import { Readable } from "node:stream"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { apiCommand, buildBodyFromFields, @@ -27,6 +29,7 @@ import { parseFieldKey, parseFields, parseHeaders, + parseOrgProjectFromApiPath, prepareRequestOptions, readStdin, resolveApiResponseOutput, @@ -108,6 +111,55 @@ describe("normalizeEndpoint: api/0/ prefix stripping (CLI-K1)", () => { }); }); +describe("parseOrgProjectFromApiPath", () => { + test.each([ + ["organizations/acme/issues/", { org: "acme" }], + ["projects/acme/web/events/", { org: "acme", project: "web" }], + ["teams/acme/engineering/", { org: "acme" }], + ["organizations/", undefined], + ["organizations/{organization_id_or_slug}/issues/", undefined], + ["issues/", undefined], + ] as const)("%s", (path, expected) => { + expect(parseOrgProjectFromApiPath(path)).toEqual(expected); + }); +}); + +describe("apiCommand tags org from the path", () => { + let setTagSpy: ReturnType; + + beforeEach(() => { + setTagSpy = vi.spyOn(Sentry, "setTag"); + }); + + afterEach(() => { + setTagSpy.mockRestore(); + }); + + test("dry-run of a projects path sets sentry.org and sentry.project", async () => { + const func = await apiCommand.loader(); + const context = { + stdin: createMockStdin(""), + stdout: { write: vi.fn(() => true), isTTY: false }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }; + await func.call( + context, + { + method: "GET", + silent: false, + verbose: false, + "dry-run": true, + json: false, + }, + "projects/acme/web/events/" + ); + + expect(setTagSpy).toHaveBeenCalledWith("sentry.org", "acme"); + expect(setTagSpy).toHaveBeenCalledWith("sentry.project", "web"); + }); +}); + describe("normalizeEndpoint: path traversal hardening (#350)", () => { test("rejects bare .. traversal", () => { expect(() => normalizeEndpoint("..")).toThrow(/path traversal/); diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index df7e9ef28..36cbbf222 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -5,6 +5,8 @@ * and viewCommand func() body in src/commands/event/view.ts */ +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { collectEventIds, @@ -729,12 +731,14 @@ describe("resolveEventTarget", () => { let findEventAcrossOrgsSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; let resolveProjectBySlugSpy: ReturnType; + let setTagSpy: ReturnType; beforeEach(async () => { resolveEventInOrgSpy = vi.spyOn(apiClient, "resolveEventInOrg"); findEventAcrossOrgsSpy = vi.spyOn(apiClient, "findEventAcrossOrgs"); resolveOrgAndProjectSpy = vi.spyOn(resolveTarget, "resolveOrgAndProject"); resolveProjectBySlugSpy = vi.spyOn(resolveTarget, "resolveProjectBySlug"); + setTagSpy = vi.spyOn(Sentry, "setTag"); setOrgRegion("acme", DEFAULT_SENTRY_URL); }); @@ -743,6 +747,7 @@ describe("resolveEventTarget", () => { findEventAcrossOrgsSpy.mockRestore(); resolveOrgAndProjectSpy.mockRestore(); resolveProjectBySlugSpy.mockRestore(); + setTagSpy.mockRestore(); }); test("returns explicit target directly", async () => { @@ -803,6 +808,8 @@ describe("resolveEventTarget", () => { expect(result?.org).toBe("acme"); expect(result?.project).toBe("backend"); expect(result?.prefetchedEvent).toBeDefined(); + expect(setTagSpy).toHaveBeenCalledWith("sentry.org", "acme"); + expect(setTagSpy).toHaveBeenCalledWith("sentry.project", "backend"); }); test("delegates AutoDetect to resolveAutoDetectTarget", async () => {