Skip to content
Draft
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
20 changes: 14 additions & 6 deletions apps/cli-docs/src/content/docs/library-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,22 @@ try {

## Environment Isolation

The library never mutates `process.env`. Each invocation creates an isolated
copy of the environment. This means:
The library never mutates `process.env`. Each invocation captures the environment
when called and owns its in-memory auth and routing state. Pending requests keep that
context even if the command fails before they finish. This means:

- Your application's env vars are never touched
- Multiple sequential calls are safe
- Auth tokens passed via `token` don't leak to subsequent calls

Stored login credentials remain shared by calls using the same config directory.
Each HTTP response is cached under the identity selected for that request, even if
the stored session changes while it is in flight.

:::note
Concurrent calls are not supported in the current version.
Calls should be sequential (awaited one at a time).
Overlapping invocations reject with `SentryError`. Calls must be sequential
(awaited one at a time), including calls on different SDK instances.
:::

## Comparison with Subprocess
Expand Down Expand Up @@ -244,7 +250,7 @@ for await (const snapshot of sdk.run("dashboard", "view", "123", "--refresh", "3

// Stop streaming by breaking out of the loop
for await (const log of sdk.log.list({ follow: "2" })) {
if (someCondition) break; // Streaming stops immediately
if (someCondition) break; // Signals cancellation and waits for cleanup
}
```

Expand All @@ -263,10 +269,12 @@ setTimeout(() => controller.abort(), 30_000);
for await (const log of sdk.log.list({ follow: "5" })) {
console.log(log);
}
// Loop exits when signal fires
// Loop exits after cancellation and cleanup finish
```

:::note
Concurrent streaming calls are not supported. Each streaming invocation
uses an isolated environment — only one can be active at a time.
uses an isolated environment — only one SDK invocation can be active at a time.
Finishing iteration or exiting with `break` waits for the producer to stop and
finish cleanup. Await that cleanup before starting another SDK call.
:::
6 changes: 4 additions & 2 deletions packages/cli/src/commands/dashboard/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,10 @@ export const viewCommand = buildCommand({
// Library mode: honor external abort signal (e.g., consumer break)
const externalSignal = (this.process as { abortSignal?: AbortSignal })
?.abortSignal;
if (externalSignal) {
externalSignal.addEventListener("abort", stop, { once: true });
if (externalSignal?.aborted) {
stop();
}
externalSignal?.addEventListener("abort", stop, { once: true });

let isFirstRender = true;

Expand Down Expand Up @@ -326,6 +327,7 @@ export const viewCommand = buildCommand({
}
} finally {
process.removeListener("SIGINT", stop);
externalSignal?.removeEventListener("abort", stop);
}
return;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/log/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,11 +382,15 @@ async function* generateFollowLogs<T extends LogLike>(
process.once("SIGINT", stop);

// Library mode: honor external abort signal (e.g., consumer break)
if (config.abortSignal) {
config.abortSignal.addEventListener("abort", stop, { once: true });
if (config.abortSignal?.aborted) {
stop();
}
config.abortSignal?.addEventListener("abort", stop, { once: true });

try {
if (controller.signal.aborted) {
return;
}
// Initial fetch
const initialLogs = await config.fetch("1m");
if (initialLogs.length > 0) {
Expand All @@ -410,6 +414,7 @@ async function* generateFollowLogs<T extends LogLike>(
}
} finally {
process.removeListener("SIGINT", stop);
config.abortSignal?.removeEventListener("abort", stop);
}
}

Expand Down
23 changes: 17 additions & 6 deletions packages/cli/src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
warnIfSaasWithEnvCa,
} from "../custom-ca.js";
import { applyCustomHeaders } from "../custom-headers.js";
import { getIdentityFingerprint } from "../db/auth.js";
import { ApiError, ValidationError } from "../errors.js";
import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
import { invalidateCachedResponsesMatching } from "../response-cache.js";
import { getApiBaseUrl } from "../sentry-client.js";
Expand Down Expand Up @@ -664,13 +666,22 @@ export async function mergeIssues(
// stale data.
const apiBase = getApiBaseUrl().replace(TRAILING_SLASH_RE, "");
const affectedIds = data.merge.children.toSpliced(0, 0, data.merge.parent);
await Promise.all(
affectedIds.map((id) =>
invalidateCachedResponsesMatching(
`${apiBase}/api/0/issues/${encodeURIComponent(id)}/`,
try {
const identity = getIdentityFingerprint();
await Promise.all(
affectedIds.map((id) =>
invalidateCachedResponsesMatching(
`${apiBase}/api/0/issues/${encodeURIComponent(id)}/`,
identity,
),
),
),
);
);
} catch (error) {
// Cache maintenance must not turn a completed merge into a failure.
logger
.withTag("issues")
.debug("Merged issue cache invalidation failed", error);
}
return data.merge;
} catch (error) {
// The bulk-mutate endpoint returns 204 when no matching issues are
Expand Down
21 changes: 12 additions & 9 deletions packages/cli/src/lib/async-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export type AsyncChannelOptions = {
/**
* Called when the consumer calls `return()` on the iterator
* (e.g., `break` in a `for await...of` loop). Use this to signal
* the producer to stop.
* the producer to stop. Iterator return waits for asynchronous cleanup.
*/
onReturn?: () => void;
onReturn?: () => void | Promise<void>;
};

/**
Expand Down Expand Up @@ -111,16 +111,19 @@ export function createAsyncChannel<T>(

const iterator: AsyncIterator<T> = {
next,
return(): Promise<IteratorResult<T>> {
async return(): Promise<IteratorResult<T>> {
closed = true;
buffer.length = 0;
if (pending) {
const p = pending;
pending = undefined;
p.resolve({ value: undefined as T, done: true });
try {
await options?.onReturn?.();
} finally {
if (pending) {
const p = pending;
pending = undefined;
p.resolve({ value: undefined as T, done: true });
}
}
options?.onReturn?.();
return Promise.resolve({ value: undefined as T, done: true });
return { value: undefined as T, done: true };
},
};

Expand Down
68 changes: 32 additions & 36 deletions packages/cli/src/lib/custom-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*
* The library API (`createSentrySDK({ headers })`) bypasses the string format
* and sets the structured headers for the current invocation via
* {@link setCustomHeadersOverride}, validated with the same rules.
* {@link withCustomHeadersOverride}, validated with the same rules.
*
* @example
* ```bash
Expand All @@ -28,7 +28,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { getConfiguredSentryUrl } from "./constants.js";
import { getDefaultHeaders } from "./db/defaults.js";
import { getEnv } from "./env.js";
import { createInvocationState, getEnv } from "./env.js";
import { ConfigError } from "./errors.js";
import { logger } from "./logger.js";
import { isSentrySaasUrl } from "./sentry-urls.js";
Expand Down Expand Up @@ -66,23 +66,14 @@ const HEADER_SEPARATOR_RE = /[;\n]/;
/** Strips trailing carriage return from a line (Windows line endings). */
const TRAILING_CR_RE = /\r$/;

/** Cached parsed headers (from env var or defaults). `undefined` = not yet parsed. */
let cachedHeaders: readonly [string, string][] | undefined;

/** Tracks the raw source string that produced `cachedHeaders`, for invalidation. */
let cachedRawSource: string | undefined;

/** Whether the SaaS warning has already been logged this session. */
let saasWarningLogged = false;

/** Whether the untrusted-destination warning has already been logged. */
let untrustedDestinationWarningLogged = false;

/**
* Structured headers set by the library API for the current invocation.
* `undefined` = not set, fall through to the env var / SQLite defaults.
*/
let overrideHeaders: readonly [string, string][] | undefined;
type HeaderState = {
cachedHeaders?: readonly [string, string][];
cachedRawSource?: string;
saasWarningLogged?: boolean;
untrustedDestinationWarningLogged?: boolean;
overrideHeaders?: readonly [string, string][];
};
const getHeaderState = createInvocationState<HeaderState>(() => ({}));
const scopedHeadersOverride = new AsyncLocalStorage<{
value: readonly [string, string][] | undefined;
}>();
Expand Down Expand Up @@ -162,7 +153,8 @@ export function parseCustomHeaders(raw: string): readonly [string, string][] {
* over an inherited env var. The self-hosted guard and the request-origin
* trust check in {@link applyCustomHeaders} still apply.
*
* Pass `undefined` to clear. The SDK invoke layer calls this next to `setEnv`.
* Pass `undefined` to inherit env/SQLite headers. The SDK validates overrides
* inside its invocation context.
*
* @param headers - Header name/value map from `SentryOptions.headers`
* @throws {ConfigError} On invalid or reserved header names
Expand Down Expand Up @@ -191,7 +183,7 @@ function validateCustomHeadersOverride(
export function setCustomHeadersOverride(
headers: Record<string, string> | undefined,
): void {
overrideHeaders = validateCustomHeadersOverride(headers);
getHeaderState().overrideHeaders = validateCustomHeadersOverride(headers);
}

export function withCustomHeadersOverride<T>(
Expand Down Expand Up @@ -240,11 +232,12 @@ function resolveRawHeaders(): string | undefined {

/** Self-hosted guard: warn once and report false on SaaS. */
function passesSelfHostedGuard(): boolean {
const state = getHeaderState();
if (isSelfHosted()) {
return true;
}
if (!saasWarningLogged) {
saasWarningLogged = true;
if (!state.saasWarningLogged) {
state.saasWarningLogged = true;
log.warn(
"Custom headers are set but no self-hosted Sentry instance is configured. Headers will be ignored.",
);
Expand All @@ -263,8 +256,9 @@ function passesSelfHostedGuard(): boolean {
* because `SENTRY_HOST` can be set dynamically by URL argument parsing.
*/
export function getCustomHeaders(): readonly [string, string][] {
const state = getHeaderState();
const scoped = scopedHeadersOverride.getStore();
const effective = scoped ? scoped.value : overrideHeaders;
const effective = scoped ? scoped.value : state.overrideHeaders;
if (effective !== undefined) {
return effective.length > 0 && passesSelfHostedGuard() ? effective : [];
}
Expand All @@ -279,13 +273,13 @@ export function getCustomHeaders(): readonly [string, string][] {
}

// Return cached result if the raw source hasn't changed
if (cachedHeaders !== undefined && cachedRawSource === raw) {
return cachedHeaders;
if (state.cachedHeaders !== undefined && state.cachedRawSource === raw) {
return state.cachedHeaders;
}

cachedHeaders = parseCustomHeaders(raw);
cachedRawSource = raw;
return cachedHeaders;
state.cachedHeaders = parseCustomHeaders(raw);
state.cachedRawSource = raw;
return state.cachedHeaders;
}

/**
Expand All @@ -308,14 +302,15 @@ export function applyCustomHeaders(
requestUrl: string | URL | Request,
isTrusted = isRequestOriginTrustedForCustomHeaders(requestUrl),
): void {
const state = getHeaderState();
const customHeaders = getCustomHeaders();
if (customHeaders.length === 0) {
return;
}

if (!isTrusted) {
if (!untrustedDestinationWarningLogged) {
untrustedDestinationWarningLogged = true;
if (!state.untrustedDestinationWarningLogged) {
state.untrustedDestinationWarningLogged = true;
log.warn(
"Skipping custom headers for request to untrusted host. " +
"If this is legitimate, run 'sentry auth login --url <url>' against the intended instance.",
Expand All @@ -334,9 +329,10 @@ export function applyCustomHeaders(
* @internal
*/
export function _resetCustomHeadersCache(): void {
cachedHeaders = undefined;
cachedRawSource = undefined;
overrideHeaders = undefined;
saasWarningLogged = false;
untrustedDestinationWarningLogged = false;
const state = getHeaderState();
state.cachedHeaders = undefined;
state.cachedRawSource = undefined;
state.overrideHeaders = undefined;
state.saasWarningLogged = false;
state.untrustedDestinationWarningLogged = false;
}
Loading
Loading