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: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ Get the client ID from your Sentry OAuth application settings.
## Running Locally

```bash
bun run --env-file=.env.local src/bin.ts auth login
bun run --env-file=.env.local cli auth login
```

## Testing the Device Flow

1. Run the CLI login command:

```bash
bun run --env-file=.env.local src/bin.ts auth login
bun run --env-file=.env.local cli auth login
```

2. You'll see output like:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,10 @@ bun install

```bash
# Run CLI in development mode
bun run dev --help
bun run cli --help

# With environment variables
bun run --env-file=.env.local src/bin.ts --help
bun run --env-file=.env.local cli --help
```

### Scripts
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cd cli
bun install

# Run CLI in development mode
bun run --env-file=.env.local src/bin.ts --help
bun run --env-file=.env.local cli --help

# Run tests
bun test
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@sentry/core@10.50.0": "patches/@sentry%2Fcore@10.50.0.patch"
},
"scripts": {
"cli": "bun run src/bin.ts",
"dev": "bun run generate:schema && bun run generate:docs && bun run generate:sdk && bun run src/bin.ts",
"build": "bun run generate:schema && bun run generate:docs && bun run generate:sdk && bun run script/build.ts --single",
"build:all": "bun run generate:schema && bun run generate:docs && bun run generate:sdk && bun run script/build.ts",
Expand Down
3 changes: 2 additions & 1 deletion src/commands/replay/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ async function fetchReplayActivity(
const segments = await getReplayRecordingSegments(
org,
String(replay.project_id),
replay.id
replay.id,
{ expectedSegments: replay.count_segments }
);
return extractReplayActivityEvents(segments, MAX_ACTIVITY_EVENTS);
} catch (error) {
Expand Down
72 changes: 68 additions & 4 deletions src/lib/api/replays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
API_MAX_PER_PAGE,
apiRequestToRegion,
autoPaginate,
MAX_PAGINATION_PAGES,
type PaginatedResponse,
parseLinkHeader,
} from "./infrastructure.js";
Expand Down Expand Up @@ -104,6 +105,29 @@ type FetchReplayPageOptions = {
cursor?: string;
};

type FetchReplayRecordingSegmentsPageOptions = {
regionUrl: string;
orgSlug: string;
projectSlugOrId: string;
replayId: string;
cursor?: string;
};

/** Options for {@link getReplayRecordingSegments}. */
export type GetReplayRecordingSegmentsOptions = {
/**
* Soft stop hint: total segment count from replay metadata.
*
* Pagination stops as soon as this many segments have been fetched, even if
* the API advertises another cursor. Because metadata can be slightly stale,
* the result is NOT trimmed to this value — callers may receive more segments
* than expected if the final page overshoots.
*
* Omit (or pass null/undefined) to fetch all pages up to MAX_PAGINATION_PAGES.
*/
expectedSegments?: number | null;
};

/**
* Coerce numeric project_id to string for consistent downstream handling.
*
Expand Down Expand Up @@ -214,22 +238,62 @@ export async function getReplay(
* Uses the project-scoped replay endpoint because recording segments are
* partitioned by project. `download=true` matches the frontend contract and
* returns the parsed segment payload directly.
*
* Uses a manual pagination loop rather than {@link autoPaginate} because
* `autoPaginate` trims results to `limit`, but `expectedSegments` is a soft
* hint — trimming could silently drop real segments if metadata is stale.
*/
export async function getReplayRecordingSegments(
orgSlug: string,
projectSlugOrId: string,
replayId: string
replayId: string,
options: GetReplayRecordingSegmentsOptions = {}
): Promise<ReplayRecordingSegments> {
const regionUrl = await resolveOrgRegion(orgSlug);
const { data } = await apiRequestToRegion<ReplayRecordingSegments>(
const expectedSegments = options.expectedSegments ?? Number.POSITIVE_INFINITY;
const segments: ReplayRecordingSegments = [];
let cursor: string | undefined;

for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) {
const { data, nextCursor } = await fetchReplayRecordingSegmentsPage({
regionUrl,
orgSlug,
projectSlugOrId,
replayId,
cursor,
});

segments.push(...data);

if (segments.length >= expectedSegments || !nextCursor) {
return segments;
}

cursor = nextCursor;
}

return segments;
}

async function fetchReplayRecordingSegmentsPage(
options: FetchReplayRecordingSegmentsPageOptions
): Promise<PaginatedResponse<ReplayRecordingSegments>> {
const { cursor, orgSlug, projectSlugOrId, regionUrl, replayId } = options;
const { data, headers } = await apiRequestToRegion<ReplayRecordingSegments>(
regionUrl,
`/projects/${orgSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/`,
{
params: { download: true },
params: {
cursor,
download: true,
per_page: API_MAX_PER_PAGE,
},
schema: ReplayRecordingSegmentsSchema,
}
);
return data;

const { nextCursor } = parseLinkHeader(headers.get("link") ?? null);
return { data, nextCursor };
}

/**
Expand Down
1 change: 0 additions & 1 deletion src/types/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ export const REPLAY_LIST_FIELDS = [
"info_ids",
"is_archived",
"os",
"ota_updates",
"platform",
"project_id",
"releases",
Expand Down
6 changes: 6 additions & 0 deletions test/commands/replay/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ describe("viewCommand.func", () => {
expect(parsed.relatedIssues[0]?.shortId).toBe("CLI-123");
expect(parsed.relatedTraces[0]?.spanCount).toBe(8);
expect(parsed.trace_ids[0]).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
expect(getReplayRecordingSegmentsSpy).toHaveBeenCalledWith(
"test-org",
"42",
REPLAY_ID,
{ expectedSegments: 5 }
);
expect(listIssuesPaginatedSpy).toHaveBeenCalledWith(
"test-org",
"",
Expand Down
93 changes: 88 additions & 5 deletions test/lib/api/replays.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { MAX_PAGINATION_PAGES } from "../../../src/lib/api/infrastructure.js";
import {
getReplay,
getReplayRecordingSegments,
Expand All @@ -29,6 +30,24 @@ function replayRow(id = REPLAY_ID) {
};
}

function recordingSegmentsResponse(
body: unknown,
nextCursor?: string
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};

if (nextCursor) {
headers.Link = `<https://sentry.io/api/0/next/>; rel="next"; results="true"; cursor="${nextCursor}"`;
}

return new Response(JSON.stringify(body), {
status: 200,
headers,
});
}

describe("listReplays", () => {
let originalFetch: typeof globalThis.fetch;

Expand Down Expand Up @@ -73,7 +92,6 @@ describe("listReplays", () => {
expect(url.searchParams.get("statsPeriod")).toBe("24h");
expect(url.searchParams.get("per_page")).toBe("25");
expect(url.searchParams.getAll("field")).toContain("id");
expect(url.searchParams.getAll("field")).toContain("ota_updates");
expect(url.searchParams.getAll("field")).toContain("user");
expect(result.data).toHaveLength(1);
expect(result.nextCursor).toBe("0:25:0");
Expand Down Expand Up @@ -244,10 +262,7 @@ describe("getReplayRecordingSegments", () => {
globalThis.fetch = mockFetch(async (input, init) => {
const req = new Request(input!, init);
capturedUrl = req.url;
return new Response(JSON.stringify([[{ timestamp: 1 }]]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
return recordingSegmentsResponse([[{ timestamp: 1 }]]);
});

const segments = await getReplayRecordingSegments(
Expand All @@ -261,8 +276,76 @@ describe("getReplayRecordingSegments", () => {
`/api/0/projects/test-org/42/replays/${REPLAY_ID}/recording-segments/`
);
expect(url.searchParams.get("download")).toBe("true");
expect(url.searchParams.get("per_page")).toBe("100");
expect(segments).toEqual([[{ timestamp: 1 }]]);
});

test("auto-paginates recording segments using the link cursor", async () => {
const capturedUrls: string[] = [];
let callIndex = 0;

globalThis.fetch = mockFetch(async (input, init) => {
const req = new Request(input!, init);
capturedUrls.push(req.url);

const body =
callIndex === 0
? Array.from({ length: 100 }, (_, index) => [{ segment: index }])
: [[{ segment: 100 }]];
const nextCursor = callIndex === 0 ? "0:100:0" : undefined;
callIndex += 1;

return recordingSegmentsResponse(body, nextCursor);
});

const segments = await getReplayRecordingSegments(
"test-org",
"42",
REPLAY_ID,
{ expectedSegments: 101 }
);

expect(segments).toHaveLength(101);
expect(capturedUrls).toHaveLength(2);

const firstUrl = new URL(capturedUrls[0]!);
expect(firstUrl.searchParams.get("per_page")).toBe("100");
expect(firstUrl.searchParams.get("cursor")).toBeNull();

const secondUrl = new URL(capturedUrls[1]!);
expect(secondUrl.searchParams.get("cursor")).toBe("0:100:0");
expect(secondUrl.searchParams.get("per_page")).toBe("100");
});

test("stops recording segment pagination at the safety cap", async () => {
const capturedUrls: string[] = [];
let callIndex = 0;

globalThis.fetch = mockFetch(async (input, init) => {
const req = new Request(input!, init);
capturedUrls.push(req.url);

const nextCursor = `0:${(callIndex + 1) * 100}:0`;
const body = [[{ segment: callIndex }]];
callIndex += 1;

return recordingSegmentsResponse(body, nextCursor);
});

const segments = await getReplayRecordingSegments(
"test-org",
"42",
REPLAY_ID
);

expect(segments).toHaveLength(MAX_PAGINATION_PAGES);
expect(capturedUrls).toHaveLength(MAX_PAGINATION_PAGES);

const finalUrl = new URL(capturedUrls.at(-1)!);
expect(finalUrl.searchParams.get("cursor")).toBe(
`0:${(MAX_PAGINATION_PAGES - 1) * 100}:0`
);
});
});

describe("listReplayIdsForIssue", () => {
Expand Down
Loading