Skip to content

Commit addb36a

Browse files
committed
perf: HTTP latency optimizations — diagnostics, cache warming, concurrency limits
## 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 a26e3f4 commit addb36a

File tree

6 files changed

+144
-46
lines changed

6 files changed

+144
-46
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: 6 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,
@@ -238,12 +239,16 @@ async function resolveProjectSearch(
238239
// 3. Fast path: try resolving the short ID directly across all orgs.
239240
// The shortid endpoint validates both project existence and issue existence
240241
// in a single call, eliminating the separate getProject() round-trip.
242+
// Concurrency-limited to avoid overwhelming the API for enterprise users.
241243
const fullShortId = expandToFullShortId(suffix, projectSlug);
242244
const orgs = await listOrganizations();
243245

246+
const limit = pLimit(5);
244247
const results = await Promise.all(
245248
orgs.map((org) =>
246-
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
249+
limit(() =>
250+
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
251+
)
247252
)
248253
);
249254

src/lib/api/events.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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

@@ -17,6 +18,11 @@ import { ApiError, AuthError } from "../errors.js";
1718
import { getOrgSdkConfig, unwrapResult } from "./infrastructure.js";
1819
import { listOrganizations } from "./organizations.js";
1920

21+
/**
22+
* Maximum concurrent API requests when searching events across organizations.
23+
*/
24+
const ORG_FANOUT_CONCURRENCY = 5;
25+
2026
/**
2127
* Get the latest event for an issue.
2228
* Uses region-aware routing for multi-region support.
@@ -124,8 +130,9 @@ export async function findEventAcrossOrgs(
124130
): Promise<ResolvedEvent | null> {
125131
const orgs = await listOrganizations();
126132

133+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
127134
const results = await Promise.allSettled(
128-
orgs.map((org) => resolveEventInOrg(org.slug, eventId))
135+
orgs.map((org) => limit(() => resolveEventInOrg(org.slug, eventId)))
129136
);
130137

131138
// First pass: return the first successful match

src/lib/api/projects.ts

Lines changed: 54 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,
@@ -35,6 +37,14 @@ import {
3537
} from "./infrastructure.js";
3638
import { getUserRegions, listOrganizations } from "./organizations.js";
3739

40+
/**
41+
* Maximum concurrent API requests when fanning out across organizations.
42+
*
43+
* Limits parallel `getProject()` / `listProjects()` / DSN search calls
44+
* to prevent overwhelming the API for enterprise users with many orgs.
45+
*/
46+
const ORG_FANOUT_CONCURRENCY = 5;
47+
3848
/**
3949
* List all projects in an organization.
4050
* Automatically paginates through all API pages to return the complete list.
@@ -207,23 +217,28 @@ export async function findProjectsBySlug(
207217
// the expensive getUserRegions() + listOrganizationsInRegion() fan-out.
208218
const orgs = await listOrganizations();
209219

210-
// Direct lookup in parallel — one API call per org instead of paginating all projects
220+
// Direct lookup with concurrency limit — one API call per org instead of
221+
// paginating all projects. p-limit prevents overwhelming the API for users
222+
// with many organizations.
223+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
211224
const searchResults = await Promise.all(
212225
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-
})
226+
limit(() =>
227+
withAuthGuard(async () => {
228+
const project = await getProject(org.slug, projectSlug);
229+
// The API accepts project_id_or_slug, so a numeric input could
230+
// resolve by ID instead of slug. When the input is all digits,
231+
// accept the match (the user passed a numeric project ID).
232+
// For non-numeric inputs, verify the slug actually matches to
233+
// avoid false positives from coincidental ID collisions.
234+
// Note: Sentry enforces that project slugs must start with a letter,
235+
// so an all-digits input can only ever be a numeric ID, never a slug.
236+
if (!isNumericId && project.slug !== projectSlug) {
237+
return null;
238+
}
239+
return { ...project, orgSlug: org.slug };
240+
})
241+
)
227242
)
228243
);
229244

@@ -285,14 +300,17 @@ export async function findProjectsByPattern(
285300
): Promise<ProjectWithOrg[]> {
286301
const orgs = await listOrganizations();
287302

303+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
288304
const searchResults = await Promise.all(
289305
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-
})
306+
limit(() =>
307+
withAuthGuard(async () => {
308+
const projects = await listProjects(org.slug);
309+
return projects
310+
.filter((p) => matchesWordBoundary(pattern, p.slug))
311+
.map((p) => ({ ...p, orgSlug: org.slug }));
312+
})
313+
)
296314
)
297315
);
298316

@@ -328,19 +346,22 @@ export async function findProjectByDsnKey(
328346
return projects[0] ?? null;
329347
}
330348

349+
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
331350
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-
})
351+
regions.map((region) =>
352+
limit(async () => {
353+
try {
354+
const { data } = await apiRequestToRegion<SentryProject[]>(
355+
region.url,
356+
"/projects/",
357+
{ params: { query: `dsn:${publicKey}` } }
358+
);
359+
return data;
360+
} catch {
361+
return [];
362+
}
363+
})
364+
)
344365
);
345366

346367
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)