Skip to content
Open
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
17 changes: 17 additions & 0 deletions apps/cli-docs/src/fragments/commands/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ bodies; streaming responses such as SSE are excluded.

When querying the Events API (`/events/` endpoint), valid dataset values are: `spans`, `logs`, `errors`, `tracemetrics`, `profile_functions`, and `uptime_results`.

## Pagination

Each invocation fetches one page. With `--json`, successful textual responses
include `pagination.nextCursor` and/or `pagination.prevCursor` when the server's
`Link` header advertises a usable continuation. `--fields` filters only the
response body and preserves this metadata.

Repeat the same GET request with `--cursor <token>` to continue, keeping its
filters, ordering, page size, and time range. The token is opaque: `next` and
`prev` do not refer to saved pages. `--cursor` replaces existing cursor parameters
from both the endpoint URL and request fields; all other parameters are retained.
It is only supported for GET requests, including `--dry-run` previews.

When no usable cursor is advertised, `pagination` is omitted. This does not prove
that the underlying dataset was fully scanned or unsampled. The command does not
automatically follow pages.

## Binary responses

Endpoints that return binary data — image attachments, minidumps, debug files — are streamed to stdout as raw bytes, so you can redirect them straight to a file.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Make an authenticated API request

**Flags:**
- `-X, --method <value> - The HTTP method for the request - (default: "GET")`
- `--cursor <value> - Pagination cursor for GET (replaces any existing cursor)`
- `-d, --data <value> - Inline JSON body for the request (like curl -d)`
- `-F, --field <value>... - Add a typed parameter (key=value, key[sub]=value, key[]=value)`
- `-f, --raw-field <value>... - Add a string parameter without JSON parsing`
Expand All @@ -38,6 +39,12 @@ Make an authenticated API request
# List organizations
sentry api organizations/

# Read a page and its pagination metadata
sentry api organizations/ -f per_page=2 --json

# Continue using a cursor from the previous response
sentry api organizations/ -f per_page=2 --cursor '2:1:0' --json

# Get a specific issue
sentry api issues/123456789/

