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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ const sentry = createSentryClient(
const { data, error } = await sentry.listOrganizations();
```

### Preparing a raw request

Use `sentry.client.prepareRequest` when a consumer needs to inspect a request
or send it through its own raw transport. It shares the preparation used by
ordinary SDK requests: instance and per-call configuration, authentication,
validation, body serialization, and request interceptors.

```ts
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",
query: { cursor: "opaque-next", per_page: 50 },
});
// Both field values remain; the old cursor is replaced and the fragment removed.
const response = await fetch(request);
// The consumer owns status handling and reading the raw body.
```

Preparation does not call the configured `fetch` or parse a response. Auth
providers, validators and request interceptors still run and can have side
effects; their errors reject unchanged. The returned native `Request` includes
the resolved options and request-interceptor changes. Its signal remains linked
to the caller's unless an interceptor replaces or clears it. Send it directly
with the chosen transport; calling `client.request` again would repeat preparation.
Reading `request.clone()` leaves its body available for dispatch. A custom fetch or
browser adapter can still add credentials, CSRF headers or routing at dispatch.

For endpoints with an existing query, serialized query keys replace all existing
occurrences of those keys. Explicit `null` input keys also remove existing values,
even with custom serializers; the serialized output can then add them back.
Unrelated repeated parameters and their encoding are retained, as is custom
query-serializer output. The default serializer omits `null` and `undefined`:
`null` clears the original value, while `undefined` leaves it unchanged.
Literal URL fragments are removed; encoded `%23` values are retained. These
rules also apply to ordinary requests through the same URL builder.

Use `bodySerializer: null` for an already serialized string, `FormData`, or
another native body. For `FormData`, also clear the default JSON content type
with `headers: { "Content-Type": null }` so `Request` supplies its boundary.
Otherwise the configured serializer still applies. Native
`Request` construction can add a content type for strings or form data; prepared
headers describe that request, not a consumer's earlier input or redacted preview.

Pass decoded server cursors and explicit page sizes as query parameters. The SDK
does not interpret bookmark formats, select navigation directions or store
history. Existing custom transports accepted by generated operations do not
have to implement `prepareRequest`; the factory's concrete transport adds it.

### Cancellation and custom fetch

Reuse the client and supply an `AbortSignal` for each operation. The caller owns the signal, whether it comes from a command, an MCP tool call, or a query library:
Expand Down
1 change: 1 addition & 0 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ cpSync("lib/source-analysis.ts", "src/source-analysis.ts");

// Keep the pinned Fetch generator's runtime and types aligned with our contract.
execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-response-contract.mjs"))}`, { stdio: "inherit" });
execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-request-preparation.mjs"))}`, { stdio: "inherit" });

// 3. Generate operation helpers and the bound client from the SDK output.
// This post-processor inspects src/sdk.gen.ts and openapi-derefed.json,
Expand Down
74 changes: 74 additions & 0 deletions scripts/generate-request-preparation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** Expose the pinned Fetch client's own preparation without dispatching a request. */
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

function replaceOnce(source, before, after) {
if (source.split(before).length !== 2) {
throw new Error(`Fetch generator changed: expected one match: ${before}`);
}
return source.replace(before, () => after);
}

function update(name, transform) {
const path = fileURLToPath(new URL(`../src/${name}`, import.meta.url));
writeFileSync(path, transform(readFileSync(path, "utf8")));
}

update("client/client.gen.ts", source => {
source = replaceOnce(source,
"export const createClient = (config: Config = {}): Client => {",
`/** The concrete SDK transport adds preparation without requiring it from custom Clients. */
export type PreparedClient = Client & {
/** Prepare a native Request using configured auth, serializers and request interceptors, without calling fetch. */
prepareRequest(options: RequestOptions & Pick<Required<RequestOptions>, 'method'>): Promise<Request>;
};

export const createClient = (config: Config = {}): PreparedClient => {`);
source = replaceOnce(source,
" // The public Client signature applies operation-specific data and throw types.\n const request = async (options: RequestOptions) => {",
" const prepare = async (options: RequestOptions) => {");
source = replaceOnce(source,
" // fetch must be assigned here, otherwise it would throw the error:",
` return { request, opts };
};

const prepareRequest = async (options: RequestOptions): Promise<Request> =>
(await prepare(options)).request;

// The public Client signature applies operation-specific data and throw types.
const request = async (options: RequestOptions) => {
const { request, opts } = await prepare(options);

// fetch must be assigned here, otherwise it would throw the error:`);
source = replaceOnce(source, " request,\n setConfig,", " request,\n prepareRequest,\n setConfig,");
return replaceOnce(source, " } as Client;", " } as PreparedClient;");
});

