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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai)
## Toolchain

Use pnpm and Node.js (22.13 or later); do not add Bun-only APIs or tooling. Run `pnpm build` before `pnpm typecheck` and `pnpm test`: both read the generated `src/` and `dist/` output.

## URLs

Parse URLs with `URL` (`new URL`, `URL.canParse`), never with regular expressions. `test/url-parsing.test.ts` fails on any regex that matches a scheme separator (`://`).
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Each `createSentryClient` call creates an independent transport configuration. T

Supply the base URL and authentication appropriate for your deployment. See Sentry's [API authentication documentation](https://docs.sentry.io/api/auth/).

The base URL is the Sentry origin, such as `https://sentry.io` or a self-hosted `https://sentry.example.com`. Operation paths are relative to the API base path, which the client adds after the origin. It comes from the server URL in Sentry's OpenAPI spec and is exported as `API_BASE_PATH` (`"/api/0"`) from `@sentry/api`, `@sentry/api/client`, and `@sentry/api/operations`. Raw requests take the same relative paths, such as `sentry.client.get({ url: "/organizations/" })`.

### Standalone operations

Individual operation functions remain available as a modular alternative and accept transport configuration directly:
Expand Down Expand Up @@ -118,7 +120,7 @@ const logOperations = operations.filter(operation =>
console.log(logOperations.map(({ functionName, method, path }) => ({ functionName, method, path })));
```

Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, explicit deprecation flag, `experimental` flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters.
Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path (relative to `API_BASE_PATH`), summary, description, tags, path parameter names, query metadata, explicit deprecation flag, `experimental` flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters.

`experimental` is `true` when Sentry marks the operation with the `x-sentry-experimental` OpenAPI extension, which it sets for endpoints under active development that may change. Their SDK docs also start with an **Experimental:** note. To find or reject experimental operations, for example in a lint rule or build check:

Expand Down Expand Up @@ -221,7 +223,7 @@ validation, body serialization, and request interceptors.
const sentry = createSentryClient({ baseUrl: "https://example.test/sentry" });
const request = await sentry.client.prepareRequest({
method: "GET",
url: "/api/0/organizations/example/events/?field=id&field=title&cursor=old#view",
url: "/organizations/example/events/?field=id&field=title&cursor=old#view",
query: { cursor: "opaque-next", per_page: 50 },
});
// Both field values remain; the old cursor is replaced and the fragment removed.
Expand Down Expand Up @@ -607,7 +609,7 @@ Generic helpers delegate cancellation to your fetcher. Pass its signal through t
`@sentry/api/client` is the request layer the Sentry CLI and MCP servers share, built on the generated operations. It uses only web-standard APIs (`fetch`, `Request`, `Headers`, `URL`, `AbortSignal`, `crypto.subtle`), so it runs in Node, Cloudflare Workers, and browsers. Hosts inject what differs between them:

- **Transport.** `createApiTransport` sets `Authorization` from a `CredentialProvider`, refreshes the credential once after a 401, times out each attempt, retries through the shared retry policy (429 for any method; 500, 502, 503, 504, timeouts, and network errors only for GET, HEAD, OPTIONS, or writes marked idempotent), follows redirects only to trusted origins, and serves and invalidates cached GETs. `CredentialProvider.current` receives the request URL and the organization and project its path targets (`parseApiPathScope`), so a host with several logins can pick the one that can reach that organization. The host passes credentials, a `TransportCache`, the request-scoped `fresh` hook, a logger, a span hook, and its error classes.
- **Request core.** `createApiClientCore` sends `<origin>/api/0<path>` through the transport and throws the status's `ApiError` subclass for an error response. `sdkClient` gives a generated operation a client that sends through the same path.
- **Request core.** `createApiClientCore` sends `<origin><API_BASE_PATH><path>` through the transport and throws the status's `ApiError` subclass for an error response. `sdkClient` gives a generated operation a client that sends through the same path.
- **Response cache.** The cache policy (`lookupResponse`, `storeResponse`, tiers, identity-scoped keys, invalidation) works on any `CacheStore` and `BodyCodec`. `createMemoryStore` is a byte-budgeted in-memory store. Compressing codecs need host APIs such as `node:zlib`, so hosts supply them.
- **Validation.** Endpoint wrappers validate responses with any [Standard Schema](https://standardschema.dev) validator the host passes in, such as the `@sentry/api/zod` or `@sentry/api/valibot` schemas. The client depends on neither.
- **Pagination.** `paginate` and `fetchAllPages` walk list endpoints on top of `paginateUpTo` and `consumePage`. Their cursors are Sentry cursors that also record rows already returned from a page, so a resumed walk neither repeats nor skips rows.
Expand Down
10 changes: 8 additions & 2 deletions build.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { build } from "esbuild";
import { cpSync, appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { emitDeclarations } from "./lib/emit-declarations.mjs";
import { splitApiBasePath } from "./lib/api-base-path.mjs";
import { generateClient } from "./lib/hey-api.mjs";
import { normalizeSpec } from "./lib/normalize-spec.mjs";

// 0.5 Normalize verbose English sentence operationIds to short REST-conventional
// identifiers (e.g. "List Your Organizations" → "listOrganizations").
// identifiers (e.g. "List Your Organizations" → "listOrganizations"), and
// make every path relative to the API base path in the server URL.
// Writes a temporary openapi-normalized.json that the generator reads instead
// of the source spec. The source spec is never modified.
// OperationIds that are already identifiers (no spaces) are left untouched —
Expand All @@ -29,7 +31,10 @@ cpSync("lib/api-client", "src/api-client", { recursive: true });
// The generators are post-processors, not Hey API plugins, because the
// plugin API is documented as in-development and unstable.
const sourceSpec = JSON.parse(readFileSync("./openapi-derefed.json", "utf8"));
const apiBasePath = splitApiBasePath(sourceSpec);
const steps = [
// Export the base path, and send every operation under it.
"generate-api-base-path",
// Keep the pinned Fetch generator's runtime and types aligned with our contract.
"generate-response-contract",
"generate-request-preparation",
Expand All @@ -53,7 +58,7 @@ const steps = [
];
for (const name of steps) {
const { emit } = await import(`./scripts/${name}.mjs`);
emit({ normalizedSpec, sourceSpec });
emit({ normalizedSpec, sourceSpec, apiBasePath });
}

// 4. Append re-exports to the generated index.ts so the pagination
Expand All @@ -62,6 +67,7 @@ appendFileSync(
"src/index.ts",
[
"",
"export { API_BASE_PATH } from './api-base-path.gen.ts';",
"export { callWithTypedErrors, narrowError, SentryApiError } from './sentry-errors.ts';",
"export type { DocumentedSentryApiError, NarrowedResult, SentryApiResultError, SentryApiTransportError, SdkResult, UndocumentedSentryApiError } from './sentry-errors.ts';",
"export { parseSentryLinkHeader, unwrapResult, unwrapPaginatedResult, fetchPage, paginateAll, paginateUpTo, consumePage } from './sentry-pagination.ts';",
Expand Down
69 changes: 69 additions & 0 deletions lib/api-base-path.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Reads the API base path, such as `/api/0`, from the spec's server URL and
* leaves every operation path relative to it.
*
* The spec declares it in `servers` (`https://{region}.sentry.io/api/0`), and
* its paths are relative to it. The client takes the Sentry origin as
* `baseUrl` and adds the base path itself, so the servers keep only their
* origin.
*/

function trimTrailingSlashes(path) {
let end = path.length;
while (end > 0 && path[end - 1] === "/") end -= 1;
return path.slice(0, end);
}

function splitServerUrl(url) {
if (!URL.canParse(url)) throw new Error(`Unsupported server URL: ${url}`);
const { origin, pathname } = new URL(url);
return { origin, path: trimTrailingSlashes(pathname) };
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
}
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved

// TODO: Remove once Sentry's spec declares the base path in `servers`.
// Until then, every path starts with the same `/api/<version>/`.
function legacyBasePath(paths) {
const prefixes = new Set(Object.keys(paths).map(path => {
const [root, api, version, ...rest] = path.split("/");
return root === "" && api === "api" && version && rest.length > 0 ? `/api/${version}` : undefined;
}));
const [prefix] = prefixes;
if (prefixes.size !== 1 || prefix === undefined) {
throw new Error("The server URL has no base path, and the paths do not share one");
}
return prefix;
}

/**
* Make `spec`'s paths relative to its API base path, reduce its server URLs
* to their origins, and return the base path. A spec without servers, such
* as a test fixture, is left unchanged and has no base path.
* @param {{servers?: {url: string}[], paths: Record<string, object>}} spec
* @returns {string} The base path, such as `/api/0`, or `""`
*/
export function splitApiBasePath(spec) {
if (!spec.servers?.length) return "";
let basePath = splitServerUrl(spec.servers[0].url).path;
if (basePath === "") {
basePath = legacyBasePath(spec.paths);
spec.paths = Object.fromEntries(
Object.entries(spec.paths).map(([path, item]) => [path.slice(basePath.length), item]),
);
}

// An operation can override the servers, but not move off the base path.
const toOrigins = servers => servers.map(server => {
const { origin, path } = splitServerUrl(server.url);
if (path !== "" && path !== basePath) {
throw new Error(`Server ${server.url} is not under the API base path ${basePath}`);
}
return { ...server, url: origin };
});
spec.servers = toOrigins(spec.servers);
for (const pathItem of Object.values(spec.paths)) {
for (const operation of Object.values(pathItem)) {
if (operation?.servers) operation.servers = toOrigins(operation.servers);
}
}
return basePath;
}
29 changes: 16 additions & 13 deletions lib/api-client/api-path-scope.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/**
* Reading the organization and project an API path targets. No imports, so
* the generated client can use it without pulling in `@sentry/api/client`.
* Reading the organization and project an API path targets. It imports only
* the base path, so the generated client can use it without pulling in
* `@sentry/api/client`.
*/

import { API_BASE_PATH } from "../api-base-path.gen.js";

/** The organization, and project when present, an API path targets. */
export type ApiPathScope = { organization: string; project?: string };

Expand All @@ -16,29 +19,29 @@ function isApiPathIdentifier(segment: string | undefined): segment is string {
return segment !== undefined && API_PATH_IDENTIFIER.test(segment);
}

/** Path segments after `/api/0/`, or undefined for a URL outside the API. */
/** Path segments after {@link API_BASE_PATH}, or undefined for a URL outside the API. */
function apiPathSegments(urlOrPath: string): string[] | undefined {
const isUrl = URL.canParse(urlOrPath);
const path = isUrl
let path = isUrl
? new URL(urlOrPath).pathname
: (urlOrPath.split(/[?#]/, 1)[0] ?? "");
const segments = path.split("/").filter((segment) => segment.length > 0);
for (let index = 0; index < segments.length - 1; index += 1) {
if (segments[index] === "api" && segments[index + 1] === "0") {
return segments.slice(index + 2);
}
const apiIndex = path.indexOf(`${API_BASE_PATH}/`);
if (apiIndex !== -1) {
path = path.slice(apiIndex + API_BASE_PATH.length);
} else if (isUrl) {
return;
}
return isUrl ? undefined : segments;
return path.split("/").filter((segment) => segment.length > 0);
}

/**
* The organization, and project when present, a Sentry API request targets,
* read from its path: `/organizations/{org}/`, `/projects/{org}/{project}/`,
* `/teams/{org}/`, and `/organizations/{org}/projects/{project}/`.
*
* Accepts a full URL, whose path must contain `/api/0/` (a self-hosted
* install may serve it under a prefix), or a path relative to `/api/0/` with
* or without that prefix. Undefined for unscoped paths, list endpoints such
* Accepts a full URL, whose path must contain {@link API_BASE_PATH} (a
* self-hosted install may serve it under a prefix), or a path relative to it
* with or without that prefix. Undefined for unscoped paths, list endpoints such
* as `/organizations/`, and schema placeholders.
*
* Hosts with several credentials or regions use it to pick the one that
Expand Down
4 changes: 3 additions & 1 deletion lib/api-client/api-path.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { API_BASE_PATH } from "../api-base-path.gen.js";
import { UserInputError } from "./errors.js";

export { API_BASE_PATH } from "../api-base-path.gen.js";
export { type ApiPathScope, parseApiPathScope } from "./api-path-scope.js";

/**
Expand All @@ -26,7 +28,7 @@ export function buildSentryApiUrl(baseUrl: string, endpoint: string): string {
while (end > 0 && baseUrl.charAt(end - 1) === "/") {
end -= 1;
}
return `${baseUrl.slice(0, end)}/api/0/${path}`;
return `${baseUrl.slice(0, end)}${API_BASE_PATH}/${path}`;
}

/**
Expand Down
10 changes: 4 additions & 6 deletions lib/api-client/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
parseSentryLinkHeader,
type SentryClient,
} from "@sentry/api";
import { buildSentryApiUrl } from "./api-path.js";
import { API_BASE_PATH, buildSentryApiUrl } from "./api-path.js";
import {
type ApiError,
ApiValidationError,
Expand Down Expand Up @@ -56,7 +56,7 @@ export type ApiRequestOptions = {

export type ApiClientCore = {
/**
* Send a request to `origin` + `/api/0` + `path`.
* Send a request to `origin` + {@link API_BASE_PATH} + `path`.
*
* @throws {ApiError} For an error status not in `allowStatuses`
* @throws {ConfigurationError} When the host is unreachable or misconfigured
Expand Down Expand Up @@ -105,8 +105,6 @@ const NETWORK_ERROR_MESSAGES: Record<string, string> = {
ECONNRESET: "Connection reset. Try again in a moment.",
};

const API_PREFIX = "/api/0";

/** Network failures that mean the host is wrong or unreachable. */
const CONFIGURATION_ERROR_CODES = new Set([
"ENOTFOUND",
Expand Down Expand Up @@ -268,7 +266,7 @@ export function createApiClientCore({
const url = new URL(sdkRequest.url);
if (
url.origin !== base ||
url.pathname !== API_PREFIX.concat(expectedPath) ||
url.pathname !== API_BASE_PATH.concat(expectedPath) ||
sdkRequest.method !== "GET"
) {
throw new ConfigurationError(
Expand All @@ -277,7 +275,7 @@ export function createApiClientCore({
}
const response = await request(
origin,
`${url.pathname.slice(API_PREFIX.length)}${url.search}`,
`${url.pathname.slice(API_BASE_PATH.length)}${url.search}`,
{ method: "GET", signal: sdkRequest.signal },
);
const contentType = response.headers.get("content-type");
Expand Down
11 changes: 6 additions & 5 deletions lib/api-client/idempotent-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
* `@sentry/api/operations` directly.
*/

import { API_BASE_PATH } from "../api-base-path.gen.js";
import { isIdempotentRequest } from "./retry.js";

/** A non-safe operation Sentry marks idempotent. */
export type IdempotentOperation = {
/** Uppercase HTTP method */
method: string;
/** OpenAPI path template, such as `/api/0/organizations/{org}/chunk-upload/` */
/** OpenAPI path template relative to {@link API_BASE_PATH}, such as `/organizations/{org}/chunk-upload/` */
path: string;
};

Expand Down Expand Up @@ -59,8 +60,8 @@ function toMatcher({ method, path }: IdempotentOperation): Matcher {
* Build `isIdempotent(method, url)` for a list of writes. The list is read on
* the first call, so a caller may pass a thunk that loads it lazily.
*
* Matches from the `/api/` segment on, so a self-hosted install served under
* a path prefix still matches.
* Matches the path after {@link API_BASE_PATH}, so a self-hosted install
* served under a path prefix still matches.
*/
export function createIdempotentOperationMatcher(
operations: () => readonly IdempotentOperation[],
Expand All @@ -71,11 +72,11 @@ export function createIdempotentOperationMatcher(
return false;
}
const pathname = new URL(url).pathname;
const apiIndex = pathname.indexOf("/api/");
const apiIndex = pathname.indexOf(`${API_BASE_PATH}/`);
if (apiIndex === -1) {
return false;
}
const apiPath = pathname.slice(apiIndex);
const apiPath = pathname.slice(apiIndex + API_BASE_PATH.length);
const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`;
const upper = method.toUpperCase();

Expand Down
18 changes: 10 additions & 8 deletions lib/api-client/response-cache/invalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* apply:
*
* 1. **Hierarchy walk.** Sweep the URL's own path and every ancestor
* up to `/api/0/`. A mutation on
* up to the API base path. A mutation on
* `/organizations/{org}/releases/1.0.0/deploys/` sweeps itself,
* `.../releases/1.0.0/`, and `.../releases/` — which catches the
* detail, deploys-list, and releases-list GET caches in one pass.
Expand All @@ -27,7 +27,9 @@
* query-param variant cached under that path).
*/

const API_V0_SEGMENT = "/api/0/";
import { API_BASE_PATH } from "../../api-base-path.gen.js";

const API_SEGMENT = `${API_BASE_PATH}/`;
const TRAILING_SLASH_RE = /\/$/;

/**
Expand All @@ -45,7 +47,7 @@ const SKIP_INVALIDATION_PATTERNS: readonly RegExp[] = [

/**
* Rule for mutations whose effects cross URL trees. Patterns match
* the path relative to `/api/0/`. `extra` returns additional
* the path relative to the API base path. `extra` returns additional
* path-prefixes (swept under the mutation's own origin); `extraAbsolute`
* returns absolute URL prefixes (when invalidation must cross origins,
* e.g. region-scoped mutation clearing a cache under the control silo).
Expand Down Expand Up @@ -80,7 +82,7 @@ const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [
{
match: /^organizations\/[^/]+\/issues\/([^/]+)\/?$/,
extraAbsolute: ([, issueId], { apiBaseUrl }) => [
`${apiBaseUrl.replace(TRAILING_SLASH_RE, "")}/api/0/issues/${issueId}/`,
`${apiBaseUrl.replace(TRAILING_SLASH_RE, "")}${API_SEGMENT}issues/${issueId}/`,
],
},
];
Expand All @@ -95,7 +97,7 @@ const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [
* that need to clear caches under a different origin — e.g. the
* legacy `/issues/{id}/` endpoint the CLI's `getIssue()` reads).
* @returns Full-URL prefixes to sweep. Returns `[]` if the URL is
* not under `/api/0/` (e.g. sourcemap chunk upload to an arbitrary
* not under the API base path (e.g. sourcemap chunk upload to an arbitrary
* endpoint) or can't be parsed.
*/
export function computeInvalidationPrefixes(
Expand All @@ -110,7 +112,7 @@ export function computeInvalidationPrefixes(
return [];
}

const apiIdx = parsed.pathname.indexOf(API_V0_SEGMENT);
const apiIdx = parsed.pathname.indexOf(API_SEGMENT);
if (apiIdx === -1) {
return [];
}
Expand All @@ -119,8 +121,8 @@ export function computeInvalidationPrefixes(
return [];
}

const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_V0_SEGMENT.length)}`;
const relPath = parsed.pathname.slice(apiIdx + API_V0_SEGMENT.length);
const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_SEGMENT.length)}`;
const relPath = parsed.pathname.slice(apiIdx + API_SEGMENT.length);
if (relPath === "") {
return [];
}
Expand Down
Loading
Loading