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
1 change: 1 addition & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
type ApiRequestOptions,
apiRequest,
apiRequestToRegion,
autoPaginate,
buildSearchParams,
ORG_FANOUT_CONCURRENCY,
type PaginatedResponse,
Expand Down
48 changes: 48 additions & 0 deletions src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,54 @@ export type PaginatedResponse<T> = {
nextCursor?: string;
};

/**
* Auto-paginate across multiple API pages, accumulating results up to `limit`.
*
* Calls `fetchPage` repeatedly until enough rows are collected or pages are
* exhausted. Caps at {@link MAX_PAGINATION_PAGES} to prevent runaway loops.
*
* The caller is responsible for baking `perPage` into the `fetchPage` closure
* (typically `Math.min(limit, API_MAX_PER_PAGE)`). This helper only manages
* cursor chaining and row accumulation.
*
* @param fetchPage - Async function that fetches a single page given a cursor
* @param limit - Total number of items to collect
* @param initialCursor - Optional starting cursor
* @returns Accumulated items with optional nextCursor from the last page
*/
export async function autoPaginate<T>(
fetchPage: (cursor: string | undefined) => Promise<PaginatedResponse<T[]>>,
limit: number,
initialCursor?: string
): Promise<PaginatedResponse<T[]>> {
// Fast path: single-page fetch when limit fits in one API page
if (limit <= API_MAX_PER_PAGE) {
return fetchPage(initialCursor);
}

// Multi-page: accumulate rows across pages up to the requested limit
const allRows: T[] = [];
let cursor: string | undefined = initialCursor;

for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) {
const result = await fetchPage(cursor);
allRows.push(...result.data);

if (allRows.length >= limit || !result.nextCursor) {
// Overshot — trim and drop nextCursor (cursor would skip items)
if (allRows.length > limit) {
return { data: allRows.slice(0, limit) };
}
return { data: allRows, nextCursor: result.nextCursor };
}

cursor = result.nextCursor;
}

// Safety limit reached — return what we have, no nextCursor
return { data: allRows.slice(0, limit) };
}