update("client/index.ts", source => replaceOnce(source,
"export { createClient } from './client.gen';",
"export { createClient } from './client.gen';\nexport type { PreparedClient } from './client.gen';",
));

// Raw endpoints can already contain a query. Overlay explicitly serialized keys
// without re-encoding unrelated values or a custom serializer's output.
update("core/utils.gen.ts", source => replaceOnce(source,
` if (search) {
url += \`?\${search}\`;
}
return url;`,
` url = url.split('#', 1)[0]!;
const queryIndex = url.indexOf('?');
const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex);
const existing = queryIndex === -1 ? '' : url.slice(queryIndex + 1);
const replaced = new Set(new URLSearchParams('?' + search).keys());
// An explicit null clears an existing parameter; undefined leaves it alone.
for (const [key, value] of Object.entries(query ?? {})) {
if (value === null) replaced.add(key);
}
const retained = existing.split('&').filter(part => {
const key = new URLSearchParams('?' + part).keys().next().value;
return part !== '' && (key === undefined || !replaced.has(key));
});
const combined = [...retained, search].filter(Boolean).join('&');
return pathname + (combined ? \`?\${combined}\` : '');`,
));
12 changes: 12 additions & 0 deletions test/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ function publicFactory() {
return configured;
}

async function preparedRequests(sentry: SentryClient) {
const request: Request = await sentry.client.prepareRequest({
method: "GET", url: "/api/0/custom/", query: { cursor: "opaque" },
});
// @ts-expect-error Generic preparation requires an explicit method.
await sentry.client.prepareRequest({ url: "/api/0/custom/" });
// Custom clients accepted by standalone operations need no new capability.
const { prepareRequest, ...legacyClient } = sentry.client;
await listOrganizations({ client: legacyClient });
void [request, prepareRequest];
}

