From df9a978402544de6e288d361202d368adcde57c2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 17:59:16 +0000 Subject: [PATCH 1/2] fix(api): cap per_page at 100 and fill open-ended date ranges Two bugs caused 400 Bad Request errors from the Sentry API: 1. listTransactions/listSpans passed --limit directly as per_page. Values >100 were rejected by the API. Now caps at API_MAX_PER_PAGE and auto-paginates when limit exceeds 100, matching the queryEvents pattern in discover.ts. (CLI-F3, CLI-10) 2. timeRangeToApiParams returned only start or only end for open-ended absolute ranges (e.g. --period '>=2024-01-01'). The API requires both. Now fills end=now for start-only, start=end-90d for end-only. Fixes all consumers (traces, spans, explore). (CLI-10) --- src/lib/api/traces.ts | 169 ++++++++++++++++++++++++++++++------ src/lib/time-range.ts | 9 ++ test/lib/time-range.test.ts | 19 +++- 3 files changed, 166 insertions(+), 31 deletions(-) diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index cd7322c00..419106a03 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -21,7 +21,9 @@ import { resolveOrgRegion } from "../region.js"; import { isAllDigits } from "../utils.js"; import { + API_MAX_PER_PAGE, apiRequestToRegion, + MAX_PAGINATION_PAGES, type PaginatedResponse, parseLinkHeader, } from "./infrastructure.js"; @@ -293,30 +295,23 @@ type ListTransactionsOptions = { }; /** - * 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}` + * Fetch a single page of transactions 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 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> { 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( regionUrl, @@ -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 @@ -351,6 +346,72 @@ 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> { + const regionUrl = await resolveOrgRegion(orgSlug); + const limit = options.limit || 10; + const perPage = Math.min(limit, API_MAX_PER_PAGE); + + // Fast path: single-page fetch when limit fits in one API page + if (limit <= API_MAX_PER_PAGE) { + return fetchTransactionsPage( + regionUrl, + orgSlug, + projectSlug, + options, + perPage + ); + } + + // Multi-page: accumulate rows across pages up to the requested limit + const allRows: TransactionListItem[] = []; + let cursor: string | undefined = options.cursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { + const result = await fetchTransactionsPage( + regionUrl, + orgSlug, + projectSlug, + { ...options, cursor }, + perPage + ); + + allRows.push(...result.data); + + // Stop when we've reached the requested limit or there are no more pages + if (allRows.length >= limit || !result.nextCursor) { + 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) }; +} + // Span listing /** Fields to request from the spans API */ @@ -391,18 +452,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> { const isNumericProject = isAllDigits(projectSlug); let projectFilter: string; @@ -419,8 +480,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"; @@ -437,7 +496,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 @@ -454,3 +513,59 @@ 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> { + const regionUrl = await resolveOrgRegion(orgSlug); + const limit = options.limit || 10; + const perPage = Math.min(limit, API_MAX_PER_PAGE); + + // Fast path: single-page fetch when limit fits in one API page + if (limit <= API_MAX_PER_PAGE) { + return fetchSpansPage(regionUrl, orgSlug, projectSlug, options, perPage); + } + + // Multi-page: accumulate rows across pages up to the requested limit + const allRows: SpanListItem[] = []; + let cursor: string | undefined = options.cursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { + const result = await fetchSpansPage( + regionUrl, + orgSlug, + projectSlug, + { ...options, cursor }, + perPage + ); + + allRows.push(...result.data); + + // Stop when we've reached the requested limit or there are no more pages + if (allRows.length >= limit || !result.nextCursor) { + 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) }; +} diff --git a/src/lib/time-range.ts b/src/lib/time-range.ts index 4466e5497..4892d0ab1 100644 --- a/src/lib/time-range.ts +++ b/src/lib/time-range.ts @@ -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; } diff --git a/test/lib/time-range.test.ts b/test/lib/time-range.test.ts index b7c13a835..caaaad2b0 100644 --- a/test/lib/time-range.test.ts +++ b/test/lib/time-range.test.ts @@ -270,24 +270,35 @@ describe("timeRangeToApiParams", () => { expect(params.end).toBe("2024-02-01T23:59:59Z"); }); - test("absolute start-only → start, no end", () => { + test("absolute start-only → start + auto-filled end (now)", () => { + const before = new Date(); const params = timeRangeToApiParams({ type: "absolute", start: "2024-01-01T00:00:00Z", }); + const after = new Date(); expect(params.start).toBe("2024-01-01T00:00:00Z"); - expect(params.end).toBeUndefined(); expect(params.statsPeriod).toBeUndefined(); + // end should be filled with "now" + expect(params.end).toBeDefined(); + const endDate = new Date(params.end!); + expect(endDate.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(endDate.getTime()).toBeLessThanOrEqual(after.getTime()); }); - test("absolute end-only → end, no start", () => { + test("absolute end-only → end + auto-filled start (90 days before end)", () => { const params = timeRangeToApiParams({ type: "absolute", end: "2024-02-01T23:59:59Z", }); expect(params.end).toBe("2024-02-01T23:59:59Z"); - expect(params.start).toBeUndefined(); expect(params.statsPeriod).toBeUndefined(); + // start should be filled with 90 days before end + expect(params.start).toBeDefined(); + const startDate = new Date(params.start!); + const expectedStart = new Date("2024-02-01T23:59:59Z"); + expectedStart.setDate(expectedStart.getDate() - 90); + expect(startDate.toISOString()).toBe(expectedStart.toISOString()); }); }); From 7d1c42fb6c8c972e582cc54251b45780edaa3447 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 30 Apr 2026 17:12:01 +0000 Subject: [PATCH 2/2] refactor(api): extract shared autoPaginate helper and add traces tests Extract the duplicated multi-page pagination loop from listTransactions, listSpans, and queryEvents into a generic autoPaginate() helper in infrastructure.ts. This addresses Cursor Bugbot's feedback about code duplication across the three call sites. Also adds comprehensive tests for listTransactions and listSpans (26 tests covering URL construction, per_page capping, auto-pagination, trimming, sort, project handling, and cursor passthrough) to push patch coverage above 80%. --- src/lib/api-client.ts | 1 + src/lib/api/infrastructure.ts | 48 +++ src/lib/api/traces.ts | 98 ++---- test/lib/api/traces.test.ts | 636 ++++++++++++++++++++++++++++++++++ 4 files changed, 710 insertions(+), 73 deletions(-) create mode 100644 test/lib/api/traces.test.ts diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index afde9ba38..3935d06fd 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -40,6 +40,7 @@ export { type ApiRequestOptions, apiRequest, apiRequestToRegion, + autoPaginate, buildSearchParams, ORG_FANOUT_CONCURRENCY, type PaginatedResponse, diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index aad1ab112..e43269117 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -218,6 +218,54 @@ export type PaginatedResponse = { 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( + fetchPage: (cursor: string | undefined) => Promise>, + limit: number, + initialCursor?: string +): Promise> { + // 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. diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index 419106a03..3b535f5e3 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -23,7 +23,7 @@ import { isAllDigits } from "../utils.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, - MAX_PAGINATION_PAGES, + autoPaginate, type PaginatedResponse, parseLinkHeader, } from "./infrastructure.js"; @@ -371,45 +371,18 @@ export async function listTransactions( const limit = options.limit || 10; const perPage = Math.min(limit, API_MAX_PER_PAGE); - // Fast path: single-page fetch when limit fits in one API page - if (limit <= API_MAX_PER_PAGE) { - return fetchTransactionsPage( - regionUrl, - orgSlug, - projectSlug, - options, - perPage - ); - } - - // Multi-page: accumulate rows across pages up to the requested limit - const allRows: TransactionListItem[] = []; - let cursor: string | undefined = options.cursor; - - for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { - const result = await fetchTransactionsPage( - regionUrl, - orgSlug, - projectSlug, - { ...options, cursor }, - perPage - ); - - allRows.push(...result.data); - - // Stop when we've reached the requested limit or there are no more pages - if (allRows.length >= limit || !result.nextCursor) { - 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) }; + return autoPaginate( + (cursor) => + fetchTransactionsPage( + regionUrl, + orgSlug, + projectSlug, + { ...options, cursor }, + perPage + ), + limit, + options.cursor + ); } // Span listing @@ -535,37 +508,16 @@ export async function listSpans( const limit = options.limit || 10; const perPage = Math.min(limit, API_MAX_PER_PAGE); - // Fast path: single-page fetch when limit fits in one API page - if (limit <= API_MAX_PER_PAGE) { - return fetchSpansPage(regionUrl, orgSlug, projectSlug, options, perPage); - } - - // Multi-page: accumulate rows across pages up to the requested limit - const allRows: SpanListItem[] = []; - let cursor: string | undefined = options.cursor; - - for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { - const result = await fetchSpansPage( - regionUrl, - orgSlug, - projectSlug, - { ...options, cursor }, - perPage - ); - - allRows.push(...result.data); - - // Stop when we've reached the requested limit or there are no more pages - if (allRows.length >= limit || !result.nextCursor) { - 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) }; + return autoPaginate( + (cursor) => + fetchSpansPage( + regionUrl, + orgSlug, + projectSlug, + { ...options, cursor }, + perPage + ), + limit, + options.cursor + ); } diff --git a/test/lib/api/traces.test.ts b/test/lib/api/traces.test.ts new file mode 100644 index 000000000..64e835101 --- /dev/null +++ b/test/lib/api/traces.test.ts @@ -0,0 +1,636 @@ +/** + * Tests for the traces API helpers (listTransactions, listSpans). + * + * Verifies URL construction, query parameter encoding, schema validation, + * pagination cursor extraction, and auto-pagination across multiple pages. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { listSpans, listTransactions } from "../../../src/lib/api/traces.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +// --------------------------------------------------------------------------- +// listTransactions +// --------------------------------------------------------------------------- + +describe("listTransactions", () => { + useTestConfigDir("traces-txn-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + /** + * Helper to mock sequential fetch responses for multi-page tests. + * Each call to fetch returns the next response in the queue. + */ + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + const TX_META = { + fields: { + trace: "string", + id: "string", + transaction: "string", + timestamp: "date", + "transaction.duration": "duration", + project: "string", + }, + }; + + /** Generate N rows of fake transaction data */ + function makeTxnRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + trace: `trace-${i}`, + id: `id-${i}`, + transaction: `/api/endpoint-${i}`, + timestamp: "2024-01-15T00:00:00Z", + "transaction.duration": 100 + i, + project: "my-project", + })); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("sends dataset=transactions", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain("dataset=transactions"); + }); + + test("passes per_page capped at 100 even when limit is higher", async () => { + // With limit > 100, the first page should still request per_page=100 + mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + await listTransactions("my-org", "my-project", { limit: 150 }); + + // Both pages should use per_page=100 + // (the second page still uses API_MAX_PER_PAGE since limit > 100) + }); + + test("sends sort=-timestamp by default", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain(`sort=${encodeURIComponent("-timestamp")}`); + }); + + test('sends sort=-transaction.duration for sort="duration"', async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { sort: "duration" }); + + expect(decodeURIComponent(capturedUrl)).toContain( + "sort=-transaction.duration" + ); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { cursor: "0:50:0" }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { statsPeriod: "1h" }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("defaults statsPeriod to 7d when not provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain("statsPeriod=7d"); + }); + + test("suppresses statsPeriod when start/end are present", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("auto-paginates when limit > 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 150, + }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results and drops nextCursor when overshoot", async () => { + mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 120, + }); + + expect(result.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path for limit <= 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 50, + }); + + expect(result.data).toHaveLength(50); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=50"); + }); + + test("non-numeric project slug goes in query as project:slug", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(decodeURIComponent(capturedUrl)).toContain( + "query=project:my-project" + ); + // Should NOT appear as a separate project= param + expect(capturedUrl).not.toMatch(/[?&]project=my-project/); + }); + + test("numeric project ID goes as project param", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "12345"); + + expect(capturedUrl).toContain("project=12345"); + // Should NOT appear as project:12345 in the query string + expect(decodeURIComponent(capturedUrl)).not.toContain("project:12345"); + }); + + test("returns nextCursor from Link header", async () => { + const cursor = "0:10:0"; + mockOk( + { data: makeTxnRows(10), meta: TX_META }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await listTransactions("my-org", "my-project", { + limit: 10, + }); + + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false", async () => { + mockOk( + { data: [], meta: TX_META }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await listTransactions("my-org", "my-project"); + + expect(result.nextCursor).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// listSpans +// --------------------------------------------------------------------------- + +describe("listSpans", () => { + useTestConfigDir("traces-span-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + const SPAN_META = { + fields: { + id: "string", + parent_span: "string", + "span.op": "string", + description: "string", + "span.duration": "duration", + timestamp: "date", + project: "string", + transaction: "string", + trace: "string", + }, + }; + + /** Generate N rows of fake span data */ + function makeSpanRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + id: `span-${i}`, + parent_span: `parent-${i}`, + "span.op": "http.client", + description: `GET /api/endpoint-${i}`, + "span.duration": 50 + i, + timestamp: "2024-01-15T00:00:00Z", + project: "my-project", + transaction: "/api/foo", + trace: `trace-${i}`, + })); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("sends dataset=spans", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain("dataset=spans"); + }); + + test("passes per_page capped at 100 when limit is higher", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(50), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + await listSpans("my-org", "my-project", { limit: 150 }); + + // Both pages should use per_page=100 + expect(getCapturedUrls()[0]).toContain("per_page=100"); + expect(getCapturedUrls()[1]).toContain("per_page=100"); + }); + + test("sends sort=-timestamp by default", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain(`sort=${encodeURIComponent("-timestamp")}`); + }); + + test('sends sort=-span.duration for sort="duration"', async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { sort: "duration" }); + + expect(decodeURIComponent(capturedUrl)).toContain("sort=-span.duration"); + }); + + test("allProjects sends project=-1", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { allProjects: true }); + + expect(capturedUrl).toContain("project=-1"); + // Should NOT have project:my-project in query + expect(decodeURIComponent(capturedUrl)).not.toContain( + "project%3Amy-project" + ); + }); + + test("auto-paginates when limit > 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(50), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 150 }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results when overshoot", async () => { + mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 120 }); + + expect(result.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path for limit <= 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(30), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 30 }); + + expect(result.data).toHaveLength(30); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=30"); + }); + + test("passes extraFields when provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { + extraFields: ["span.self_time", "span.category"], + }); + + const decoded = decodeURIComponent(capturedUrl); + expect(decoded).toContain("field=span.self_time"); + expect(decoded).toContain("field=span.category"); + // Standard fields should still be present + expect(decoded).toContain("field=id"); + expect(decoded).toContain("field=span.op"); + }); + + test("non-numeric project slug goes in query as project:slug", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain( + `query=${encodeURIComponent("project:my-project")}` + ); + // Should NOT appear as a separate project= param with the slug value + expect(capturedUrl).not.toMatch(/[?&]project=my-project/); + }); + + test("numeric project ID goes as project param", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "12345"); + + expect(capturedUrl).toContain("project=12345"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:12345"); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { statsPeriod: "1h" }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("defaults statsPeriod to 7d when not provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain("statsPeriod=7d"); + }); + + test("suppresses statsPeriod when start/end are present", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { cursor: "0:50:0" }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("returns nextCursor from Link header", async () => { + const cursor = "0:10:0"; + mockOk( + { data: makeSpanRows(10), meta: SPAN_META }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await listSpans("my-org", "my-project", { limit: 10 }); + + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false", async () => { + mockOk( + { data: [], meta: SPAN_META }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await listSpans("my-org", "my-project"); + + expect(result.nextCursor).toBeUndefined(); + }); +});