/**
* Make an authenticated request to a specific Sentry region.
* Returns both parsed response data and raw headers for pagination support.
Expand Down
121 changes: 94 additions & 27 deletions src/lib/api/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import { resolveOrgRegion } from "../region.js";
import { isAllDigits } from "../utils.js";

import {
API_MAX_PER_PAGE,
apiRequestToRegion,
autoPaginate,
type PaginatedResponse,
parseLinkHeader,
} from "./infrastructure.js";
Expand Down Expand Up @@ -293,30 +295,23 @@ type ListTransactionsOptions = {
};

/**
* List recent transactions for a project.
* Uses the Explore/Events API with dataset=transactions.
* Fetch a single page of transactions from the Explore/Events endpoint.
*
* Handles project slug vs numeric ID automatically:
* - Numeric IDs are passed as the `project` parameter
* - Slugs are added to the query string as `project:{slug}`
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug or numeric ID
* @param options - Query options (query, limit, sort, statsPeriod, cursor)
* @returns Paginated response with transaction items and optional next cursor
* Internal helper used by {@link listTransactions} for both single-page and
* multi-page (auto-paginating) fetches.
*/
export async function listTransactions(
// biome-ignore lint/nursery/useMaxParams: internal helper mirrors the public API surface
async function fetchTransactionsPage(
regionUrl: string,
orgSlug: string,
projectSlug: string,
options: ListTransactionsOptions = {}
options: ListTransactionsOptions,
perPage: number
): Promise<PaginatedResponse<TransactionListItem[]>> {
const isNumericProject = isAllDigits(projectSlug);
const projectFilter = isNumericProject ? "" : `project:${projectSlug}`;
const fullQuery = [projectFilter, options.query].filter(Boolean).join(" ");

const regionUrl = await resolveOrgRegion(orgSlug);

// Use raw request: the SDK's dataset type doesn't include "transactions"
const { data: response, headers } =
await apiRequestToRegion<TransactionsResponse>(
regionUrl,
Expand All @@ -330,7 +325,7 @@ export async function listTransactions(
// sending `query=` causes the Sentry API to behave differently than
// omitting the parameter.
query: fullQuery || undefined,
per_page: options.limit || 10,
per_page: perPage,
statsPeriod:
options.start || options.end
? undefined
Expand All @@ -351,6 +346,45 @@ export async function listTransactions(
return { data: response.data, nextCursor };
}

/**
* List recent transactions for a project.
* Uses the Explore/Events API with dataset=transactions.
*
* Handles project slug vs numeric ID automatically:
* - Numeric IDs are passed as the `project` parameter
* - Slugs are added to the query string as `project:{slug}`
*
* When `limit` exceeds {@link API_MAX_PER_PAGE}, transparently fetches multiple
* pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}).
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug or numeric ID
* @param options - Query options (query, limit, sort, statsPeriod, cursor)
* @returns Paginated response with transaction items and optional next cursor
*/
export async function listTransactions(
orgSlug: string,
projectSlug: string,
options: ListTransactionsOptions = {}
): Promise<PaginatedResponse<TransactionListItem[]>> {
const regionUrl = await resolveOrgRegion(orgSlug);
const limit = options.limit || 10;
const perPage = Math.min(limit, API_MAX_PER_PAGE);

return autoPaginate(
(cursor) =>
fetchTransactionsPage(
regionUrl,
orgSlug,
projectSlug,
{ ...options, cursor },
perPage
),
limit,
options.cursor
);
}

// Span listing

/** Fields to request from the spans API */
Expand Down Expand Up @@ -391,18 +425,18 @@ type ListSpansOptions = {
};

/**
* List spans using the EAP spans search endpoint.
* Uses the Explore/Events API with dataset=spans.
* Fetch a single page of spans from the Explore/Events endpoint.
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug or numeric ID
* @param options - Query options (query, limit, sort, statsPeriod, cursor)
* @returns Paginated response with span items and optional next cursor
* Internal helper used by {@link listSpans} for both single-page and
* multi-page (auto-paginating) fetches.
*/
export async function listSpans(
// biome-ignore lint/nursery/useMaxParams: internal helper mirrors the public API surface
async function fetchSpansPage(
regionUrl: string,
orgSlug: string,
projectSlug: string,
options: ListSpansOptions = {}
options: ListSpansOptions,
perPage: number
): Promise<PaginatedResponse<SpanListItem[]>> {
const isNumericProject = isAllDigits(projectSlug);
let projectFilter: string;
Expand All @@ -419,8 +453,6 @@ export async function listSpans(
? SPAN_FIELDS.concat(options.extraFields)
: SPAN_FIELDS;

const regionUrl = await resolveOrgRegion(orgSlug);

let projectParam: string | undefined;
if (options.allProjects) {
projectParam = "-1";
Expand All @@ -437,7 +469,7 @@ export async function listSpans(
field: fields,
project: projectParam,
query: fullQuery || undefined,
per_page: options.limit || 10,
per_page: perPage,
statsPeriod:
options.start || options.end
? undefined
Expand All @@ -454,3 +486,38 @@ export async function listSpans(
const { nextCursor } = parseLinkHeader(headers.get("link") ?? null);
return { data: response.data, nextCursor };
}

/**
* List spans using the EAP spans search endpoint.
* Uses the Explore/Events API with dataset=spans.
*
* When `limit` exceeds {@link API_MAX_PER_PAGE}, transparently fetches multiple
* pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}).
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug or numeric ID
* @param options - Query options (query, limit, sort, statsPeriod, cursor)
* @returns Paginated response with span items and optional next cursor
*/
export async function listSpans(
orgSlug: string,
projectSlug: string,
options: ListSpansOptions = {}
): Promise<PaginatedResponse<SpanListItem[]>> {
const regionUrl = await resolveOrgRegion(orgSlug);
const limit = options.limit || 10;
const perPage = Math.min(limit, API_MAX_PER_PAGE);

return autoPaginate(
(cursor) =>
fetchSpansPage(
regionUrl,
orgSlug,
projectSlug,
{ ...options, cursor },
perPage
),
limit,
options.cursor
);
}
9 changes: 9 additions & 0 deletions src/lib/time-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,15 @@ export function timeRangeToApiParams(range: TimeRange): TimeRangeApiParams {
if (range.end) {
params.end = range.end;
}
// Fill missing boundary — the Sentry API requires both start and end
// when absolute dates are used, otherwise it returns 400.
if (params.start && !params.end) {
params.end = new Date().toISOString();
} else if (params.end && !params.start) {
const endDate = new Date(params.end);
endDate.setDate(endDate.getDate() - 90);
params.start = endDate.toISOString();
}
return params;
}

Expand Down
Loading
Loading