Skip to content

Commit f66785d

Browse files
MathurAditya724BYK
andauthored
perf(init): run org detection in background during preamble (#443)
## Summary - Starts DSN scanning (`resolveOrg`) **concurrently** with the `sentry init` preamble phase (experimental confirm + git status check), so the result is ready when the wizard later needs the org for project creation. - On a cold start (no DSN cache), this eliminates **2-5 seconds** of blocking latency that previously occurred deep inside the wizard's suspend/resume loop at `createSentryProject` → `resolveOrgSlug`. - When the org is already explicit (e.g., `sentry init acme/my-app`), background detection is skipped entirely. - `listOrganizations()` is **not** prefetched because it already has its own SQLite cache layer (PR #446) populated after `sentry login` (PR #490), so it returns instantly on warm starts. ## How it works ``` Before: init → preamble (user confirms) → wizard starts → ... → create-sentry-project → resolveOrgSlug → resolveOrg (DSN scan, 2-5s) → listOrganizations (300ms) After: init → [bg: resolveOrg (DSN scan)] → preamble (user confirms) → wizard starts → ... → create-sentry-project → resolveOrgSlug → await bg result (already settled, ~0ms) → listOrganizations → SQLite cache hit (~0ms) ``` ## Changes | File | Change | |------|--------| | `src/lib/init/prefetch.ts` | New warm/consume module for background DSN scanning | | `src/commands/init.ts` | Fire `warmOrgDetection()` before `runWizard()` when org is unknown | | `src/lib/init/local-ops.ts` | `resolveOrgSlug()` uses prefetch-aware `resolveOrgPrefetched()` for DSN scanning; uses `listOrganizations()` directly (SQLite-cached) | | `test/commands/init.test.ts` | 7 new tests verifying background detection is passed/omitted correctly | ## Risk Low. Background promise is `.catch()`-wrapped — if it fails, the existing synchronous path runs as a fallback. No behavioral change for the user; only latency improvement. --------- Co-authored-by: Burak Yigit Kaya <byk@sentry.io>
1 parent 1b9bb95 commit f66785d

File tree

4 files changed

+144
-5
lines changed

4 files changed

+144
-5
lines changed

src/commands/init.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { SentryContext } from "../context.js";
2222
import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js";
2323
import { buildCommand } from "../lib/command.js";
2424
import { ContextError } from "../lib/errors.js";
25+
import { warmOrgDetection } from "../lib/init/prefetch.js";
2526
import { runWizard } from "../lib/init/wizard-runner.js";
2627
import { validateResourceId } from "../lib/input-validation.js";
2728
import { logger } from "../lib/logger.js";
@@ -229,7 +230,15 @@ export const initCommand = buildCommand<
229230
const { org: explicitOrg, project: explicitProject } =
230231
await resolveTarget(targetArg);
231232

232-
// 5. Run the wizard
233+
// 5. Start background org detection when org is not yet known.
234+
// The prefetch runs concurrently with the preamble, the wizard startup,
235+
// and all early suspend/resume rounds — by the time the wizard needs the
236+
// org (inside createSentryProject), the result is already cached.
237+
if (!explicitOrg) {
238+
warmOrgDetection(targetDir);
239+
}
240+
241+
// 6. Run the wizard
233242
await runWizard({
234243
directory: targetDir,
235244
yes: flags.yes,

src/lib/init/local-ops.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
tryGetPrimaryDsn,
1717
} from "../api-client.js";
1818
import { ApiError } from "../errors.js";
19-
import { resolveOrg } from "../resolve-target.js";
2019
import { resolveOrCreateTeam } from "../resolve-team.js";
2120
import { buildProjectUrl } from "../sentry-urls.js";
2221
import { slugify } from "../utils.js";
@@ -25,6 +24,7 @@ import {
2524
MAX_FILE_BYTES,
2625
MAX_OUTPUT_BYTES,
2726
} from "./constants.js";
27+
import { resolveOrgPrefetched } from "./prefetch.js";
2828
import type {
2929
ApplyPatchsetPayload,
3030
CreateSentryProjectPayload,
@@ -658,18 +658,27 @@ function applyPatchset(
658658
/**
659659
* Resolve the org slug from local config, env vars, or by listing the user's
660660
* organizations from the API as a fallback.
661-
* Returns the slug on success, or a LocalOpResult error to return early.
661+
*
662+
* DSN scanning uses the prefetch-aware helper from `./prefetch.ts` — if
663+
* {@link warmOrgDetection} was called earlier (by `init.ts`), the result is
664+
* already cached and returns near-instantly.
665+
*
666+
* `listOrganizations()` uses SQLite caching for near-instant warm lookups
667+
* (populated after `sentry login` or the first API call), so it does not
668+
* need background prefetching.
669+
*
670+
* @returns The org slug on success, or a {@link LocalOpResult} error to return early.
662671
*/
663672
async function resolveOrgSlug(
664673
cwd: string,
665674
yes: boolean
666675
): Promise<string | LocalOpResult> {
667-
const resolved = await resolveOrg({ cwd });
676+
const resolved = await resolveOrgPrefetched(cwd);
668677
if (resolved) {
669678
return resolved.org;
670679
}
671680

672-
// Fallback: list user's organizations from API
681+
// Fallback: list user's organizations (SQLite-cached after login/first call)
673682
const orgs = await listOrganizations();
674683
if (orgs.length === 0) {
675684
return {

src/lib/init/prefetch.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Background Org Detection Prefetch
3+
*
4+
* Provides a warm/consume pattern for org resolution during `sentry init`.
5+
* Call {@link warmOrgDetection} early (before the preamble) to start DSN
6+
* scanning in the background. Later, call {@link resolveOrgPrefetched} —
7+
* it returns the cached result instantly if the background work has
8+
* finished, or falls back to a live call if it hasn't been warmed.
9+
*
10+
* `listOrganizations()` does NOT need prefetching because it has its own
11+
* SQLite cache layer (PR #446). After `sentry login`, the org cache is
12+
* pre-populated (PR #490), so subsequent calls return from cache instantly
13+
* without any HTTP requests. Only `resolveOrg()` (DSN scanning) benefits
14+
* from background prefetching since it performs filesystem I/O.
15+
*
16+
* This keeps the hot path (inside the wizard's `createSentryProject`)
17+
* free of explicit promise-threading — callers just swap in the
18+
* prefetch-aware functions.
19+
*/
20+
21+
import type { ResolvedOrg } from "../resolve-target.js";
22+
import { resolveOrg } from "../resolve-target.js";
23+
24+
type OrgResult = ResolvedOrg | null;
25+
26+
let orgPromise: Promise<OrgResult> | undefined;
27+
let warmedCwd: string | undefined;
28+
29+
/**
30+
* Kick off background DSN scanning + env var / config checks.
31+
*
32+
* Safe to call multiple times — subsequent calls are no-ops.
33+
* Errors are silently swallowed so the foreground path can retry.
34+
*/
35+
export function warmOrgDetection(cwd: string): void {
36+
if (!orgPromise) {
37+
warmedCwd = cwd;
38+
orgPromise = resolveOrg({ cwd }).catch(() => null);
39+
}
40+
}
41+
42+
/**
43+
* Resolve the org, using the prefetched result if available.
44+
*
45+
* Returns the cached background result only when `cwd` matches the
46+
* directory that was passed to {@link warmOrgDetection}. If `cwd`
47+
* differs (or warming was never called), falls back to a live
48+
* `resolveOrg()` call so callers always get results for the correct
49+
* working directory.
50+
*/
51+
export function resolveOrgPrefetched(cwd: string): Promise<OrgResult> {
52+
if (orgPromise && cwd === warmedCwd) {
53+
return orgPromise;
54+
}
55+
return resolveOrg({ cwd }).catch(() => null);
56+
}
57+
58+
/**
59+
* Reset prefetch state. Used by tests to prevent cross-test leakage.
60+
*/
61+
export function resetPrefetch(): void {
62+
orgPromise = undefined;
63+
warmedCwd = undefined;
64+
}

test/commands/init.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import path from "node:path";
1111
import { initCommand } from "../../src/commands/init.js";
1212
import { ContextError } from "../../src/lib/errors.js";
1313
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
14+
import * as prefetchNs from "../../src/lib/init/prefetch.js";
15+
import { resetPrefetch } from "../../src/lib/init/prefetch.js";
16+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
1417
import * as wizardRunner from "../../src/lib/init/wizard-runner.js";
1518
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
1619
import * as resolveTarget from "../../src/lib/resolve-target.js";
@@ -19,6 +22,7 @@ import * as resolveTarget from "../../src/lib/resolve-target.js";
1922
let capturedArgs: Record<string, unknown> | undefined;
2023
let runWizardSpy: ReturnType<typeof spyOn>;
2124
let resolveProjectSpy: ReturnType<typeof spyOn>;
25+
let warmSpy: ReturnType<typeof spyOn>;
2226

2327
const func = (await initCommand.loader()) as unknown as (
2428
this: {
@@ -45,6 +49,7 @@ const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const;
4549

4650
beforeEach(() => {
4751
capturedArgs = undefined;
52+
resetPrefetch();
4853
runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation(
4954
(args: Record<string, unknown>) => {
5055
capturedArgs = args;
@@ -59,11 +64,19 @@ beforeEach(() => {
5964
org: "resolved-org",
6065
project: slug,
6166
}));
67+
// Spy on warmOrgDetection to verify it's called/skipped appropriately.
68+
// The mock prevents real DSN scans and API calls from the background.
69+
warmSpy = spyOn(prefetchNs, "warmOrgDetection").mockImplementation(
70+
// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op mock
71+
() => {}
72+
);
6273
});
6374

6475
afterEach(() => {
6576
runWizardSpy.mockRestore();
6677
resolveProjectSpy.mockRestore();
78+
warmSpy.mockRestore();
79+
resetPrefetch();
6780
});
6881

6982
describe("init command func", () => {
@@ -324,4 +337,48 @@ describe("init command func", () => {
324337
expect(capturedArgs?.team).toBe("backend");
325338
});
326339
});
340+
341+
// ── Background org detection ──────────────────────────────────────────
342+
343+
describe("background org detection", () => {
344+
test("warms prefetch when org is not explicit", async () => {
345+
const ctx = makeContext();
346+
await func.call(ctx, DEFAULT_FLAGS);
347+
expect(warmSpy).toHaveBeenCalledTimes(1);
348+
expect(warmSpy).toHaveBeenCalledWith("/projects/app");
349+
});
350+
351+
test("skips prefetch when org is explicit", async () => {
352+
const ctx = makeContext();
353+
await func.call(ctx, DEFAULT_FLAGS, "acme/my-app");
354+
expect(warmSpy).not.toHaveBeenCalled();
355+
});
356+
357+
test("skips prefetch when org-only is explicit", async () => {
358+
const ctx = makeContext();
359+
await func.call(ctx, DEFAULT_FLAGS, "acme/");
360+
expect(warmSpy).not.toHaveBeenCalled();
361+
});
362+
363+
test("skips prefetch for bare slug (project-search resolves org)", async () => {
364+
const ctx = makeContext();
365+
await func.call(ctx, DEFAULT_FLAGS, "my-app");
366+
// resolveProjectBySlug returns { org: "resolved-org" } → org is known
367+
expect(warmSpy).not.toHaveBeenCalled();
368+
});
369+
370+
test("warms prefetch for path-only arg", async () => {
371+
const ctx = makeContext();
372+
await func.call(ctx, DEFAULT_FLAGS, "./subdir");
373+
expect(warmSpy).toHaveBeenCalledTimes(1);
374+
});
375+
376+
test("warms prefetch with resolved directory path", async () => {
377+
const ctx = makeContext("/projects/app");
378+
await func.call(ctx, DEFAULT_FLAGS, "./subdir");
379+
expect(warmSpy).toHaveBeenCalledWith(
380+
path.resolve("/projects/app", "./subdir")
381+
);
382+
});
383+
});
327384
});

0 commit comments

Comments
 (0)