async function operationArguments(sentry: SentryClient) {
await sentry.listOrganizations();
await sentry.listOrganizations({ query: { owner: true }, signal: new AbortController().signal });
Expand Down
13 changes: 13 additions & 0 deletions test/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ describe("package entry points", () => {
assert.equal(typeof client.createSentryClient, "function");
assert.equal(typeof browser.createBrowserFetch, "function");
assert.equal("analyzeSourceApiUsage" in client, false);
const sentry = client.createSentryClient({baseUrl: "https://example.test/prefix"});
const request = await sentry.client.prepareRequest({
method: "POST", url: "/api/0/custom/?field=id&field=name&cursor=old#view",
query: {cursor: "opaque:next"}, body: {message: "example"},
});
assert.equal(request.url, "https://example.test/prefix/api/0/custom/?field=id&field=name&cursor=opaque%3Anext");
assert.equal(request.headers.get("content-type"), "application/json");
assert.deepEqual(await request.clone().json(), {message: "example"});
const transport = async request => new Response(await request.text(), {status: 422});
const response = await transport(request);
assert.equal(response.status, 422);
assert.deepEqual(await response.json(), {message: "example"});
`);
execFileSync("node", ["client.mjs"], { cwd: directory });
writeFileSync(join(directory, "smoke.mjs"), `
Expand Down Expand Up @@ -109,6 +121,7 @@ describe("package entry points", () => {
writeFileSync(join(directory, "types.ts"), `
import { consumePage, type PaginationContinuation, type PagePosition, type SentryClient } from "@sentry/api";
declare const sentry: SentryClient;
const prepared: Promise<Request> = sentry.client.prepareRequest({method: "GET", url: "/api/0/custom/"});
const position: PagePosition = {cursor: "opaque", offset: 2};
const resume: PaginationContinuation = {...position, requestBudget: 10};
const rows: string[] = consumePage({data: ["one"]}, position).data;
Expand Down
182 changes: 182 additions & 0 deletions test/request-preparation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, test } from "bun:test";
import { createSentryClient, type Config } from "@sentry/api";
import { createBrowserSdkConfig } from "@sentry/api/browser";

const asFetch = (implementation: (request: Request) => Promise<Response>) =>
((input: RequestInfo | URL, init?: RequestInit) =>
implementation(new Request(input, init))) as NonNullable<Config["fetch"]>;
const snapshot = async (request: Request) => {
const headers: Record<string, string> = {};
request.headers.forEach((value, name) => { headers[name] = value; });
return { url: request.url, method: request.method, headers, body: await request.clone().text() };
};

describe("request preparation", () => {
test("shares request preparation with dispatch, running configured hooks once without fetching", async () => {
const stages: string[] = [];
const requests: Request[] = [];
const transport = asFetch(async request => {
requests.push(request);
return Response.json({ accepted: true });
});
const sentry = createSentryClient({
baseUrl: "https://example.test/sentry",
headers: { "X-Default": "default", "X-Override": "original" },
auth: async () => { stages.push("auth"); return "test-token"; },
requestValidator: async () => { stages.push("validate"); },
bodySerializer: body => { stages.push("serialize"); return JSON.stringify(body); },
fetch: transport,
});
sentry.client.interceptors.request.use(request => {
stages.push("intercept");
request.headers.set("X-Prepared", "yes");
return request;
});
const options = {
method: "POST" as const,
url: "/api/0/organizations/{org}/items/",
path: { org: "example" },
body: { title: "Overview" },
headers: { "x-override": "per-call" },
security: [{ type: "http" as const, scheme: "bearer" as const }],
};
const prepared = await sentry.client.prepareRequest(options);
expect(stages).toEqual(["auth", "validate", "serialize", "intercept"]);
expect(requests).toHaveLength(0);
const preview = await snapshot(prepared);
expect(prepared.headers.get("Authorization")).toBe("Bearer test-token");
expect(prepared.headers.get("X-Default")).toBe("default");
expect(prepared.headers.get("X-Override")).toBe("per-call");
expect(await (await transport(prepared)).json()).toEqual({ accepted: true });
const result = await sentry.client.request(options);

expect(result.data).toEqual({ accepted: true });
expect(stages).toEqual(["auth", "validate", "serialize", "intercept", "auth", "validate", "serialize", "intercept"]);
expect(await snapshot(requests[0]!)).toEqual(preview);
expect(await snapshot(requests[1]!)).toEqual(preview);
expect(options.body).toEqual({ title: "Overview" });
const configuredHeaders = sentry.client.getConfig().headers;
if (!(configuredHeaders instanceof Headers)) throw new Error("Expected resolved headers");
expect(configuredHeaders.get("X-Override")).toBe("original");
});

test("composes with browser transport without owning its CSRF or raw response handling", async () => {
const previousFetch = globalThis.fetch;
const requests: Request[] = [];
const response = Response.json({ data: [], meta: { fields: {} } }, { status: 403 });
globalThis.fetch = asFetch(async request => { requests.push(request); return response; });
try {
const config = createBrowserSdkConfig({ baseUrl: "https://example.test", getCsrfToken: () => "csrf-example" });
const sentry = createSentryClient({ ...config, fetch: asFetch(request => config.fetch(request)) });
const prepared = await sentry.client.prepareRequest({ method: "POST", url: "/api/0/custom/", body: { value: 1 } });
expect(requests).toHaveLength(0);
expect(prepared.headers.has("X-CSRFToken")).toBe(false);
const raw = await config.fetch(prepared);
expect(raw).toBe(response);
expect(raw.bodyUsed).toBe(false);
expect(requests[0]!.headers.get("X-CSRFToken")).toBe("csrf-example");
expect(requests[0]!.credentials).toBe("include");
expect(raw.status).toBe(403);
expect(await raw.json()).toEqual({ data: [], meta: { fields: {} } });
} finally {
globalThis.fetch = previousFetch;
}
});

test("replaces an opaque cursor without losing repeated filters, prefixes or encoded path segments", async () => {
const requests: Request[] = [];
const sentry = createSentryClient({
baseUrl: "https://example.test/sentry/",
fetch: asFetch(async request => { requests.push(request); return Response.json([]); }),
});
const options = {
method: "GET" as const,
url: "/api/0/items/a%2Fb/?cursor=old&cursor=older&field=id&field=name&?cursor=unrelated&query=a%20b#ignored",
query: { cursor: "opaque/+?=#", per_page: 25 },
};
const request = await sentry.client.prepareRequest(options);
const url = new URL(request.url);
expect(url.pathname).toBe("/sentry/api/0/items/a%2Fb/");
expect(url.searchParams.getAll("cursor")).toEqual(["opaque/+?=#"]);
expect(url.searchParams.get("?cursor")).toBe("unrelated");
expect(url.searchParams.getAll("field")).toEqual(["id", "name"]);
expect(url.searchParams.get("per_page")).toBe("25");
expect(url.search).toContain("query=a%20b");
expect(url.hash).toBe("");
await sentry.client.request(options);
expect(requests[0]!.url).toBe(request.url);
expect(options.url).toContain("cursor=old&cursor=older");
});

test("clears a cursor for the first page while leaving unspecified query parameters intact", async () => {
const sentry = createSentryClient({ baseUrl: "https://example.test" });
const request = await sentry.client.prepareRequest({
method: "GET", url: "/api/0/items/?cursor=one&cursor=two&per_page=25&field=id&field=name",
query: { cursor: null, per_page: undefined },
});
const query = new URL(request.url).searchParams;
expect(query.has("cursor")).toBe(false);
expect(query.get("per_page")).toBe("25");
expect(query.getAll("field")).toEqual(["id", "name"]);
});

test("retains a custom serializer's bytes when overlaying an existing query", async () => {
const sentry = createSentryClient({ baseUrl: "https://example.test" });
const request = await sentry.client.prepareRequest({
method: "GET", url: "/api/0/items/?cursor=old&field=old&%3Fcursor=old&query=a%20b",
query: { cursor: "custom/cursor value", field: ["id", "name"] },
querySerializer: () => "??cursor=literal&cursor=custom%2fcursor+value&field=id&field=name",
});
expect(request.url).toBe("https://example.test/api/0/items/?query=a%20b&?cursor=literal&cursor=custom%2fcursor+value&field=id&field=name");
});

test("supports native raw and multipart bodies without JSON serialization", async () => {
const sentry = createSentryClient({ baseUrl: "https://example.test" });
const raw = await sentry.client.prepareRequest({
method: "POST", url: "/api/0/custom/", body: "raw payload", bodySerializer: null,
headers: { "Content-Type": "text/custom" },
});
expect(raw.headers.get("Content-Type")).toBe("text/custom");
expect(await raw.text()).toBe("raw payload");
const form = new FormData();
form.append("tag", "one");
form.append("tag", "two");
const multipart = await sentry.client.prepareRequest({
method: "POST", url: "/api/0/upload/", body: form, bodySerializer: null,
headers: { "Content-Type": null },
});
expect(multipart.headers.get("Content-Type")).toMatch(/^multipart\/form-data; boundary=/);
expect((await multipart.formData()).getAll("tag")).toEqual(["one", "two"]);
});

test("preserves an aborted signal and its reason without dispatching", async () => {
const controller = new AbortController();
const reason = new Error("Caller stopped the request");
controller.abort(reason);
let calls = 0;
const sentry = createSentryClient({ baseUrl: "https://example.test", fetch: asFetch(async () => { calls++; return new Response(); }) });
const request = await sentry.client.prepareRequest({ method: "GET", url: "/api/0/items/", signal: controller.signal });
expect(request.signal.aborted).toBe(true);
expect(request.signal.reason).toBe(reason);
expect(() => request.signal.throwIfAborted()).toThrow(reason);
expect(calls).toBe(0);
});

test("isolates concurrent clients and keeps prepared requests unchanged after reconfiguration", async () => {
const first = createSentryClient({ baseUrl: "https://first.example.test", headers: { "X-Context": "first" } });
const second = createSentryClient({ baseUrl: "https://second.example.test", headers: { "X-Context": "second" } });
const { prepareRequest } = first.client;
const options = { method: "POST" as const, url: "/api/0/items/", body: { value: "original" } };
const [prepared, other] = await Promise.all([prepareRequest(options), second.client.prepareRequest(options)]);
first.client.setConfig({ baseUrl: "https://changed.example.test", headers: { "X-Context": "changed" } });
options.body.value = "edited";
const next = await prepareRequest(options);

expect([prepared.url, other.url, next.url]).toEqual([
"https://first.example.test/api/0/items/", "https://second.example.test/api/0/items/", "https://changed.example.test/api/0/items/",
]);
expect([prepared, other, next].map(request => request.headers.get("X-Context"))).toEqual(["first", "second", "changed"]);
expect(await prepared.json()).toEqual({ value: "original" });
expect(await next.json()).toEqual({ value: "edited" });
});
});
Loading