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
66 changes: 35 additions & 31 deletions packages/cli/src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import {
createOrganizationDashboard,
getOrganizationDashboard,
listOrganizationDashboardRevisions,
listOrganizationDashboards,
type ListOrganizationEventsTimeseriesData,
listOrganizationEvents,
listOrganizationEventsTimeseries,
restoreOrganizationDashboardRevision,
updateOrganizationDashboard,
} from "@sentry/api";
import { resolveEventsSort } from "@sentry/toolkit-core/events-sort";
Expand Down Expand Up @@ -43,18 +45,16 @@ import type { EventsDataset } from "../datasets.js";
import { reportCliError } from "../error-reporting.js";
import { ApiError, stringifyUnknown } from "../errors.js";

import { resolveOrgRegion } from "../region.js";
import { getSdkClientConfig } from "../sentry-sdk-client.js";

import {
apiRequestToRegion,
getOrgSdkConfig,
ORG_FANOUT_CONCURRENCY,
type PaginatedResponse,
parseLinkHeader,
unwrapPaginatedResult,
unwrapResult,
unwrapValidatedResult,
validateResponse,
} from "./infrastructure.js";

/**
Expand Down Expand Up @@ -192,29 +192,26 @@ export async function listDashboardRevisionsPaginated(
dashboardId: string,
options: { perPage?: number; cursor?: string } = {},
): Promise<PaginatedResponse<DashboardRevision[]>> {
const regionUrl = await resolveOrgRegion(orgSlug);
const params: Record<string, string | number | undefined> = {
per_page: options.perPage,
cursor: options.cursor,
};

const { data, headers } = await apiRequestToRegion<DashboardRevision[]>(
regionUrl,
`/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/`,
{ params, schema: array(DashboardRevisionSchema) },
const config = getOrgSdkConfig();
const result = await listOrganizationDashboardRevisions({
...config,
path: {
organization_id_or_slug: orgSlug,
dashboard_id: dashboardId as unknown as number,
},
query: { per_page: options.perPage, cursor: options.cursor },
});
const { response, ...page } = unwrapPaginatedResult<unknown>(
result,
`Failed to list revisions for dashboard '${dashboardId}'`,
);

const { nextCursor, prevCursor } = parseLinkHeader(
headers.get("link") ?? null,
const data = validateResponse(
array(DashboardRevisionSchema),
page.data,
`/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/`,
response?.status ?? 0,
);
const out: PaginatedResponse<DashboardRevision[]> = { data };
if (nextCursor !== undefined) {
out.nextCursor = nextCursor;
}
if (prevCursor !== undefined) {
out.prevCursor = prevCursor;
}
return out;
return { ...page, data };
}

/**
Expand All @@ -230,14 +227,21 @@ export async function restoreDashboardRevision(
dashboardId: string,
revisionId: string,
): Promise<DashboardDetail> {
const regionUrl = await resolveOrgRegion(orgSlug);
const encodedRevisionId = encodeURIComponent(revisionId);
const { data } = await apiRequestToRegion<DashboardDetail>(
regionUrl,
`/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/${encodedRevisionId}/restore/`,
{ method: "POST", schema: DashboardDetailSchema },
const config = getOrgSdkConfig();
const result = await restoreOrganizationDashboardRevision({
...config,
path: {
organization_id_or_slug: orgSlug,
dashboard_id: dashboardId as unknown as number,
revision_id: revisionId as unknown as number,
},
});
return unwrapValidatedResult(
result,
`Failed to restore revision '${revisionId}' of dashboard '${dashboardId}'`,
DashboardDetailSchema,
`/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/${revisionId}/restore/`,
);
return data;
}

// ---------------------------------------------------------------------------
Expand Down
37 changes: 20 additions & 17 deletions packages/cli/src/lib/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* including trace-associated logs.
*/

