Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,10 @@ authenticated fetch in `src/lib/sentry-client.ts` offers every API response to
`src/lib/api/completion-seed.ts`, which stores entities from the `@sentry/api`
operations it lists (by URL template, type-checked against the SDK, and the
query parameters that pick what a response holds, such as the attribute
keys' `dataset`), so API functions never seed by hand. To complete a new
keys' `dataset`), so API functions never seed by hand. Attribute keys are
also fetched ahead of time by `src/lib/completion-warmup.ts`, which
`auth login` and `cli setup` call: the default org plus up to three
recently used orgs, all datasets in parallel, aborted after a second. To complete a new
entity type, add it to `CompletionEntityKind`, its operations in `completion-seed.ts` (one
response may seed several kinds), a tagged parser, and an `entityCompleter`
(target positionals) or `flagEntityCompleter` (flag values) entry in
Expand Down
59 changes: 44 additions & 15 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
completingParser,
parseText,
} from "../../lib/completion-target.js";
import {
WARMUP_TIMEOUT_MS,
warmAttributeCompletions,
} from "../../lib/completion-warmup.js";
import { normalizeUrl } from "../../lib/constants.js";
import {
getAccountIdToReplace,
Expand Down Expand Up @@ -719,10 +723,10 @@ export const loginCommand = buildCommand({
await removeAccount(replacedAccountId);
}

// Warm the org + region cache so the first real command is fast.
// Fire-and-forget — login already succeeded, caching is best-effort.
warmOrgCache();
return yield new CommandOutput(result);
yield new CommandOutput(result);
// Login already succeeded and is printed; warming is best-effort.
await warmCachesAfterLogin();
return;
}

// OAuth device flow (host scope recorded via completeOAuthFlow → setAuthToken)
Expand All @@ -735,8 +739,8 @@ export const loginCommand = buildCommand({

if (result) {
persistLoginUrlAsDefault(flags.url, effectiveHost);
warmOrgCache();
yield new CommandOutput(result);
await warmCachesAfterLogin();
return { hint: rcTokenHint(rcConfig, effectiveHost) };
}
// Error already displayed by runInteractiveLogin
Expand All @@ -745,18 +749,43 @@ export const loginCommand = buildCommand({
});

/**
* Pre-populate the org + region SQLite cache in the background.
* Warm the caches the next commands read, after a successful login.
*
* First the org + region cache, so the first real command doesn't pay the
* cold-start cost of `getUserRegions()` + fan-out to each region's org list
* endpoint (~800ms on a typical SaaS account). Then, with the account's orgs
* known, the attribute keys that field completion offers (see
* {@link warmAttributeCompletions}).
*
* Called after successful authentication so that the first real command
* doesn't pay the cold-start cost of `getUserRegions()` + fan-out to
* each region's org list endpoint (~800ms on a typical SaaS account).
* Login has already printed success, so each step gets
* {@link WARMUP_TIMEOUT_MS}. An org list that takes longer is left to finish
* in the background, as before this warm-up existed, and the attribute
* warm-up is skipped.
*
* Failures are silently ignored — the cache will be populated lazily
* on the next command that needs it.
* Never throws: each cache is populated lazily by the next command that
* needs it anyway.
*/
function warmOrgCache(): void {
// oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
listOrganizationsUncached().catch(() => {
// Best-effort: cache warming failure doesn't affect the login result
async function warmCachesAfterLogin(): Promise<void> {
const listed = listOrganizationsUncached().then(
() => true,
(error: unknown) => {
log.debug("Could not warm the org cache after login", error);
return false;
},
);
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<false>((resolve) => {
timer = setTimeout(() => {
log.debug("Org list is still loading; not warming attribute keys");
resolve(false);
}, WARMUP_TIMEOUT_MS);
});
try {
if (!(await Promise.race([listed, timedOut]))) {
return;
}
} finally {
clearTimeout(timer);
}
await warmAttributeCompletions();
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
16 changes: 15 additions & 1 deletion packages/cli/src/commands/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
type CompletionLocation,
installCompletions,
} from "../../lib/completions.js";
import {
SETUP_REFRESH_AFTER_MS,
warmAttributeCompletions,
} from "../../lib/completion-warmup.js";
import { CLI_VERSION } from "../../lib/constants.js";
import { getAuthConfig } from "../../lib/db/auth.js";
import {
Expand Down Expand Up @@ -622,7 +626,8 @@
"Sets up shell integration for the Sentry CLI:\n\n" +
"- Adds binary directory to PATH (if not already in PATH): the shell\n" +
" config file, or the user PATH on Windows\n" +
"- Installs shell completions (bash, zsh, fish)\n" +
"- Installs shell completions (bash, zsh, fish) and, when logged in,\n" +
" fetches the attribute keys that field completion offers\n" +
"- Installs agent skills for detected AI coding assistants\n" +
"- Records installation metadata for upgrades\n\n" +
"With --install, also handles binary placement from a temporary\n" +
Expand Down Expand Up @@ -809,6 +814,15 @@
version: CLI_VERSION,
});

// 6. Fetch attribute keys for field completion, the only reader of that
// cache, unless they were fetched recently. Best-effort, silent, and
// capped at about a second; skipped without a stored login.
if (!flags["no-completions"]) {
await warmAttributeCompletions({
refreshAfterMs: SETUP_REFRESH_AFTER_MS,
});
}

Check warning on line 824 in packages/cli/src/commands/cli/setup.ts

View check run for this annotation

@sentry/warden / warden: code-review

Attribute warm-up runs before setup auto-login and never again

On a fresh curl install, `warmAttributeCompletions` runs with no stored login (no-op), then setup auto-login via `runInteractiveLogin` succeeds without warming, so field completion stays empty. Move the warm-up after Authentication and list orgs first (as `warmCachesAfterLogin` does) so a just-created session is included.
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved

if (
!freshInstall ||
flags.method !== "curl" ||
Expand Down
23 changes: 21 additions & 2 deletions packages/cli/src/lib/api/completion-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,32 @@ function dsnEntities(body: unknown): CompletionEntity[] {
});
}

/** Whether Sentry defines an attribute key, rather than the user's data. */
function isSentryAttribute(attribute: unknown): boolean {
return (
stringField(objectField(attribute, "attributeSource"), "source_type") ===
"sentry"
);
}

/**
* Attribute keys of a trace item dataset, the form `--field` and `--query`
* take (`tags[cart.total,number]` for a custom number), described by their
* type, after their display name when it differs.
*
* Sentry-defined keys come first. The endpoint lists the keys an org stores
* by internal name, one attribute type after another, and appends a few
* curated aliases and contexts. In a large org, numeric and vendor keys
* would otherwise fill the per-org cap before `span.*` and the other
* conventions. Stored keys that map to a Sentry definition are tagged
* `sentry` too, so they move up with the aliases.
*/
function attributeEntities(body: unknown): CompletionEntity[] {
return items(body).flatMap((attribute) => {
const attributes = items(body);
return [
...attributes.filter(isSentryAttribute),
...attributes.filter((attribute) => !isSentryAttribute(attribute)),
].flatMap((attribute) => {
const value = stringField(attribute, "key");
const name = stringField(attribute, "name");
const type = stringField(attribute, "attributeType");
Expand All @@ -266,7 +285,7 @@ function attributeEntities(body: unknown): CompletionEntity[] {
}

/** Entity kind of the attribute keys of each trace item dataset. */
const ATTRIBUTE_KINDS = {
export const ATTRIBUTE_KINDS = {
spans: "span-attribute",
logs: "log-attribute",
tracemetrics: "metric-attribute",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/lib/api/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ export type ListTraceItemAttributesOptions = {
context?: boolean;
/** Pagination cursor from a previous page's `nextCursor` */
cursor?: string;
/** Aborts the request, including its retries */
signal?: AbortSignal;
};

/**
Expand Down Expand Up @@ -361,6 +363,7 @@ export async function listTraceItemAttributes(
...config,
path: { organization_id_or_slug: orgSlug },
query,
signal: options.signal,
});
return unwrapPaginatedResult<TraceItemAttributeDefinition[]>(
result,
Expand Down
152 changes: 152 additions & 0 deletions packages/cli/src/lib/completion-warmup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Warm the shell-completion cache with trace item attribute keys.
*
* Field completion (`explore --field`, `dashboard widget --query`,
* `alert metrics --aggregate`, …) reads the `span-attribute`,
* `log-attribute` and `metric-attribute` kinds of `completion_cache`. Only
* a `GET /organizations/{org}/trace-items/attributes/` response seeds them
* (see `api/completion-seed.ts`), so without a warm-up field completion
* offers nothing until the user runs `sentry attribute list`.
*
* `auth login` and `cli setup` (which `cli upgrade` runs) call
* {@link warmAttributeCompletions}. It requests each dataset of a few orgs in
* parallel and lets the fetch layer store the keys. It is best-effort: it
* never throws or prompts, logs only at debug level, and gives up after
* {@link WARMUP_TIMEOUT_MS} so the command never looks stuck.
*/

import { ATTRIBUTE_KINDS } from "./api/completion-seed.js";
import { listTraceItemAttributes } from "./api/traces.js";
import { hasStoredAuthCredentials } from "./db/auth.js";
import {
getLatestCompletionSeen,
getRecentlyUsedOrgs,
} from "./db/completion-cache.js";
import { getDefaultOrganization } from "./db/defaults.js";
import { getAllOrgRegions } from "./db/regions.js";
import { logger } from "./logger.js";

const log = logger.withTag("completion-warmup");

/** How long the warm-up may take before its requests are aborted. */
export const WARMUP_TIMEOUT_MS = 1000;

/** Orgs warmed besides the default org, picked by recent use. */
export const MAX_RECENT_ORGS = 3;

/** Orgs not used for this long are not warmed (matches the cache TTL). */
export const RECENT_ORG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;

/** `cli setup` re-warms a dataset of an org only when it is older than this. */
export const SETUP_REFRESH_AFTER_MS = 24 * 60 * 60 * 1000;

/** A trace item dataset whose attribute keys completion offers. */
type AttributeDataset = keyof typeof ATTRIBUTE_KINDS;

const ATTRIBUTE_DATASETS = Object.keys(ATTRIBUTE_KINDS) as AttributeDataset[];

/**
* The orgs to warm: the default org, then up to {@link MAX_RECENT_ORGS}
* orgs used within {@link RECENT_ORG_WINDOW_MS}, or the only org of an
* account that has one and no history.
*
* Only orgs whose region the active account has cached qualify. That keeps
* the warm-up from switching accounts or looking up a region over the
* network, a request it could not abort.
*/
export function pickWarmupOrgs(now = Date.now()): string[] {
const known = [...getAllOrgRegions().keys()];
const knownSet = new Set(known);
const picked: string[] = [];
const defaultOrg = getDefaultOrganization();
if (defaultOrg && knownSet.has(defaultOrg)) {
picked.push(defaultOrg);
}
const recent = getRecentlyUsedOrgs(
now - RECENT_ORG_WINDOW_MS,
Object.values(ATTRIBUTE_KINDS),
).filter((org) => knownSet.has(org) && org !== defaultOrg);
picked.push(...recent.slice(0, MAX_RECENT_ORGS));
if (picked.length === 0 && known.length === 1) {
picked.push(...known);
}
return picked;
}

/** Options for {@link warmAttributeCompletions}. */
type WarmAttributeCompletionsOptions = {
/**
* Skip a dataset of an org whose keys were stored more recently than
* this many milliseconds ago. Omit to warm every dataset.
*/
refreshAfterMs?: number;
};

/** One attributes request of the warm-up. */
type WarmupRequest = { org: string; dataset: AttributeDataset };

/** The datasets of `orgs` to request, leaving out fresh ones. */
function planRequests(
orgs: readonly string[],
refreshAfterMs: number | undefined,
now: number,
): WarmupRequest[] {
return orgs.flatMap((org) =>
ATTRIBUTE_DATASETS.filter((dataset) => {
if (refreshAfterMs === undefined) {
return true;
}
const latest = getLatestCompletionSeen(ATTRIBUTE_KINDS[dataset], org);
return latest === undefined || now - latest >= refreshAfterMs;
}).map((dataset) => ({ org, dataset })),
);
}

/**
* Fetch the attribute keys of each dataset of the orgs from
* {@link pickWarmupOrgs}, so field completion works before the user runs
* `sentry attribute list`. All requests run in parallel and are aborted
* after {@link WARMUP_TIMEOUT_MS}. Does nothing without a stored login, so
* an environment token (as in CI) never triggers it.
*
* Never throws. A missing feature, a rejected token, being offline or a
* timeout leaves the cache as it was; failures are only logged at debug
* level.
*/
export async function warmAttributeCompletions(
options: WarmAttributeCompletionsOptions = {},
): Promise<void> {
let requests: WarmupRequest[];
try {
if (!hasStoredAuthCredentials()) {
return;
}
const now = Date.now();
requests = planRequests(pickWarmupOrgs(now), options.refreshAfterMs, now);
} catch (error) {
log.debug("Not warming attribute completions", error);
return;
}
if (requests.length === 0) {
return;
}

const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new Error("Attribute completion warm-up timed out"));
}, WARMUP_TIMEOUT_MS);
try {
await Promise.all(
requests.map(({ org, dataset }) =>
listTraceItemAttributes(org, {
dataset,
signal: controller.signal,
}).catch((error: unknown) => {
log.debug(`Not warming ${dataset} attributes of '${org}'`, error);
}),
),
);
} finally {
clearTimeout(timer);
}
}
Loading
Loading