Expand Down
69 changes: 64 additions & 5 deletions packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { access, readFile } from "node:fs/promises";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import type { SentryContext } from "../context.js";
import { appendSearchParams, rawApiRequest } from "../lib/api-client.js";
import {
appendSearchParams,
parseLinkHeader,
rawApiRequest,
} from "../lib/api-client.js";
import { buildCommand } from "../lib/command.js";
import {
OutputError,
Expand Down Expand Up @@ -41,6 +45,7 @@ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

type ApiFlags = {
readonly method: HttpMethod;
readonly cursor?: string;
readonly data?: string;
readonly field?: string[];
readonly "raw-field"?: string[];
Expand Down Expand Up @@ -1177,10 +1182,12 @@ function formatApiResponseOutput(
if (!json || output instanceof Uint8Array) {
return output;
}
const pagination = parseLinkHeader(response.headers.get("link"));
return {
status: response.status,
statusText: response.statusText,
body: output,
...(pagination.nextCursor || pagination.prevCursor ? { pagination } : {}),
};
}

Expand All @@ -1206,6 +1213,29 @@ function formatApiResponseJson(data: unknown, fields?: string[]): unknown {
};
}

/**
* Compose query parameters without URL fragments, replacing all existing cursors
* when an explicit cursor is provided.
*/
function resolveRequestEndpoint(
endpoint: string,
params: Record<string, string | string[]> | undefined,
cursor: string | undefined,
): string {
const withoutFragment = endpoint.split("#", 1)[0] ?? endpoint;
const composed = appendSearchParams(withoutFragment, params);
if (cursor === undefined) {
return composed;
}
const queryIndex = composed.indexOf("?");
const path = queryIndex === -1 ? composed : composed.slice(0, queryIndex);
const query = new URLSearchParams(
queryIndex === -1 ? "" : composed.slice(queryIndex + 1),
);
query.set("cursor", cursor);
return `${path}?${query}`;
}

/**
* Resolve the full URL that rawApiRequest would use for a request.
*
Expand Down Expand Up @@ -1529,6 +1559,9 @@ export const apiCommand = buildCommand({
"Absolute HTTP(S) Sentry URLs are also accepted; their origin is " +
"validated against your authenticated host. " +
"Authentication is handled automatically using your stored credentials.\n\n" +
"Pagination: --json includes usable nextCursor/prevCursor values in " +
"pagination when the server advertises them. Repeat the same GET with " +
"--cursor <token> to continue. Each invocation fetches one page.\n\n" +
"Body options:\n" +
' --data/-d \'{"key":"value"}\' Inline JSON body (like curl -d)\n' +
' --input/-i file.json Read body from file (or "-" for stdin)\n\n' +
Expand All @@ -1543,6 +1576,15 @@ export const apiCommand = buildCommand({
description: "List organizations",
command: "sentry api organizations/",
},
{
description: "Read a page and its pagination metadata",
command: "sentry api organizations/ -f per_page=2 --json",
},
{
description: "Continue using a cursor from the previous response",
command:
"sentry api organizations/ -f per_page=2 --cursor '2:1:0' --json",
},
{
description: "Get a specific issue",
command: "sentry api issues/123456789/",
Expand Down Expand Up @@ -1619,6 +1661,13 @@ export const apiCommand = buildCommand({
default: "GET" as const,
placeholder: "method",
},
cursor: {
kind: "parsed",
parse: String,
brief: "Pagination cursor for GET (replaces any existing cursor)",
optional: true,
placeholder: "token",
},
data: {
kind: "parsed",
parse: String,
Expand Down Expand Up @@ -1682,6 +1731,12 @@ export const apiCommand = buildCommand({
},
async *func(this: SentryContext, flags: ApiFlags, endpoint: string) {
const { stdin } = this;
if (flags.cursor !== undefined && flags.method !== "GET") {
throw new ValidationError(
"--cursor is only supported for GET requests",
"cursor",
);
}

const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } =
resolveApiTarget(endpoint);
Expand All @@ -1696,6 +1751,11 @@ export const apiCommand = buildCommand({
);
}
const { body, params } = await resolveBody(flags, stdin);
const requestEndpoint = resolveRequestEndpoint(
normalizedEndpoint,
params,
flags.cursor,
);

const headers =
flags.header && flags.header.length > 0
Expand All @@ -1706,7 +1766,7 @@ export const apiCommand = buildCommand({
if (flags["dry-run"]) {
yield new CommandOutput({
method: flags.method,
url: resolveRequestUrl(normalizedEndpoint, params, requestBaseUrl),
url: resolveRequestUrl(requestEndpoint, undefined, requestBaseUrl),
headers: resolveEffectiveHeaders(headers, body),
body: body ?? null,
});
Expand All @@ -1716,15 +1776,14 @@ export const apiCommand = buildCommand({
const verbose = flags.verbose && !flags.silent;

if (verbose) {
logRequest(flags.method, normalizedEndpoint, headers);
logRequest(flags.method, requestEndpoint, headers);
}

// The shared authenticated fetch validates requestBaseUrl against the
// token host and registered regional origins before adding credentials.
const response = await rawApiRequest(normalizedEndpoint, {
const response = await rawApiRequest(requestEndpoint, {
method: flags.method,
body,
params,
headers,
baseUrl: requestBaseUrl,
});
Expand Down
150 changes: 147 additions & 3 deletions packages/cli/test/e2e/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,50 @@ import {
} from "vitest";
import { EXIT } from "../../src/lib/errors.js";
import { createE2EContext, type E2EContext } from "../fixture.js";
import organizationsFixture from "../fixtures/organizations.json";
import { cleanupTestDir, createTestConfigDir } from "../helpers.js";
import { createSentryMockServer, TEST_TOKEN } from "../mocks/routes.js";
import type { MockServer } from "../mocks/server.js";
import { apiRoutes, TEST_TOKEN } from "../mocks/routes.js";
import { createMockServer, type MockServer } from "../mocks/server.js";

let testConfigDir: string;
let mockServer: MockServer;
let ctx: E2EContext;
const paginationRequests: URL[] = [];
const nextCursor = "100:1:0";
const prevCursor = "100:0:1";

beforeAll(async () => {
mockServer = createSentryMockServer();
mockServer = createMockServer(
[
...apiRoutes,
{
method: "GET",
path: "/api/0/api-pagination/",
response: (req, _params, serverUrl) => {
const url = new URL(req.url);
paginationRequests.push(url);
const isSecondPage = url.searchParams.get("cursor") === nextCursor;
return {
body: [organizationsFixture[isSecondPage ? 1 : 0]],
headers: {
Link:
`<${serverUrl}/api/0/api-pagination/?cursor=${nextCursor}>; rel="next"; results="${!isSecondPage}"; cursor="${nextCursor}", ` +
`<${serverUrl}/api/0/api-pagination/?cursor=${prevCursor}>; rel="previous"; results="${isSecondPage}"; cursor="${prevCursor}"`,
},
};
},
},
{
method: "POST",
path: "/api/0/api-pagination/",
response: (req) => {
paginationRequests.push(new URL(req.url));
return { body: { accepted: true } };
},
},
],
{ validTokens: [TEST_TOKEN] },
);
await mockServer.start();
});

Expand All @@ -34,6 +68,7 @@ afterAll(() => {
});

beforeEach(async () => {
paginationRequests.length = 0;
testConfigDir = await createTestConfigDir("e2e-api-");
ctx = createE2EContext(testConfigDir, mockServer.url);
});
Expand Down Expand Up @@ -124,6 +159,115 @@ describe("sentry api", () => {
expect(JSON.parse(result.stdout)).toEqual({ method: "GET" });
});

test("continues one page at a time and preserves pagination with --fields", async () => {
await ctx.setAuthToken(TEST_TOKEN);
const args = ["api", "api-pagination/", "--json", "--fields", "name"];

const first = await ctx.run(args);
expect(first.exitCode, first.stderr + first.stdout).toBe(0);
const firstPage = JSON.parse(first.stdout);
expect(firstPage).toEqual({
status: 200,
statusText: "OK",
body: [{ name: organizationsFixture[0].name }],
pagination: { nextCursor },
});
expect(paginationRequests).toHaveLength(1);

const second = await ctx.run([
...args,
"--cursor",
firstPage.pagination.nextCursor,
]);
expect(second.exitCode, second.stderr + second.stdout).toBe(0);
expect(JSON.parse(second.stdout)).toEqual({
status: 200,
statusText: "OK",
body: [{ name: organizationsFixture[1].name }],
pagination: { prevCursor },
});
expect(paginationRequests).toHaveLength(2);
expect(paginationRequests[1].searchParams.getAll("cursor")).toEqual([
nextCursor,
]);
});

test.each([
{
name: "typed and raw fields",
flags: ["-F", "cursor=typed-old", "-f", "cursor=raw-old"],
},
{ name: "JSON data", flags: ["-d", '{"cursor":"data-old"}'] },
{ name: "form data", flags: ["-d", "cursor=data-old"] },
])(
"--cursor replaces $name and URL cursors in the preview and request",
async ({ flags }) => {
await ctx.setAuthToken(TEST_TOKEN);
const args = [
"api",
`${mockServer.url}/api/0/api-pagination/?cursor=url-old&cursor=url-older&field=id&field=name&project=1&project=2`,
...flags,
"--cursor",
nextCursor,
"--json",
];

const preview = await ctx.run([...args, "--dry-run"]);
expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0);
const request = JSON.parse(preview.stdout);
const url = new URL(request.url);
expect(url.searchParams.getAll("cursor")).toEqual([nextCursor]);
expect(url.searchParams.getAll("field")).toEqual(["id", "name"]);
expect(url.searchParams.getAll("project")).toEqual(["1", "2"]);
expect(paginationRequests).toHaveLength(0);

const result = await ctx.run(args);
expect(result.exitCode, result.stderr + result.stdout).toBe(0);
expect(paginationRequests.map((sent) => sent.href)).toEqual([url.href]);
expect(JSON.parse(result.stdout).body).toEqual([organizationsFixture[1]]);
},
);

test("rejects --cursor for non-GET requests before sending them", async () => {
await ctx.setAuthToken(TEST_TOKEN);
const result = await ctx.run([
"api",
"api-pagination/",
"--method",
"POST",
"--cursor",
nextCursor,
]);

expect(result.exitCode).toBe(EXIT.VALIDATION);
expect(result.stdout + result.stderr).toMatch(/cursor.*GET|GET.*cursor/i);
expect(paginationRequests).toHaveLength(0);
});

test.each([
{ endpoint: "api-pagination/?query=foo%23bar#anchor", query: "foo#bar" },
{ endpoint: "api-pagination/#anchor", query: null },
])(
"--cursor ignores URL fragments in $endpoint",
async ({ endpoint, query }) => {
await ctx.setAuthToken(TEST_TOKEN);
const args = ["api", endpoint, "--cursor", nextCursor, "--json"];

const preview = await ctx.run([...args, "--dry-run"]);
expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0);
const url = new URL(JSON.parse(preview.stdout).url);
expect(url.hash).toBe("");
expect(url.searchParams.get("query")).toBe(query);
expect(url.searchParams.getAll("cursor")).toEqual([nextCursor]);
expect(paginationRequests).toHaveLength(0);

const result = await ctx.run(args);
expect(result.exitCode, result.stderr + result.stdout).toBe(0);
expect(paginationRequests.map((sent) => sent.href)).toEqual([url.href]);
expect(JSON.parse(result.stdout).body).toEqual([organizationsFixture[1]]);
},
);

test("empty error bodies report the HTTP status and request", async () => {
await ctx.setAuthToken(TEST_TOKEN);

Expand Down
Loading
Loading