Skip to content

Commit ba7fc85

Browse files
authored
perf(api): pass ?collapse=organization on project detail fetches (#818)
## Summary Cuts ~400-500ms off `GET /api/0/projects/{org}/{project}/` by asking the server to skip full-org serialization. The collapsed response carries only `{id, slug}` for `organization` instead of the full payload (feature flags, options, etc). Colleague flagged this optimization noting the UI already benefits — every CLI hot path that fetches project details can ride the same win. ## Hot paths that benefit - DSN auto-detect (numeric `orgId/projectId` → slug resolution) - Explicit `<org>/<project>` target validation in `resolveTarget` - `findProjectsBySlug` (one `getProject` per accessible org, multiplying the saving) - `project view`, `project delete`, `dashboard create`, `release list`, `trace view`, `init`, and others All project detail fetches in the CLI funnel through `getProject()` in `src/lib/api/projects.ts`, so a single change covers everything. ## Handling the trimmed response The collapsed payload drops `organization.name`. Four call sites displayed it: - `src/lib/resolve-target.ts` — three DSN resolution paths + one cache-seed after explicit target validation - `src/lib/dsn/resolver.ts` — DSN → ResolvedProject - `src/lib/formatters/human.ts` — `project view`'s `Organization:` row All now use a new `resolveOrgDisplayName(orgSlug, explicitName?)` helper with the fallback chain: 1. Explicit `name` if the server returned it (self-hosted / older Sentry ignore `collapse` and return the full payload — handled transparently) 2. `getCachedOrganizations()` lookup — populated on login and every org-fanout operation 3. Slug itself as last resort The cosmetic "name → slug" degradation only surfaces on cold-cache + collapse-honoring server, which is vanishingly rare (every auth flow warms the cache). ## JSON output shape stays stable `sentry project view --json` previously returned the full `organization` object (including `name`, feature flags, `options`). Since `getProject()` now collapses the server response, `project view` gains a `jsonTransform` that re-hydrates `organization.name` via `resolveOrgDisplayName()` before serialization. Downstream agents and scripts that scrape `.organization.name` continue to see a value. When the server returns `name` (self-hosted / ignores `collapse`), the transform preserves it as-is. When absent, it falls back through the cached org list to the slug. Feature flags and `options` are no longer returned — this is a deliberate tradeoff, as those fields aren't documented stable CLI output and dropping them is where the perf win comes from. ## Response cache invalidation is now prefix-based The response cache keys by full URL (including sorted query params via `buildCacheKey`/`normalizeUrl`). Pre-PR, `invalidateCachedResponse(baseUrl)` would match the cached entry exactly. Post-PR, cached entries live under `baseUrl?collapse=organization`, so an exact-match invalidation would silently miss — `project delete` → `project view` would serve pre-deletion data. `invalidateProjectCaches` switches to `invalidateCachedResponsesMatching` (prefix sweep) to catch the collapsed variant plus any future query-param additions. The trailing slash on `buildApiUrl` output guarantees the prefix can't collide with sibling projects (`projects/acme/frontend/` does not prefix-match `projects/acme/frontend-old/`). Side effect: sub-resources like `/projects/acme/frontend/keys/` and `/projects/acme/frontend/trace-items/...` also get swept on `project delete`. This is desirable — those children would 404 after the parent is gone anyway. ## Tests Added to `test/lib/api-client.test.ts`: - `getProject sends ?collapse=organization query parameter` — asserts the outgoing URL carries the query param. - `getProject tolerates collapsed response missing organization.name` — confirms no blow-up when `name` is absent. - 4 `resolveOrgDisplayName` cases: explicit name wins / cache fallback / slug fallback / empty-string falsy handling. Added to `test/lib/formatters/human.details.test.ts`: - `falls back to org slug when collapsed response omits name` — module-level `useTestConfigDir` ensures an empty `org_regions` table, regex-match asserts the slug appears in **both** the display-name and parens positions (stray cached name would fail). Added to `test/commands/project/view.func.test.ts`: - `JSON output re-hydrates organization.name when API response omits it` — exercises the new `jsonTransform` slug-fallback path. - `JSON output preserves organization.name when API response includes it` — self-hosted / older-Sentry compat. - `JSON output still strips detectedFrom (human-only field)` — regression guard for the `jsonExclude` → `jsonTransform` switch. - `JSON output honours --fields filter` — confirms field filtering still works on top of the transform. ## Verification - `bun run typecheck` ✓ - `bun run lint` ✓ (only a pre-existing unrelated warning) - `bun run test:unit` → 5676 pass / 0 fail (+4 new from follow-up) - `bun run test:isolated` → 138 pass / 0 fail ## Follow-ups (out of scope) - Team detail (`retrieveATeam`) has the same `?collapse=organization` support per the SDK types — same perf win available for `team list` etc. - Similar-shape audit on other endpoints returning nested org payloads.
1 parent 2374112 commit ba7fc85

10 files changed

Lines changed: 408 additions & 21 deletions

File tree

src/commands/project/view.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
*/
77

88
import type { SentryContext } from "../../context.js";
9-
import { getProject, tryGetPrimaryDsn } from "../../lib/api-client.js";
9+
import {
10+
getProject,
11+
resolveOrgDisplayName,
12+
tryGetPrimaryDsn,
13+
} from "../../lib/api-client.js";
1014
import {
1115
ProjectSpecificationType,
1216
parseOrgProjectArg,
@@ -15,6 +19,7 @@ import { openInBrowser } from "../../lib/browser.js";
1519
import { buildCommand } from "../../lib/command.js";
1620
import { AuthError, ContextError, withAuthGuard } from "../../lib/errors.js";
1721
import { divider, formatProjectDetails } from "../../lib/formatters/index.js";
22+
import { filterFields } from "../../lib/formatters/json.js";
1823
import { CommandOutput } from "../../lib/formatters/output.js";
1924
import {
2025
applyFreshFlag,
@@ -179,6 +184,50 @@ async function fetchAllProjectDetails(
179184
return { projects, dsns, targets: validTargets };
180185
}
181186

187+
/**
188+
* Re-hydrate `organization.name` on a project entry.
189+
*
190+
* `getProject()` passes `?collapse=organization` so the server returns
191+
* only `{id, slug}` for `organization` (~400-500ms faster). For JSON
192+
* consumers that scrape `.organization.name`, we refill the field from
193+
* the cached organizations list (or the slug as last resort) so the
194+
* JSON output shape stays stable across CLI versions.
195+
*/
196+
function hydrateOrganizationName(entry: ProjectViewEntry): ProjectViewEntry {
197+
if (!entry.organization || entry.organization.name) {
198+
return entry;
199+
}
200+
return {
201+
...entry,
202+
organization: {
203+
...entry.organization,
204+
name: resolveOrgDisplayName(entry.organization.slug),
205+
},
206+
};
207+
}
208+
209+
/**
210+
* Build the JSON payload: strip `detectedFrom` (human-only), re-hydrate
211+
* `organization.name`, and apply `--fields` filtering.
212+
*
213+
* Replaces the simpler `jsonExclude: ["detectedFrom"]` config so we can
214+
* also restore `organization.name` that the collapsed API response omits.
215+
*/
216+
function jsonTransformProjectView(
217+
entries: ProjectViewEntry[],
218+
fields?: string[]
219+
): unknown {
220+
const hydrated = entries.map((entry) => {
221+
const { detectedFrom: _detectedFrom, ...rest } =
222+
hydrateOrganizationName(entry);
223+
return rest;
224+
});
225+
if (fields && fields.length > 0) {
226+
return hydrated.map((item) => filterFields(item, fields));
227+
}
228+
return hydrated;
229+
}
230+
182231
/**
183232
* Format project view entries for human-readable terminal output.
184233
*
@@ -221,7 +270,7 @@ export const viewCommand = buildCommand({
221270
},
222271
output: {
223272
human: formatProjectViewHuman,
224-
jsonExclude: ["detectedFrom"],
273+
jsonTransform: jsonTransformProjectView,
225274
},
226275
parameters: {
227276
positional: {

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export {
9797
matchesWordBoundary,
9898
type ProjectSearchResult,
9999
type ProjectWithOrg,
100+
resolveOrgDisplayName,
100101
tryGetPrimaryDsn,
101102
} from "./api/projects.js";
102103
export {

src/lib/api/projects.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ import { getCachedOrganizations } from "../db/regions.js";
2626
import { type AuthGuardSuccess, withAuthGuard } from "../errors.js";
2727
import { logger } from "../logger.js";
2828
import { resolveOrgRegion } from "../region.js";
29-
import {
30-
invalidateCachedResponse,
31-
invalidateCachedResponsesMatching,
32-
} from "../response-cache.js";
29+
import { invalidateCachedResponsesMatching } from "../response-cache.js";
3330
import { getApiBaseUrl } from "../sentry-client.js";
3431
import { buildProjectUrl } from "../sentry-urls.js";
3532
import { isAllDigits } from "../utils.js";
@@ -229,6 +226,12 @@ export async function deleteProject(
229226
* Flush the project-detail GET and the org-wide project list so
230227
* follow-up `project list` / `project view` reads don't see the
231228
* deleted project. Never throws.
229+
*
230+
* Uses prefix-matching on the project detail URL rather than an exact
231+
* match because `getProject()` appends `?collapse=organization`, and
232+
* the response cache keys entries by the full URL (including query
233+
* string). A prefix sweep catches the collapsed variant plus any
234+
* future query-parameter additions.
232235
*/
233236
async function invalidateProjectCaches(
234237
orgSlug: string,
@@ -237,7 +240,7 @@ async function invalidateProjectCaches(
237240
try {
238241
const regionUrl = await resolveOrgRegion(orgSlug);
239242
await Promise.all([
240-
invalidateCachedResponse(
243+
invalidateCachedResponsesMatching(
241244
buildApiUrl(regionUrl, "projects", orgSlug, projectSlug)
242245
),
243246
invalidateCachedResponsesMatching(
@@ -453,25 +456,73 @@ export async function findProjectByDsnKey(
453456
/**
454457
* Get a specific project.
455458
* Uses region-aware routing for multi-region support.
459+
*
460+
* Passes `?collapse=organization` so the server skips full-org
461+
* serialization (~400-500ms faster). The response's `organization` field
462+
* is trimmed to `{id, slug}` — no `name`, feature flags, or options.
463+
* Callers needing a display name should use `resolveOrgDisplayName()`
464+
* which falls back to the cached organizations list.
465+
*
466+
* Self-hosted or older Sentry versions that don't recognize `collapse`
467+
* silently ignore the query param and return the full `organization`
468+
* payload, so this is safe for all deployments.
456469
*/
457470
export async function getProject(
458471
orgSlug: string,
459472
projectSlug: string
460473
): Promise<SentryProject> {
461474
const config = await getOrgSdkConfig(orgSlug);
462475

463-
const result = await retrieveAProject({
476+
// `collapse` is server-supported but not in the OpenAPI spec, so the
477+
// SDK types `query` as `never` on `RetrieveAProjectData`. Double-cast
478+
// via `unknown` to bypass the stricter argument type while still
479+
// sending the param at runtime. Same intent as the `per_page` cast
480+
// used above.
481+
const result = (await retrieveAProject({
464482
...config,
465483
path: {
466484
organization_id_or_slug: orgSlug,
467485
project_id_or_slug: projectSlug,
468486
},
469-
});
487+
query: { collapse: "organization" },
488+
} as unknown as Parameters<typeof retrieveAProject>[0])) as
489+
| { data: unknown; error: undefined }
490+
| { data: undefined; error: unknown };
470491

471492
const data = unwrapResult(result, "Failed to get project");
472493
return data as unknown as SentryProject;
473494
}
474495

496+
/**
497+
* Resolve an organization's display name from the best available source.
498+
*
499+
* `getProject()` passes `?collapse=organization` so the server skips
500+
* full-org serialization (~400-500ms faster). Collapsed responses omit
501+
* `organization.name`, so callers that want a human-friendly label must
502+
* fall back to cached org metadata.
503+
*
504+
* Resolution order:
505+
* 1. Explicit `name` if present (self-hosted or Sentry versions that
506+
* ignore the `collapse` query param still return the full payload).
507+
* 2. The locally cached organizations list (populated by login and every
508+
* org-fanout operation).
509+
* 3. The slug itself — always a valid human identifier, worst case.
510+
*
511+
* @param orgSlug - Organization slug (required for cache lookup)
512+
* @param explicitName - The `organization.name` from an API response, if any
513+
* @returns A display-ready organization name (never empty)
514+
*/
515+
export function resolveOrgDisplayName(
516+
orgSlug: string,
517+
explicitName?: string
518+
): string {
519+
if (explicitName) {
520+
return explicitName;
521+
}
522+
const cached = getCachedOrganizations().find((o) => o.slug === orgSlug);
523+
return cached?.name ?? orgSlug;
524+
}
525+
475526
/**
476527
* Get project keys (DSNs) for a project.
477528
* Uses region-aware routing for multi-region support.

src/lib/dsn/resolver.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
findProjectByDsnKey,
1010
listOrganizations,
1111
listProjects,
12+
resolveOrgDisplayName,
1213
} from "../api-client.js";
1314
import { getCachedDsn, updateCachedResolution } from "../db/dsn-cache.js";
1415
import { getDsnSourceDescription } from "./detector.js";
@@ -65,7 +66,10 @@ export async function resolveProject(
6566

6667
const resolved: ResolvedProjectInfo = {
6768
orgSlug: project.organization.slug,
68-
orgName: project.organization.name,
69+
orgName: resolveOrgDisplayName(
70+
project.organization.slug,
71+
project.organization.name
72+
),
6973
projectSlug: project.slug,
7074
projectName: project.name,
7175
};

src/lib/formatters/human.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
TraceSpan,
2929
Writer,
3030
} from "../../types/index.js";
31+
import { resolveOrgDisplayName } from "../api-client.js";
3132
import { withSerializeSpan } from "../telemetry.js";
3233
import { type FixabilityTier, muted } from "./colors.js";
3334
import {
@@ -1537,9 +1538,13 @@ export function formatProjectDetails(
15371538
kvRows.push(["Created", new Date(project.dateCreated).toLocaleString()]);
15381539
}
15391540
if (project.organization) {
1541+
const orgName = resolveOrgDisplayName(
1542+
project.organization.slug,
1543+
project.organization.name
1544+
);
15401545
kvRows.push([
15411546
"Organization",
1542-
`${escapeMarkdownInline(project.organization.name)} (${safeCodeSpan(project.organization.slug)})`,
1547+
`${escapeMarkdownInline(orgName)} (${safeCodeSpan(project.organization.slug)})`,
15431548
]);
15441549
}
15451550
if (project.firstEvent) {

src/lib/resolve-target.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
getProject,
2525
listOrganizations,
2626
listProjects,
27+
resolveOrgDisplayName,
2728
} from "./api-client.js";
2829
import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js";
2930
import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js";
@@ -193,9 +194,13 @@ export async function resolveFromDsn(
193194
const projectInfo = await getProject(dsn.orgId, dsn.projectId);
194195

195196
if (projectInfo.organization) {
197+
const orgName = resolveOrgDisplayName(
198+
projectInfo.organization.slug,
199+
projectInfo.organization.name
200+
);
196201
setCachedProject(dsn.orgId, dsn.projectId, {
197202
orgSlug: projectInfo.organization.slug,
198-
orgName: projectInfo.organization.name,
203+
orgName,
199204
projectSlug: projectInfo.slug,
200205
projectName: projectInfo.name,
201206
projectId: projectInfo.id,
@@ -205,7 +210,7 @@ export async function resolveFromDsn(
205210
org: projectInfo.organization.slug,
206211
project: projectInfo.slug,
207212
projectId: toNumericId(projectInfo.id),
208-
orgDisplay: projectInfo.organization.name,
213+
orgDisplay: orgName,
209214
projectDisplay: projectInfo.name,
210215
detectedFrom,
211216
};
@@ -333,9 +338,13 @@ export async function resolveDsnByPublicKey(
333338
}
334339

335340
if (projectInfo.organization) {
341+
const orgName = resolveOrgDisplayName(
342+
projectInfo.organization.slug,
343+
projectInfo.organization.name
344+
);
336345
setCachedProjectByDsnKey(dsn.publicKey, {
337346
orgSlug: projectInfo.organization.slug,
338-
orgName: projectInfo.organization.name,
347+
orgName,
339348
projectSlug: projectInfo.slug,
340349
projectName: projectInfo.name,
341350
projectId: projectInfo.id,
@@ -345,7 +354,7 @@ export async function resolveDsnByPublicKey(
345354
org: projectInfo.organization.slug,
346355
project: projectInfo.slug,
347356
projectId: toNumericId(projectInfo.id),
348-
orgDisplay: projectInfo.organization.name,
357+
orgDisplay: orgName,
349358
projectDisplay: projectInfo.name,
350359
detectedFrom,
351360
packagePath: dsn.packagePath,
@@ -402,9 +411,13 @@ async function resolveDsnToTarget(
402411
const projectInfo = await getProject(orgId, dsnProjectId);
403412

404413
if (projectInfo.organization) {
414+
const orgName = resolveOrgDisplayName(
415+
projectInfo.organization.slug,
416+
projectInfo.organization.name
417+
);
405418
setCachedProject(orgId, dsnProjectId, {
406419
orgSlug: projectInfo.organization.slug,
407-
orgName: projectInfo.organization.name,
420+
orgName,
408421
projectSlug: projectInfo.slug,
409422
projectName: projectInfo.name,
410423
projectId: projectInfo.id,
@@ -414,7 +427,7 @@ async function resolveDsnToTarget(
414427
org: projectInfo.organization.slug,
415428
project: projectInfo.slug,
416429
projectId: toNumericId(projectInfo.id),
417-
orgDisplay: projectInfo.organization.name,
430+
orgDisplay: orgName,
418431
projectDisplay: projectInfo.name,
419432
detectedFrom,
420433
packagePath,
@@ -880,7 +893,10 @@ export async function fetchProjectId(
880893
try {
881894
setCachedProject(project_.organization.id, project_.id, {
882895
orgSlug: project_.organization.slug,
883-
orgName: project_.organization.name,
896+
orgName: resolveOrgDisplayName(
897+
project_.organization.slug,
898+
project_.organization.name
899+
),
884900
projectSlug: project_.slug,
885901
projectName: project_.name,
886902
projectId: project_.id,

src/types/sentry.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,19 @@ export type SentryProject = Partial<SdkProjectListItem> & {
5858
id: string;
5959
slug: string;
6060
name: string;
61-
/** Organization context (present in detail responses, absent in list) */
61+
/**
62+
* Organization context (present in detail responses, absent in list).
63+
*
64+
* `name` is optional because `getProject()` passes `?collapse=organization`
65+
* to skip full-org serialization on the server (~400-500ms faster). The
66+
* collapsed payload only carries `{id, slug}`. Callers needing a display
67+
* name should use `resolveOrgDisplayName()` which falls back to the
68+
* cached organizations list.
69+
*/
6270
organization?: {
6371
id: string;
6472
slug: string;
65-
name: string;
73+
name?: string;
6674
[key: string]: unknown;
6775
};
6876
/** Project status (returned by API but not in the OpenAPI spec) */

0 commit comments

Comments
 (0)