import { listOrganizationEvents } from "@sentry/api";
import { listOrganizationEvents, listOrganizationTraceLogs } from "@sentry/api";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import { type GenericSchema, safeParse } from "valibot";
Expand All @@ -21,17 +21,15 @@ import {
} from "../../types/index.js";
import { ApiError } from "../errors.js";
import { decodeUuidV7Timestamp } from "../hex-id.js";
import { resolveOrgRegion } from "../region.js";
import { LOG_RETENTION_PERIOD } from "../retention.js";
import { isAllDigits } from "../utils.js";
import {
API_MAX_PER_PAGE,
apiRequestToRegion,
getOrgSdkConfig,
paginate,
parseLinkHeader,
unwrapPaginatedResult,
unwrapResult,
validateResponse,
} from "./infrastructure.js";
import { getTraceItemDetail } from "./traces.js";

Expand Down Expand Up @@ -478,16 +476,16 @@ export async function listTraceLogs(
traceId: string,
options: ListTraceLogsOptions = {},
): Promise<TraceLog[]> {
const regionUrl = await resolveOrgRegion(orgSlug);
const config = getOrgSdkConfig();

const { data } = await paginate(
options,
async (perPage, cursor) => {
const { data: response, headers } = await apiRequestToRegion<{
data: TraceLog[];
}>(regionUrl, `/organizations/${orgSlug}/trace-logs/`, {
params: {
traceId,
const result = await listOrganizationTraceLogs({
...config,
path: { organization_id_or_slug: orgSlug },
query: {
traceId: [traceId],
statsPeriod:
options.start || options.end
? undefined
Expand All @@ -497,13 +495,21 @@ export async function listTraceLogs(
per_page: perPage,
cursor,
query: options.query,
sort: toApiSort(options.sort),
sort: [toApiSort(options.sort)],
},
schema: TraceLogsResponseSchema,
});

const { nextCursor } = parseLinkHeader(headers.get("link") ?? null);
return { data: response.data, nextCursor };
const page = unwrapPaginatedResult<unknown>(
result,
"Failed to list trace logs",
);
const response = validateResponse(
TraceLogsResponseSchema,
page.data,
`/organizations/${orgSlug}/trace-logs/`,
page.response?.status ?? 0,
);
return { data: response.data, nextCursor: page.nextCursor };
},
API_MAX_PER_PAGE,
);
Expand All @@ -518,9 +524,6 @@ export async function listTraceLogs(
* to enumerate field names. This is the same endpoint the Sentry UI uses when
* expanding a log row to show its full attribute set.
*
* The endpoint is EXPERIMENTAL and not yet in @sentry/api; called directly via
* apiRequestToRegion following the same pattern as listTraceLogs.
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug
* @param logId - The sentry.item_id of the log entry
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/test/lib/api-client.coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2059,8 +2059,9 @@ describe("logs.ts (detailed)", () => {
const req = new Request(input!, init);
const url = new URL(req.url);
expect(url.pathname).toContain("/organizations/test-org/trace-logs/");
expect(url.searchParams.get("traceId")).toBe("abc123def456");
expect(url.searchParams.getAll("traceId")).toEqual(["abc123def456"]);
expect(url.searchParams.get("statsPeriod")).toBe("14d");
expect(url.searchParams.getAll("sort")).toEqual(["-timestamp"]);
return new Response(JSON.stringify(traceLogsResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
Expand Down
114 changes: 88 additions & 26 deletions packages/cli/test/lib/api/dashboards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
computeOptimalInterval,
listDashboardRevisionsPaginated,
periodToSeconds,
queryAllWidgets,
restoreDashboardRevision,
Expand All @@ -19,70 +20,131 @@ import type {
} from "../../../src/types/dashboard.js";

const {
apiRequestToRegionMock,
listOrganizationDashboardRevisionsMock,
listOrganizationEventsMock,
listOrganizationEventsTimeseriesMock,
reportCliErrorMock,
resolveOrgRegionMock,
restoreOrganizationDashboardRevisionMock,
} = vi.hoisted(() => ({
apiRequestToRegionMock: vi.fn(),
listOrganizationDashboardRevisionsMock: vi.fn(),
listOrganizationEventsMock: vi.fn(),
listOrganizationEventsTimeseriesMock: vi.fn(),
reportCliErrorMock: vi.fn(),
resolveOrgRegionMock: vi.fn(),
restoreOrganizationDashboardRevisionMock: vi.fn(),
}));

vi.mock("@sentry/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@sentry/api")>();
return {
...actual,
listOrganizationDashboardRevisions: listOrganizationDashboardRevisionsMock,
listOrganizationEvents: listOrganizationEventsMock,
listOrganizationEventsTimeseries: listOrganizationEventsTimeseriesMock,
restoreOrganizationDashboardRevision:
restoreOrganizationDashboardRevisionMock,
};
});

vi.mock("../../../src/lib/error-reporting.js", () => ({
reportCliError: reportCliErrorMock,
}));

vi.mock("../../../src/lib/region.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/lib/region.js")>();
return { ...actual, resolveOrgRegion: resolveOrgRegionMock };
});

vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../src/lib/api/infrastructure.js")
>();
return { ...actual, apiRequestToRegion: apiRequestToRegionMock };
});

// ---------------------------------------------------------------------------
// restoreDashboardRevision
// ---------------------------------------------------------------------------

describe("restoreDashboardRevision", () => {
test("posts to the revision restore endpoint", async () => {
test("restores the revision through the generated operation", async () => {
const dashboard = {
id: "123",
title: "Restored Dashboard",
widgets: [],
} as DashboardDetail;
resolveOrgRegionMock.mockResolvedValue("https://us.sentry.io");
apiRequestToRegionMock.mockResolvedValue({ data: dashboard });
restoreOrganizationDashboardRevisionMock.mockResolvedValue({
data: dashboard,
response: new Response(),
});

await expect(
restoreDashboardRevision("test-org", "123", "42"),
).resolves.toBe(dashboard);
).resolves.toEqual(dashboard);

expect(apiRequestToRegionMock).toHaveBeenCalledWith(
"https://us.sentry.io",
"/organizations/test-org/dashboards/123/revisions/42/restore/",
expect.objectContaining({ method: "POST" }),
expect(restoreOrganizationDashboardRevisionMock).toHaveBeenCalledWith(
expect.objectContaining({
path: {
organization_id_or_slug: "test-org",
dashboard_id: "123",
revision_id: "42",
},
}),
);
});

test("names the revision when the API rejects the restore", async () => {
restoreOrganizationDashboardRevisionMock.mockResolvedValue({
error: { detail: "Revision not found" },
response: new Response(null, { status: 404, statusText: "Not Found" }),
});

const error = await restoreDashboardRevision("test-org", "123", "42").catch(
(e: unknown) => e,
);
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ status: 404 });
expect((error as ApiError).message).toContain("revision '42'");
});
});

// ---------------------------------------------------------------------------
// listDashboardRevisionsPaginated
// ---------------------------------------------------------------------------

describe("listDashboardRevisionsPaginated", () => {
const revision = {
id: "7",
title: "Before edit",
dateCreated: "2026-11-01T00:00:00Z",
createdBy: null,
source: "edit",
};

test("returns validated revisions and the Link cursors", async () => {
listOrganizationDashboardRevisionsMock.mockResolvedValue({
data: [revision],
response: new Response(null, {
headers: {
link:
'<https://us.sentry.io/x/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", ' +
'<https://us.sentry.io/x/?cursor=0:25:0>; rel="next"; results="true"; cursor="0:25:0"',
},
}),
});

const page = await listDashboardRevisionsPaginated("test-org", "123", {
perPage: 25,
cursor: "0:0:0",
});

expect(page.data).toEqual([revision]);
expect(page.nextCursor).toBe("0:25:0");
expect(listOrganizationDashboardRevisionsMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { organization_id_or_slug: "test-org", dashboard_id: "123" },
query: { per_page: 25, cursor: "0:0:0" },
}),
);
});

test("rejects a response that does not match the revision schema", async () => {
listOrganizationDashboardRevisionsMock.mockResolvedValue({
data: [{ id: 7 }],
response: new Response(),
});

await expect(
listDashboardRevisionsPaginated("test-org", "123"),
).rejects.toThrow("Unexpected response format");
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading