Skip to content

Commit 8983ddb

Browse files
authored
perf: HTTP latency optimizations — diagnostics, cache warming, concurrency limits (#490)
## Summary Four targeted optimizations based on investigation showing Bun already uses HTTP/2 multiplexing and keep-alive (transport is not the bottleneck), but sequential resolution chains and unbounded fan-outs cause observable slowness. ## Changes ### 1. HTTP timing diagnostics (`sentry-client.ts`) Debug-level logging in the authenticated fetch showing method, URL path, status, timing (ms), cache hits, and retry attempts. Visible with `SENTRY_LOG_LEVEL=debug` or `--verbose`. ### 2. Post-login cache warming (`auth/login.ts`) After successful login, fire-and-forget `listOrganizationsUncached()` to pre-populate the org + region SQLite cache, eliminating the ~800ms cold-start on the first command. ### 3. Concurrency limits on org fan-outs Add `p-limit(5)` to all unbounded `Promise.all()` patterns that fan out across organizations or regions: - `findProjectsBySlug()`, `findProjectsByPattern()`, `findProjectByDsnKey()` in `api/projects.ts` - `findEventAcrossOrgs()` in `api/events.ts` - `resolveProjectSearch()` in `commands/issue/utils.ts` ### 4. Faster org resolution for normal slugs (`region.ts`) `resolveEffectiveOrg()` now uses `resolveOrgRegion()` (1 API call) for normal slugs instead of `listOrganizationsUncached()` (1+N requests). The expensive fan-out is reserved for DSN numeric IDs (`oNNNNN`) that need ID→slug mapping. ## Test plan - `bun run typecheck` — clean - `bun run lint` — clean - 293 tests across affected modules pass
1 parent b74d1d6 commit 8983ddb

File tree

8 files changed

+148
-47
lines changed

8 files changed

+148
-47
lines changed

src/commands/auth/login.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { isatty } from "node:tty";
22
import type { SentryContext } from "../../context.js";
3-
import { getCurrentUser, getUserRegions } from "../../lib/api-client.js";
3+
import {
4+
getCurrentUser,
5+
getUserRegions,
6+
listOrganizationsUncached,
7+
} from "../../lib/api-client.js";
48
import { buildCommand, numberParser } from "../../lib/command.js";
59
import {
610
clearAuth,
@@ -185,6 +189,9 @@ export const loginCommand = buildCommand({
185189
// Non-fatal: user info is supplementary. Token remains stored and valid.
186190
}
187191

192+
// Warm the org + region cache so the first real command is fast.
193+
// Fire-and-forget — login already succeeded, caching is best-effort.
194+
warmOrgCache();
188195
return yield new CommandOutput(result);
189196
}
190197

@@ -194,10 +201,29 @@ export const loginCommand = buildCommand({
194201
});
195202

196203
if (result) {
204+
// Warm the org + region cache so the first real command is fast.
205+
// Fire-and-forget — login already succeeded, caching is best-effort.
206+
warmOrgCache();
197207
yield new CommandOutput(result);
198208
} else {
199209
// Error already displayed by runInteractiveLogin
200210
process.exitCode = 1;
201211
}
202212
},
203213
});
214+
215+
/**
216+
* Pre-populate the org + region SQLite cache in the background.
217+
*
218+
* Called after successful authentication so that the first real command
219+
* doesn't pay the cold-start cost of `getUserRegions()` + fan-out to
220+
* each region's org list endpoint (~800ms on a typical SaaS account).
221+
*
222+
* Failures are silently ignored — the cache will be populated lazily
223+
* on the next command that needs it.
224+
*/
225+
function warmOrgCache(): void {
226+
listOrganizationsUncached().catch(() => {
227+
// Best-effort: cache warming failure doesn't affect the login result
228+
});
229+
}

src/commands/issue/utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Common functionality used by explain, plan, view, and other issue commands.
55
*/
66

7+
import pLimit from "p-limit";
78
import {
89
findProjectsBySlug,
910
getAutofixState,
@@ -13,6 +14,7 @@ import {
1314
type IssueSort,
1415
listIssuesPaginated,
1516
listOrganizations,
17+
ORG_FANOUT_CONCURRENCY,
1618
triggerRootCauseAnalysis,
1719
tryGetIssueByShortId,
1820
} from "../../lib/api-client.js";
@@ -238,12 +240,16 @@ async function resolveProjectSearch(
238240
// 3. Fast path: try resolving the short ID directly across all orgs.
239241
// The shortid endpoint validates both project existence and issue existence
240242
// in a single call, eliminating the separate getProject() round-trip.
243+
// Concurrency-limited to avoid overwhelming the API for enterprise users.
241244
const fullShortId = expandToFullShortId(suffix, projectSlug);
242245
const orgs = await listOrganizations();
243246

247+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
244248
const results = await Promise.all(
245249
orgs.map((org) =>
246-
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
250+
limit(() =>
251+
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
252+
)
247253
)
248254
);
249255

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export {
3737
apiRequest,
3838
apiRequestToRegion,
3939
buildSearchParams,
40+
ORG_FANOUT_CONCURRENCY,
4041
type PaginatedResponse,
4142
parseLinkHeader,
4243
rawApiRequest,

src/lib/api/events.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ import {
99
retrieveAnIssueEvent,
1010
resolveAnEventId as sdkResolveAnEventId,
1111
} from "@sentry/api";
12+
import pLimit from "p-limit";
1213

1314
import type { SentryEvent } from "../../types/index.js";
1415

1516
import { ApiError, AuthError } from "../errors.js";
1617

17-
import { getOrgSdkConfig, unwrapResult } from "./infrastructure.js";
18+
import {
19+
getOrgSdkConfig,
20+
ORG_FANOUT_CONCURRENCY,
21+
unwrapResult,
22+
} from "./infrastructure.js";
1823
import { listOrganizations } from "./organizations.js";
1924

2025
/**
@@ -124,8 +129,9 @@ export async function findEventAcrossOrgs(
124129
): Promise<ResolvedEvent | null> {
125130
const orgs = await listOrganizations();
126131

132+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
127133
const results = await Promise.allSettled(
128-
orgs.map((org) => resolveEventInOrg(org.slug, eventId))
134+
orgs.map((org) => limit(() => resolveEventInOrg(org.slug, eventId)))
129135
);
130136

131137
// First pass: return the first successful match

src/lib/api/infrastructure.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ export const MAX_PAGINATION_PAGES = Math.max(
194194
*/
195195
export const API_MAX_PER_PAGE = 100;
196196

197+
/**
198+
* Maximum concurrent API requests when fanning out across organizations or regions.
199+
*
200+
* Limits parallel calls (e.g., `getProject()` per org, `resolveEventInOrg()` per org,
201+
* DSN key search per region) to prevent overwhelming the API for enterprise users
202+
* with many organizations. For typical users with 1-5 orgs this has no effect.
203+
*/
204+
export const ORG_FANOUT_CONCURRENCY = 5;
205+
197206
/**
198207
* Paginated API response with cursor metadata.
199208
* More pages exist when `nextCursor` is defined.

src/lib/api/projects.ts

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
retrieveAProject,
1313
} from "@sentry/api";
1414

15+
import pLimit from "p-limit";
16+
1517
import type {
1618
ProjectKey,
1719
Region,
@@ -29,6 +31,7 @@ import {
2931
apiRequestToRegion,
3032
getOrgSdkConfig,
3133
MAX_PAGINATION_PAGES,
34+
ORG_FANOUT_CONCURRENCY,
3235
type PaginatedResponse,
3336
unwrapPaginatedResult,
3437
unwrapResult,
@@ -207,23 +210,28 @@ export async function findProjectsBySlug(
207210
// the expensive getUserRegions() + listOrganizationsInRegion() fan-out.
208211
const orgs = await listOrganizations();
209212

210-
// Direct lookup in parallel — one API call per org instead of paginating all projects
213+
// Direct lookup with concurrency limit — one API call per org instead of
214+
// paginating all projects. p-limit prevents overwhelming the API for users
215+
// with many organizations.
216+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
211217
const searchResults = await Promise.all(
212218
orgs.map((org) =>
213-
withAuthGuard(async () => {
214-
const project = await getProject(org.slug, projectSlug);
215-
// The API accepts project_id_or_slug, so a numeric input could
216-
// resolve by ID instead of slug. When the input is all digits,
217-
// accept the match (the user passed a numeric project ID).
218-
// For non-numeric inputs, verify the slug actually matches to
219-
// avoid false positives from coincidental ID collisions.
220-
// Note: Sentry enforces that project slugs must start with a letter,
221-
// so an all-digits input can only ever be a numeric ID, never a slug.
222-
if (!isNumericId && project.slug !== projectSlug) {
223-
return null;
224-
}
225-
return { ...project, orgSlug: org.slug };
226-
})
219+
limit(() =>
220+
withAuthGuard(async () => {
221+
const project = await getProject(org.slug, projectSlug);
222+
// The API accepts project_id_or_slug, so a numeric input could
223+
// resolve by ID instead of slug. When the input is all digits,
224+
// accept the match (the user passed a numeric project ID).
225+
// For non-numeric inputs, verify the slug actually matches to
226+
// avoid false positives from coincidental ID collisions.
227+
// Note: Sentry enforces that project slugs must start with a letter,
228+
// so an all-digits input can only ever be a numeric ID, never a slug.
229+
if (!isNumericId && project.slug !== projectSlug) {
230+
return null;
231+
}
232+
return { ...project, orgSlug: org.slug };
233+
})
234+
)
227235
)
228236
);
229237

@@ -285,14 +293,17 @@ export async function findProjectsByPattern(
285293
): Promise<ProjectWithOrg[]> {
286294
const orgs = await listOrganizations();
287295

296+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
288297
const searchResults = await Promise.all(
289298
orgs.map((org) =>
290-
withAuthGuard(async () => {
291-
const projects = await listProjects(org.slug);
292-
return projects
293-
.filter((p) => matchesWordBoundary(pattern, p.slug))
294-
.map((p) => ({ ...p, orgSlug: org.slug }));
295-
})
299+
limit(() =>
300+
withAuthGuard(async () => {
301+
const projects = await listProjects(org.slug);
302+
return projects
303+
.filter((p) => matchesWordBoundary(pattern, p.slug))
304+
.map((p) => ({ ...p, orgSlug: org.slug }));
305+
})
306+
)
296307
)
297308
);
298309

@@ -328,19 +339,22 @@ export async function findProjectByDsnKey(
328339
return projects[0] ?? null;
329340
}
330341

342+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
331343
const results = await Promise.all(
332-
regions.map(async (region) => {
333-
try {
334-
const { data } = await apiRequestToRegion<SentryProject[]>(
335-
region.url,
336-
"/projects/",
337-
{ params: { query: `dsn:${publicKey}` } }
338-
);
339-
return data;
340-
} catch {
341-
return [];
342-
}
343-
})
344+
regions.map((region) =>
345+
limit(async () => {
346+
try {
347+
const { data } = await apiRequestToRegion<SentryProject[]>(
348+
region.url,
349+
"/projects/",
350+
{ params: { query: `dsn:${publicKey}` } }
351+
);
352+
return data;
353+
} catch {
354+
return [];
355+
}
356+
})
357+
)
344358
);
345359

346360
for (const projects of results) {

src/lib/region.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,19 @@ async function resolveOrgFromCache(
145145
* When users or AI agents extract org identifiers from DSN hosts
146146
* (e.g., `o1081365` from `o1081365.ingest.us.sentry.io`), the `o`-prefixed
147147
* form isn't recognized by the Sentry API. This function resolves the
148-
* identifier using the locally cached org list:
148+
* identifier using two strategies:
149149
*
150-
* 1. Check local cache (slug or DSN numeric ID) → return resolved slug
151-
* 2. If cache miss, refresh the org list from the API (one fan-out call)
152-
* and retry the local cache lookup
153-
* 3. Fall back to returning the original slug for downstream error handling
150+
* **Normal slugs** (e.g., `acme-corp`):
151+
* 1. Check local cache → return slug
152+
* 2. Try `resolveOrgRegion(orgSlug)` — single API call to fetch org details
153+
* and populate the region cache. If it succeeds, the slug is valid.
154+
* 3. Fall back to the original slug for downstream error handling
155+
*
156+
* **DSN numeric IDs** (e.g., `o1081365`):
157+
* 1. Check local cache for numeric ID → slug mapping
158+
* 2. Refresh the full org list from the API (fan-out to all regions)
159+
* to populate the numeric-ID-to-slug mapping
160+
* 3. Retry cache lookup, fall back to original slug
154161
*
155162
* @param orgSlug - Raw org identifier from user input
156163
* @returns The org slug to use for API calls (may be normalized)
@@ -162,10 +169,27 @@ export async function resolveEffectiveOrg(orgSlug: string): Promise<string> {
162169
return fromCache;
163170
}
164171

165-
// Cache is cold or identifier is unknown — refresh the org list from API.
172+
// Check if the identifier is a DSN-style numeric ID (e.g., `o1081365`).
173+
// These need the full org list fan-out because we must map numeric ID → slug.
174+
const numericId = stripDsnOrgPrefix(orgSlug);
175+
const isDsnNumericId = numericId !== orgSlug;
176+
177+
if (!isDsnNumericId) {
178+
// Normal slug: try a single resolveOrgRegion() call (1 API request)
179+
// instead of the heavy listOrganizationsUncached() fan-out (1+N requests).
180+
// If it succeeds, the slug is valid and the region is now cached.
181+
try {
182+
await resolveOrgRegion(orgSlug);
183+
return orgSlug;
184+
} catch {
185+
// Org not found or auth error — fall through to return the original
186+
// slug. The downstream API call will produce a relevant error.
187+
return orgSlug;
188+
}
189+
}
190+
191+
// DSN numeric ID: refresh the full org list to populate ID → slug mapping.
166192
// listOrganizationsUncached() populates org_regions with slug, region, org_id, and name.
167-
// Any error (auth failure, network error, etc.) falls back to the original
168-
// slug; the downstream API call will produce a relevant error if needed.
169193
try {
170194
const { listOrganizationsUncached } = await import("./api-client.js");
171195
await listOrganizationsUncached();

src/lib/sentry-client.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ import {
1515
getUserAgent,
1616
} from "./constants.js";
1717
import { getAuthToken, isEnvTokenActive, refreshToken } from "./db/auth.js";
18+
import { logger } from "./logger.js";
1819
import { getCachedResponse, storeCachedResponse } from "./response-cache.js";
1920
import { withHttpSpan } from "./telemetry.js";
2021

22+
const log = logger.withTag("http");
23+
2124
/** Request timeout in milliseconds */
2225
const REQUEST_TIMEOUT_MS = 30_000;
2326

@@ -316,7 +319,11 @@ async function fetchWithRetry(
316319
throw result.error;
317320
}
318321

319-
await Bun.sleep(backoffDelay(attempt));
322+
const delay = backoffDelay(attempt);
323+
log.debug(
324+
`${method} ${new URL(fullUrl).pathname} → retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms`
325+
);
326+
await Bun.sleep(delay);
320327
}
321328

322329
// Unreachable: the last attempt always returns 'done' or 'throw'
@@ -356,6 +363,7 @@ function createAuthenticatedFetch(): (
356363

357364
return withHttpSpan(method, urlPath, async () => {
358365
const fullUrl = extractFullUrl(input);
366+
const startTime = performance.now();
359367

360368
// Check cache before auth/retry for GET requests.
361369
// Uses current token (no refresh) so lookups are fast but Vary-correct.
@@ -365,10 +373,17 @@ function createAuthenticatedFetch(): (
365373
authHeaders(getAuthToken())
366374
);
367375
if (cached) {
376+
log.debug(
377+
`${method} ${urlPath}${cached.status} (cache hit, ${(performance.now() - startTime).toFixed(0)}ms)`
378+
);
368379
return cached;
369380
}
370381

371-
return await fetchWithRetry(input, init, method, fullUrl);
382+
const response = await fetchWithRetry(input, init, method, fullUrl);
383+
log.debug(
384+
`${method} ${urlPath}${response.status} (${(performance.now() - startTime).toFixed(0)}ms)`
385+
);
386+
return response;
372387
});
373388
};
374389
}

0 commit comments

Comments
 (0)