Skip to content

Commit 5856b42

Browse files
fix(resolve): skip org/project discovery when the DSN already identifies the target (#1476)
Closes #1147 ## Problem When the CLI runs via an AI agent (MCP/skill), it redundantly discovered org and project identity even though a DSN was already present, causing unnecessary API calls and occasional re-auth. ## Root cause A SaaS DSN (`oXXX.ingest…` host) encodes both the numeric org ID and project ID, so it already identifies the target. But on a `project_cache` miss, `resolveFromDsn` and `resolveDsnToTarget` still fired a `getProject(orgId, projectId)` discovery call — routed through the auth/SDK-config path — purely to translate the numeric IDs into slugs/display names. That call recurs on every cold-cache invocation (common in ephemeral agent environments), which is the redundant API traffic and re-auth the issue reports. ## Fix `resolveFromDsn` and `resolveDsnToTarget` (both in `packages/cli/src/lib/resolve-target.ts`) now resolve without any discovery API call for DSNs that carry an org ID: 1. use resolution the DSN detection layer already surfaced (`dsn.resolved`), else 2. use locally cached slugs (`project_cache`), else 3. build the target from the DSN's numeric IDs via a new `dsnTargetFromNumericIds` helper — the Sentry API accepts numeric IDs as `{org,project}_id_or_slug`, and the org slug is enriched from the local regions cache (`getOrgByNumericId`, no network) when available. Public-key-only DSNs (self-hosted / no org ID) are unchanged — the DSN does not encode identity there, so they still resolve via `findProjectByDsnKey`. ## Tests `packages/cli/test/lib/resolve-target.mocked.test.ts`: - replaced the two obsolete cache-miss tests with regression tests asserting `getProject` is **not** called when the DSN encodes identity (detector-`resolved` path, numeric-ID fallback, and regions-cache slug enrichment) - added a `resolveAllTargets` test asserting orgId DSNs resolve with no discovery call - reworked the "resolution fails" test to use a public-key DSN, the only path that can still fail Verified locally: `tsc --noEmit` clean; all 50 `resolve-target.mocked` tests pass. <!-- jared:ci-checks=12 --> Co-authored-by: jared-outpost[bot] <jared-outpost[bot]@users.noreply.github.com>
1 parent f23250b commit 5856b42

2 files changed

Lines changed: 158 additions & 115 deletions

File tree

‎src/lib/resolve-target.ts‎

Lines changed: 67 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ export type ResolveOrgOptions = {
182182

183183
/**
184184
* Resolve organization and project from DSN detection.
185-
* Uses cached project info when available, otherwise fetches and caches it.
185+
*
186+
* A SaaS DSN already encodes org+project identity, so this never makes a
187+
* discovery API call: it returns cached slugs when available and otherwise
188+
* builds the target from the DSN's numeric IDs (valid API identifiers).
186189
*
187190
* @param cwd - Current working directory to search for DSN
188191
* @returns Resolved target with org/project info, or null if DSN not found
@@ -197,7 +200,19 @@ export async function resolveFromDsn(
197200

198201
const detectedFrom = getDsnSourceDescription(dsn);
199202

200-
// Check cache first
203+
// Resolution the DSN detection layer already surfaced (dsn_cache) — no lookup.
204+
if (dsn.resolved) {
205+
return {
206+
org: dsn.resolved.orgSlug,
207+
project: dsn.resolved.projectSlug,
208+
projectId: toNumericId(dsn.projectId),
209+
orgDisplay: dsn.resolved.orgName,
210+
projectDisplay: dsn.resolved.projectName,
211+
detectedFrom,
212+
};
213+
}
214+
215+
// Locally cached slugs — no lookup.
201216
const cached = getCachedProject(dsn.orgId, dsn.projectId);
202217
if (cached) {
203218
return {
@@ -210,40 +225,33 @@ export async function resolveFromDsn(
210225
};
211226
}
212227

213-
// Cache miss — fetch project details and cache them
214-
const projectInfo = await getProject(dsn.orgId, dsn.projectId);
215-
216-
if (projectInfo.organization) {
217-
const orgName = resolveOrgDisplayName(
218-
projectInfo.organization.slug,
219-
projectInfo.organization.name
220-
);
221-
setCachedProject(dsn.orgId, dsn.projectId, {
222-
orgSlug: projectInfo.organization.slug,
223-
orgName,
224-
projectSlug: projectInfo.slug,
225-
projectName: projectInfo.name,
226-
projectId: projectInfo.id,
227-
});
228-
229-
return {
230-
org: projectInfo.organization.slug,
231-
project: projectInfo.slug,
232-
projectId: toNumericId(projectInfo.id),
233-
orgDisplay: orgName,
234-
projectDisplay: projectInfo.name,
235-
detectedFrom,
236-
};
237-
}
228+
// The DSN already encodes org+project identity, so skip the getProject
229+
// discovery call (and its auth round-trip). Enrich the org slug from the
230+
// local regions cache when available, otherwise fall back to the numeric IDs
231+
// — the API accepts both as {org,project}_id_or_slug path params.
232+
return dsnTargetFromNumericIds(dsn.orgId, dsn.projectId, detectedFrom);
233+
}
238234

239-
// Fallback to numeric IDs if org info missing (rare edge case)
235+
/**
236+
* Build a {@link ResolvedTarget} straight from a DSN's numeric org/project IDs
237+
* without any API discovery. The org slug is resolved from the local regions
238+
* cache when present; both IDs are otherwise valid API identifiers.
239+
*/
240+
function dsnTargetFromNumericIds(
241+
orgId: string,
242+
projectId: string,
243+
detectedFrom: string,
244+
packagePath?: string
245+
): ResolvedTarget {
246+
const org = getOrgByNumericId(orgId)?.slug ?? orgId;
240247
return {
241-
org: dsn.orgId,
242-
project: dsn.projectId,
243-
projectId: toNumericId(projectInfo.id),
244-
orgDisplay: dsn.orgId,
245-
projectDisplay: projectInfo.name,
248+
org,
249+
project: projectId,
250+
projectId: toNumericId(projectId),
251+
orgDisplay: org,
252+
projectDisplay: projectId,
246253
detectedFrom,
254+
packagePath,
247255
};
248256
}
249257

@@ -389,10 +397,11 @@ export async function resolveDsnByPublicKey(
389397

390398
/**
391399
* Resolve a single detected DSN to a ResolvedTarget.
392-
* Uses cache when available, otherwise fetches from API.
393400
*
394401
* Supports two resolution paths:
395-
* 1. DSNs with orgId: Use getProject(orgId, projectId) API
402+
* 1. DSNs with orgId: the DSN already encodes org+project identity, so no
403+
* discovery API call is made — cached slugs are used when available,
404+
* otherwise the target is built from the DSN's numeric IDs.
396405
* 2. DSNs without orgId: Use findProjectByDsnKey(publicKey) API
397406
*
398407
* @param dsn - Detected DSN to resolve
@@ -404,15 +413,27 @@ async function resolveDsnToTarget(
404413
// For DSNs without orgId (self-hosted or some SaaS patterns),
405414
// resolve by searching for the project via DSN public key
406415
if (!dsn.orgId) {
407-
return resolveDsnByPublicKey(dsn);
416+
return await resolveDsnByPublicKey(dsn);
408417
}
409418

410-
// Capture narrowed values before the closure (TS loses narrowing across closures)
411419
const orgId = dsn.orgId;
412420
const { projectId: dsnProjectId, packagePath } = dsn;
413421
const detectedFrom = getDsnSourceDescription(dsn);
414422

415-
// Check cache first
423+
// Resolution the DSN detection layer already surfaced (dsn_cache) — no lookup.
424+
if (dsn.resolved) {
425+
return {
426+
org: dsn.resolved.orgSlug,
427+
project: dsn.resolved.projectSlug,
428+
projectId: toNumericId(dsnProjectId),
429+
orgDisplay: dsn.resolved.orgName,
430+
projectDisplay: dsn.resolved.projectName,
431+
detectedFrom,
432+
packagePath,
433+
};
434+
}
435+
436+
// Locally cached slugs — no lookup.
416437
const cached = getCachedProject(orgId, dsnProjectId);
417438
if (cached) {
418439
return {
@@ -426,46 +447,14 @@ async function resolveDsnToTarget(
426447
};
427448
}
428449

429-
// Cache miss — fetch project details and cache them
430-
const result = await withAuthGuard(async () => {
431-
const projectInfo = await getProject(orgId, dsnProjectId);
432-
433-
if (projectInfo.organization) {
434-
const orgName = resolveOrgDisplayName(
435-
projectInfo.organization.slug,
436-
projectInfo.organization.name
437-
);
438-
setCachedProject(orgId, dsnProjectId, {
439-
orgSlug: projectInfo.organization.slug,
440-
orgName,
441-
projectSlug: projectInfo.slug,
442-
projectName: projectInfo.name,
443-
projectId: projectInfo.id,
444-
});
445-
446-
return {
447-
org: projectInfo.organization.slug,
448-
project: projectInfo.slug,
449-
projectId: toNumericId(projectInfo.id),
450-
orgDisplay: orgName,
451-
projectDisplay: projectInfo.name,
452-
detectedFrom,
453-
packagePath,
454-
};
455-
}
456-
457-
// Fallback to numeric IDs if org info missing
458-
return {
459-
org: orgId,
460-
project: dsnProjectId,
461-
projectId: toNumericId(projectInfo.id),
462-
orgDisplay: orgId,
463-
projectDisplay: projectInfo.name,
464-
detectedFrom,
465-
packagePath,
466-
};
467-
});
468-
return result.ok ? result.value : null;
450+
// The DSN already encodes org+project identity, so skip the getProject
451+
// discovery call (and its auth round-trip) and build the target directly.
452+
return dsnTargetFromNumericIds(
453+
orgId,
454+
dsnProjectId,
455+
detectedFrom,
456+
packagePath
457+
);
469458
}
470459

471460
/** Minimum directory name length for inference (avoids matching too broadly) */

‎test/lib/resolve-target.mocked.test.ts‎

Lines changed: 91 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ describe("resolveFromDsn", () => {
475475
expect(mockGetCachedProject).toHaveBeenCalledWith("123", "456");
476476
});
477477

478-
test("fetches and caches project info on cache miss", async () => {
478+
test("uses resolution surfaced by the detector without any API call", async () => {
479479
mockDetectDsn.mockResolvedValue({
480480
raw: "https://abc@o123.ingest.sentry.io/456",
481481
protocol: "https",
@@ -484,31 +484,26 @@ describe("resolveFromDsn", () => {
484484
projectId: "456",
485485
orgId: "123",
486486
source: "env",
487-
sourcePath: "/test/.env",
488-
});
489-
mockGetCachedProject.mockReturnValue(null);
490-
mockGetProject.mockResolvedValue({
491-
id: "456",
492-
slug: "fetched-project",
493-
name: "Fetched Project",
494-
organization: {
495-
id: "123",
496-
slug: "fetched-org",
497-
name: "Fetched Organization",
487+
resolved: {
488+
orgSlug: "detected-org",
489+
orgName: "Detected Organization",
490+
projectSlug: "detected-project",
491+
projectName: "Detected Project",
498492
},
499493
});
500494

501495
const result = await resolveFromDsn("/test");
502496

503-
expect(result).not.toBeNull();
504-
expect(result?.org).toBe("fetched-org");
505-
expect(result?.project).toBe("fetched-project");
506-
expect(result?.orgDisplay).toBe("Fetched Organization");
507-
expect(result?.projectDisplay).toBe("Fetched Project");
508-
expect(mockSetCachedProject).toHaveBeenCalled();
497+
expect(result?.org).toBe("detected-org");
498+
expect(result?.project).toBe("detected-project");
499+
expect(result?.orgDisplay).toBe("Detected Organization");
500+
expect(result?.projectDisplay).toBe("Detected Project");
501+
// DSN already encodes identity — no discovery call and no cache lookup.
502+
expect(mockGetProject).not.toHaveBeenCalled();
503+
expect(mockGetCachedProject).not.toHaveBeenCalled();
509504
});
510505

511-
test("falls back to numeric IDs when project has no org info", async () => {
506+
test("skips org/project discovery on cache miss when DSN encodes identity", async () => {
512507
mockDetectDsn.mockResolvedValue({
513508
raw: "https://abc@o123.ingest.sentry.io/456",
514509
protocol: "https",
@@ -517,22 +512,43 @@ describe("resolveFromDsn", () => {
517512
projectId: "456",
518513
orgId: "123",
519514
source: "env",
515+
sourcePath: "/test/.env",
520516
});
521517
mockGetCachedProject.mockReturnValue(null);
522-
mockGetProject.mockResolvedValue({
523-
id: "456",
524-
slug: "project",
525-
name: "Project Name",
526-
// No organization field
527-
});
528518

529519
const result = await resolveFromDsn("/test");
530520

531-
// Falls back to using numeric IDs (both org and project)
532-
expect(result).not.toBeNull();
521+
// No getProject discovery call — the numeric IDs from the DSN are used
522+
// directly (the API accepts them as org/project identifiers).
523+
expect(mockGetProject).not.toHaveBeenCalled();
533524
expect(result?.org).toBe("123");
534-
expect(result?.project).toBe("456"); // Uses dsn.projectId, not projectInfo.slug
535-
expect(result?.projectDisplay).toBe("Project Name");
525+
expect(result?.project).toBe("456");
526+
expect(result?.projectId).toBe(456);
527+
});
528+
529+
test("enriches org slug from the local regions cache without an API call", async () => {
530+
mockDetectDsn.mockResolvedValue({
531+
raw: "https://abc@o1169445.ingest.us.sentry.io/456",
532+
protocol: "https",
533+
publicKey: "abc",
534+
host: "o1169445.ingest.us.sentry.io",
535+
projectId: "456",
536+
orgId: "1169445",
537+
source: "env",
538+
});
539+
mockGetCachedProject.mockReturnValue(null);
540+
mockGetOrgByNumericId.mockReturnValue({
541+
slug: "my-org",
542+
regionUrl: "https://us.sentry.io",
543+
});
544+
545+
const result = await resolveFromDsn("/test");
546+
547+
expect(result?.org).toBe("my-org");
548+
expect(result?.orgDisplay).toBe("my-org");
549+
expect(result?.project).toBe("456");
550+
expect(mockGetProject).not.toHaveBeenCalled();
551+
expect(mockGetOrgByNumericId).toHaveBeenCalledWith("1169445");
536552
});
537553
});
538554

@@ -840,7 +856,47 @@ describe("resolveAllTargets", () => {
840856
expect(result.targets[0].project).toBe("my-app");
841857
});
842858

843-
test("returns empty targets when all DSN resolutions fail", async () => {
859+
test("returns empty targets when a public-key DSN cannot be resolved", async () => {
860+
mockGetDefaultOrganization.mockReturnValue(null);
861+
mockGetDefaultProject.mockReturnValue(null);
862+
// Self-hosted / public-key-only DSN (no orgId) — the DSN does not encode
863+
// org identity, so a lookup is required and can fail.
864+
mockDetectAllDsns.mockResolvedValue({
865+
primary: {
866+
raw: "https://abc@sentry.example.com/456",
867+
protocol: "https",
868+
publicKey: "abc",
869+
host: "sentry.example.com",
870+
projectId: "456",
871+
source: "env",
872+
},
873+
all: [
874+
{
875+
raw: "https://abc@sentry.example.com/456",
876+
protocol: "https",
877+
publicKey: "abc",
878+
host: "sentry.example.com",
879+
projectId: "456",
880+
source: "env",
881+
},
882+
],
883+
hasMultiple: false,
884+
fingerprint: "",
885+
});
886+
mockGetCachedProjectByDsnKey.mockReturnValue(null);
887+
// findProjectByDsnKey returns null (project not found)
888+
mockFindProjectByDsnKey.mockResolvedValue(null);
889+
mockFindProjectRoot.mockResolvedValue({
890+
projectRoot: "/a",
891+
detectedFrom: "package.json",
892+
});
893+
894+
const result = await resolveAllTargets({ cwd: "/test" });
895+
896+
expect(result.targets).toHaveLength(0);
897+
});
898+
899+
test("skips discovery for orgId DSNs and resolves from numeric IDs", async () => {
844900
mockGetDefaultOrganization.mockReturnValue(null);
845901
mockGetDefaultProject.mockReturnValue(null);
846902
mockDetectAllDsns.mockResolvedValue({
@@ -868,16 +924,14 @@ describe("resolveAllTargets", () => {
868924
fingerprint: "",
869925
});
870926
mockGetCachedProject.mockReturnValue(null);
871-
// getProject returns null (project not found)
872-
mockGetProject.mockResolvedValue(null);
873-
mockFindProjectRoot.mockResolvedValue({
874-
projectRoot: "/a",
875-
detectedFrom: "package.json",
876-
});
877927

878928
const result = await resolveAllTargets({ cwd: "/test" });
879929

880-
expect(result.targets).toHaveLength(0);
930+
expect(result.targets).toHaveLength(1);
931+
expect(result.targets[0].org).toBe("123");
932+
expect(result.targets[0].project).toBe("456");
933+
// The DSN already identifies the target — no discovery API call.
934+
expect(mockGetProject).not.toHaveBeenCalled();
881935
});
882936
});
883937

0 commit comments

Comments
 (0)