Skip to content

Commit f64cfcd

Browse files
committed
fix(region): resolve DSN org prefix at resolution layer (#316)
Move DSN org prefix normalization from arg-parsing (proactive strip) to resolution layer (best-effort fallback). When an org identifier like "o1081365" fails API resolution, resolveEffectiveOrg() strips the "o" prefix and retries with the numeric ID. - Add resolveEffectiveOrg() to src/lib/region.ts with cache-aware fast path and AuthError-safe fallback - Move stripDsnOrgPrefix() to src/lib/dsn/parser.ts (co-located with DSN parsing logic) - Remove normalization from arg-parsing.ts — parser preserves raw input unchanged - Apply resolveEffectiveOrg() at 7 call sites across event/view, issue/utils, org-list, and resolve-target - Update tests to expect raw "oNNNNN" values from parser
1 parent 63d397e commit f64cfcd

File tree

9 files changed

+287
-91
lines changed

9 files changed

+287
-91
lines changed

AGENTS.md

Lines changed: 32 additions & 76 deletions
Large diffs are not rendered by default.

src/commands/event/view.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { openInBrowser } from "../../lib/browser.js";
2121
import { buildCommand } from "../../lib/command.js";
2222
import { ContextError, ResolutionError } from "../../lib/errors.js";
2323
import { formatEventDetails, writeJson } from "../../lib/formatters/index.js";
24+
import { resolveEffectiveOrg } from "../../lib/region.js";
2425
import {
2526
resolveOrgAndProject,
2627
resolveProjectBySlug,
@@ -168,13 +169,15 @@ export async function resolveEventTarget(
168169
const { parsed, eventId, cwd, stderr } = options;
169170

170171
switch (parsed.type) {
171-
case ProjectSpecificationType.Explicit:
172+
case ProjectSpecificationType.Explicit: {
173+
const org = await resolveEffectiveOrg(parsed.org);
172174
return {
173-
org: parsed.org,
175+
org,
174176
project: parsed.project,
175177
orgDisplay: parsed.org,
176178
projectDisplay: parsed.project,
177179
};
180+
}
178181

179182
case ProjectSpecificationType.ProjectSearch: {
180183
const resolved = await resolveProjectBySlug(
@@ -190,8 +193,10 @@ export async function resolveEventTarget(
190193
};
191194
}
192195

193-
case ProjectSpecificationType.OrgAll:
194-
return resolveOrgAllTarget(parsed.org, eventId, cwd);
196+
case ProjectSpecificationType.OrgAll: {
197+
const org = await resolveEffectiveOrg(parsed.org);
198+
return resolveOrgAllTarget(org, eventId, cwd);
199+
}
195200

196201
case ProjectSpecificationType.AutoDetect:
197202
return resolveAutoDetectTarget(eventId, cwd, stderr);

src/commands/issue/utils.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js";
1919
import { getProgressMessage } from "../../lib/formatters/seer.js";
2020
import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js";
2121
import { poll } from "../../lib/polling.js";
22+
import { resolveEffectiveOrg } from "../../lib/region.js";
2223
import { resolveOrg, resolveOrgAndProject } from "../../lib/resolve-target.js";
2324
import { parseSentryUrl } from "../../lib/sentry-url-parser.js";
2425
import { isAllDigits } from "../../lib/utils.js";
@@ -328,24 +329,26 @@ export async function resolveIssue(
328329

329330
case "explicit": {
330331
// Full context: org + project + suffix
332+
const org = await resolveEffectiveOrg(parsed.org);
331333
const fullShortId = expandToFullShortId(parsed.suffix, parsed.project);
332-
const issue = await getIssueByShortId(parsed.org, fullShortId);
333-
return { org: parsed.org, issue };
334+
const issue = await getIssueByShortId(org, fullShortId);
335+
return { org, issue };
334336
}
335337

336338
case "explicit-org-numeric": {
337339
// Org + numeric ID — use org-scoped endpoint for proper region routing.
340+
const org = await resolveEffectiveOrg(parsed.org);
338341
try {
339-
const issue = await getIssueInOrg(parsed.org, parsed.numericId);
340-
return { org: parsed.org, issue };
342+
const issue = await getIssueInOrg(org, parsed.numericId);
343+
return { org, issue };
341344
} catch (err) {
342345
if (err instanceof ApiError && err.status === 404) {
343346
throw new ResolutionError(
344347
`Issue ${parsed.numericId}`,
345348
"not found",
346349
commandHint,
347350
[
348-
`No issue with numeric ID ${parsed.numericId} found in org '${parsed.org}' — you may not have access, or it may have been deleted.`,
351+
`No issue with numeric ID ${parsed.numericId} found in org '${org}' — you may not have access, or it may have been deleted.`,
349352
`If this is a short ID suffix, try: sentry issue ${command} <project>-${parsed.numericId}`,
350353
]
351354
);
@@ -354,9 +357,11 @@ export async function resolveIssue(
354357
}
355358
}
356359

357-
case "explicit-org-suffix":
360+
case "explicit-org-suffix": {
358361
// Org + suffix only - ambiguous without project, always errors
359-
return resolveExplicitOrgSuffix(parsed.org, parsed.suffix, commandHint);
362+
const org = await resolveEffectiveOrg(parsed.org);
363+
return resolveExplicitOrgSuffix(org, parsed.suffix, commandHint);
364+
}
360365

361366
case "project-search":
362367
// Project slug + suffix - search across orgs

src/lib/dsn/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {
5151
inferPackagePath,
5252
isValidDsn,
5353
parseDsn,
54+
stripDsnOrgPrefix,
5455
} from "./parser.js";
5556
// Project Root Detection
5657
export type { ProjectRootReason, ProjectRootResult } from "./project-root.js";

src/lib/dsn/parser.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,35 @@ export function createDsnFingerprint(dsns: DetectedDsn[]): string {
209209
// Deduplicate (same DSN might be detected from multiple sources)
210210
return [...new Set(keys)].join(",");
211211
}
212+
213+
/**
214+
* Pattern matching bare DSN-style org identifiers: "o" followed by only digits.
215+
*
216+
* This is the bare-identifier counterpart of {@link ORG_ID_PATTERN} (which
217+
* matches the full ingest host). Used to normalize org identifiers that
218+
* were extracted from a DSN host and passed as CLI arguments.
219+
*/
220+
const DSN_ORG_PREFIX_PATTERN = /^o(\d+)$/;
221+
222+
/**
223+
* Normalize a DSN-style org identifier to a numeric org ID.
224+
*
225+
* DSN hosts encode org IDs as `oNNNNN` (e.g., `o1081365` from
226+
* `o1081365.ingest.us.sentry.io`). The Sentry API accepts numeric IDs
227+
* via `organization_id_or_slug` but not the `o`-prefixed DSN form.
228+
*
229+
* Only matches the exact pattern "o" + digits — normal slugs like
230+
* `organic` or `o1abc` pass through unchanged.
231+
*
232+
* @param org - Raw org identifier
233+
* @returns Numeric org ID if input matches `oNNNNN`, otherwise unchanged
234+
*
235+
* @example
236+
* stripDsnOrgPrefix("o1081365") // "1081365"
237+
* stripDsnOrgPrefix("o123") // "123"
238+
* stripDsnOrgPrefix("sentry") // "sentry" (no change)
239+
* stripDsnOrgPrefix("organic") // "organic" (no change — not all digits after 'o')
240+
*/
241+
export function stripDsnOrgPrefix(org: string): string {
242+
return org.replace(DSN_ORG_PREFIX_PATTERN, "$1");
243+
}

src/lib/org-list.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from "./db/pagination.js";
4040
import { AuthError, ContextError, ValidationError } from "./errors.js";
4141
import { writeFooter, writeJson } from "./formatters/index.js";
42+
import { resolveEffectiveOrg } from "./region.js";
4243
import { resolveOrgsForListing } from "./resolve-target.js";
4344

4445
// ---------------------------------------------------------------------------
@@ -764,11 +765,26 @@ export async function dispatchOrgScopedList<TEntity, TWithOrg>(
764765
);
765766
}
766767

768+
// Normalize DSN-style org identifiers (e.g., "o1081365" → "1081365").
769+
// Only fires as a fallback when the original org fails region resolution.
770+
let effectiveParsed: ParsedOrgProject = parsed;
771+
if (parsed.type === "explicit" || parsed.type === "org-all") {
772+
const effectiveOrg = await resolveEffectiveOrg(parsed.org);
773+
if (effectiveOrg !== parsed.org) {
774+
effectiveParsed = { ...parsed, org: effectiveOrg };
775+
}
776+
}
777+
767778
const defaults = buildDefaultHandlers(config);
768779
const handlers: ModeHandlerMap = { ...defaults, ...overrides };
769-
const handler = handlers[parsed.type];
780+
const handler = handlers[effectiveParsed.type];
770781

771-
const ctx: HandlerContext = { parsed, stdout, cwd, flags };
782+
const ctx: HandlerContext = {
783+
parsed: effectiveParsed,
784+
stdout,
785+
cwd,
786+
flags,
787+
};
772788

773789
// TypeScript cannot prove that `parsed` narrows to `ParsedVariant<typeof parsed.type>`
774790
// through the dynamic handler lookup, but the handler map guarantees type safety.

src/lib/region.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { retrieveAnOrganization } from "@sentry/api";
99
import { getOrgRegion, setOrgRegion } from "./db/regions.js";
10+
import { stripDsnOrgPrefix } from "./dsn/index.js";
1011
import { AuthError } from "./errors.js";
1112
import { getSdkConfig } from "./sentry-client.js";
1213
import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js";
@@ -78,3 +79,76 @@ export function isMultiRegionEnabled(): boolean {
7879
}
7980
return true;
8081
}
82+
83+
/**
84+
* Resolve the effective org slug for API calls, with DSN prefix fallback.
85+
*
86+
* When users or AI agents extract org identifiers from DSN hosts
87+
* (e.g., `o1081365` from `o1081365.ingest.us.sentry.io`), the `o`-prefixed
88+
* form isn't recognized by the Sentry API. This function validates the org
89+
* via region resolution and falls back to stripping the DSN prefix only
90+
* when the original identifier fails.
91+
*
92+
* The fallback triggers only when:
93+
* 1. The org wasn't previously cached (first-time resolution)
94+
* 2. Region resolution didn't cache a result (API didn't recognize the org)
95+
* 3. The org matches the DSN `oNNNNN` pattern
96+
*
97+
* `resolveOrgRegion` caches on success but not on failure — this is the
98+
* signal used to detect that an org wasn't found.
99+
*
100+
* @param orgSlug - Raw org identifier from user input
101+
* @returns The org slug to use for API calls (may be normalized)
102+
*/
103+
export async function resolveEffectiveOrg(orgSlug: string): Promise<string> {
104+
// Fast path: already cached from a previous successful resolution
105+
const cached = await getOrgRegion(orgSlug);
106+
if (cached) {
107+
return orgSlug;
108+
}
109+
110+
// Attempt to resolve — resolveOrgRegion caches on success, not on failure.
111+
// Auth errors mean we can't validate — return as-is and let downstream
112+
// API calls produce the proper auth error with context.
113+
try {
114+
await resolveOrgRegion(orgSlug);
115+
} catch (error) {
116+
if (error instanceof AuthError) {
117+
return orgSlug;
118+
}
119+
throw error;
120+
}
121+
122+
// Check if the resolution succeeded (was it cached?)
123+
const afterResolve = await getOrgRegion(orgSlug);
124+
if (afterResolve) {
125+
return orgSlug;
126+
}
127+
128+
// Resolution failed — try DSN prefix stripping as fallback
129+
const stripped = stripDsnOrgPrefix(orgSlug);
130+
if (stripped === orgSlug) {
131+
// Not a DSN-style identifier — return as-is, let downstream fail naturally
132+
return orgSlug;
133+
}
134+
135+
// Try the stripped version
136+
try {
137+
await resolveOrgRegion(stripped);
138+
} catch (error) {
139+
if (error instanceof AuthError) {
140+
return orgSlug;
141+
}
142+
throw error;
143+
}
144+
145+
const strippedCached = await getOrgRegion(stripped);
146+
if (strippedCached) {
147+
// Cache under original key too so future calls are instant
148+
await setOrgRegion(orgSlug, strippedCached);
149+
return stripped;
150+
}
151+
152+
// Neither worked — return original, let downstream produce the error
153+
return orgSlug;
154+
}

src/lib/resolve-target.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
ValidationError,
4646
} from "./errors.js";
4747
import { warning } from "./formatters/colors.js";
48+
import { resolveEffectiveOrg } from "./region.js";
4849
import { isAllDigits } from "./utils.js";
4950

5051
/**
@@ -996,8 +997,10 @@ export async function resolveOrgProjectTarget(
996997
const usageHint = `sentry ${commandName} <org>/<project>`;
997998

998999
switch (parsed.type) {
999-
case "explicit":
1000-
return { org: parsed.org, project: parsed.project };
1000+
case "explicit": {
1001+
const org = await resolveEffectiveOrg(parsed.org);
1002+
return { org, project: parsed.project };
1003+
}
10011004

10021005
case "org-all":
10031006
throw new ContextError(

0 commit comments

Comments
 (0)