From d2dad465a89aab792a99b2eae51dbc921f9e2345 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:55:38 +0000 Subject: [PATCH] ref(cli): Move API calls with generated operations onto @sentry/api Bump @sentry/api to 1.44.0 and move the apiRequest* call sites that have a matching generated operation onto it: alert rule delete, chunk upload options and assemble (debug files, sourcemaps, dart symbols, proguard), AI conversation detail, short ID resolve, preprod install details and snapshot latest base, release commit refs, replay detail, trace meta, and the transactions/spans event pages. Add validateResponse/unwrapValidatedResult so call sites that already check responses with a Zod schema keep doing that on SDK results. This is step 1 of moving the CLI onto generated operations. The remaining sites are blocked on missing public schema entries or type mismatches and stay on the helpers for now. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../skills/sentry-cli/references/alert.md | 50 ++--- .../skills/sentry-cli/references/event.md | 72 +++---- packages/cli/src/lib/api/alerts.ts | 17 +- packages/cli/src/lib/api/chunk-upload.ts | 113 ++++++++--- packages/cli/src/lib/api/conversations.ts | 62 +++--- packages/cli/src/lib/api/dart-symbols.ts | 35 +--- packages/cli/src/lib/api/debug-files.ts | 38 +--- packages/cli/src/lib/api/infrastructure.ts | 90 ++++++--- packages/cli/src/lib/api/issues.ts | 33 ++-- packages/cli/src/lib/api/preprod-artifacts.ts | 43 +++-- packages/cli/src/lib/api/proguard.ts | 43 +---- packages/cli/src/lib/api/releases.ts | 26 +-- packages/cli/src/lib/api/replays.ts | 23 ++- packages/cli/src/lib/api/sourcemaps.ts | 17 +- packages/cli/src/lib/api/traces.ts | 151 ++++++++------- .../cli/test/lib/api/chunk-upload.test.ts | 179 +++++++++++++----- packages/cli/test/lib/api/debug-files.test.ts | 175 ++++++++--------- .../test/lib/api/preprod-artifacts.test.ts | 93 ++++++--- packages/cli/test/lib/api/proguard.test.ts | 66 +++---- pnpm-lock.yaml | 25 ++- pnpm-workspace.yaml | 4 +- 21 files changed, 762 insertions(+), 593 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index a24671da9..dd12d2bfc 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -190,18 +190,18 @@ Edit an issue alert rule |-------|------|-------------| | `id` | string | Alert rule ID | | `name` | string | Alert rule name | -| `organizationId` | string | | -| `createdBy` | string \| null | | -| `dateCreated` | string | | -| `dateUpdated` | string | | -| `triggers` | object \| null | | -| `actionFilters` | array \| null | | -| `environment` | string \| null | | -| `config` | object | | -| `detectorIds` | array \| null | | -| `enabled` | boolean | | -| `lastTriggered` | string \| null | | -| `owner` | string \| null | | +| `organizationId` | string | ID of the organization the alert belongs to | +| `createdBy` | string \| null | ID of the user who created the alert | +| `dateCreated` | string | When the alert was created (ISO 8601) | +| `dateUpdated` | string | When the alert was last updated (ISO 8601) | +| `triggers` | object \| null | Conditions on which the alert triggers | +| `actionFilters` | array \| null | Filters to run before actions fire, each with the actions to fire | +| `environment` | string \| null | Name of the environment the alert is limited to. Null for all environments | +| `config` | object | Configuration of the alert, such as frequency in minutes | +| `detectorIds` | array \| null | IDs of the monitors connected to the alert | +| `enabled` | boolean | Whether the alert is enabled | +| `lastTriggered` | string \| null | When the alert last fired (ISO 8601) | +| `owner` | string \| null | Team or user that owns the alert, such as user:123 or team:456 | | `org` | string | Organization slug | | `project` | string | Project slug | | `status` | string | Rule status (active, disabled) | @@ -389,21 +389,21 @@ Edit a metric alert rule | Field | Type | Description | |-------|------|-------------| -| `owner` | object \| null | | -| `createdBy` | string \| null | | -| `latestGroup` | object \| null | | -| `description` | string \| null | | +| `owner` | object \| null | Team or user that owns the monitor | +| `createdBy` | string \| null | ID of the user who created the monitor | +| `latestGroup` | object \| null | Latest issue the monitor created | +| `description` | string \| null | Description of the monitor | | `id` | string | Alert rule ID | -| `projectId` | string \| null | | +| `projectId` | string \| null | ID of the project the monitor belongs to | | `name` | string | Alert rule name | -| `type` | string | | -| `workflowIds` | array \| null | | -| `dateCreated` | string | | -| `dateUpdated` | string | | -| `dataSources` | array \| null | | -| `conditionGroup` | object \| null | | -| `config` | object | | -| `enabled` | boolean | | +| `type` | string | Type of the monitor, such as metric_issue or error | +| `workflowIds` | array \| null | IDs of the alerts connected to the monitor | +| `dateCreated` | string | When the monitor was created (ISO 8601) | +| `dateUpdated` | string | When the monitor was last updated (ISO 8601) | +| `dataSources` | array \| null | Data sources the monitor evaluates, such as a query subscription | +| `conditionGroup` | object \| null | Conditions that decide the monitor's status | +| `config` | object | Configuration of the monitor, which depends on its type | +| `enabled` | boolean | Whether the monitor is enabled | | `org` | string | Organization slug | | `status` | string | Rule status (active, disabled) | diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 0fd78ece8..b4134347d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -30,44 +30,44 @@ View details of one or more events | Field | Type | Description | |-------|------|-------------| -| `id` | string | | -| `groupID` | string \| null | | +| `id` | string | ID of the event | +| `groupID` | string \| null | ID of the issue the event belongs to | | `eventID` | string | UUID-format event ID | -| `projectID` | string | | -| `message` | string \| null | | -| `title` | string | | -| `location` | string \| null | | -| `user` | object \| null | | -| `tags` | array | | -| `platform` | string \| null | | -| `dateReceived` | string \| null | | -| `contexts` | object \| null | | -| `size` | number \| null | | -| `entries` | array | | -| `dist` | string \| null | | -| `sdk` | object \| null | | -| `context` | object \| null | | -| `packages` | object | | -| `type` | string | | -| `metadata` | object | | -| `errors` | array | | +| `projectID` | string | ID of the project the event belongs to | +| `message` | string \| null | Event message | +| `title` | string | Event title | +| `location` | string \| null | Source location of the event, such as a file and line | +| `user` | object \| null | User context of the event | +| `tags` | array | Tags attached to the event | +| `platform` | string \| null | Platform of the event, such as python or javascript | +| `dateReceived` | string \| null | When Sentry received the event (ISO 8601) | +| `contexts` | object \| null | Contexts of the event, such as device, os, browser and runtime | +| `size` | number \| null | Size of the event payload in bytes | +| `entries` | array | Event interfaces, such as exception, breadcrumbs and request, each with a type and data | +| `dist` | string \| null | Distribution of the release the event was sent with | +| `sdk` | object \| null | SDK that sent the event | +| `context` | object \| null | Additional data sent with the event in its extra field | +| `packages` | object | Modules loaded when the event occurred, keyed by module name with their versions | +| `type` | string | Event type, such as error, default or transaction | +| `metadata` | object | Event metadata | +| `errors` | array | Errors Sentry found while processing the event | | `occurrence` | object \| null | | -| `_meta` | object | | -| `crashFile` | string \| null | | -| `culprit` | string \| null | | -| `dateCreated` | string | | -| `fingerprints` | array | | -| `groupingConfig` | object | | -| `startTimestamp` | number | | -| `endTimestamp` | number | | -| `measurements` | object \| null | | -| `breakdowns` | object \| null | | -| `release` | object \| null | | -| `userReport` | object \| null | | -| `sdkUpdates` | array | | -| `resolvedWith` | array | | -| `nextEventID` | string \| null | | -| `previousEventID` | string \| null | | +| `_meta` | object | Metadata about changes Sentry made to the event's fields, such as data scrubbing or trimming | +| `crashFile` | string \| null | Crash report attachment of the event, if any | +| `culprit` | string \| null | Function or module that caused the event | +| `dateCreated` | string | When the event occurred (ISO 8601) | +| `fingerprints` | array | Grouping hashes calculated for the event | +| `groupingConfig` | object | Grouping configuration used to group the event | +| `startTimestamp` | number | When the transaction started, as a Unix timestamp in seconds | +| `endTimestamp` | number | When the transaction ended, as a Unix timestamp in seconds | +| `measurements` | object \| null | Measurements recorded for the transaction, such as web vitals, keyed by name | +| `breakdowns` | object \| null | Span operation breakdowns for the transaction, keyed by breakdown name | +| `release` | object \| null | Release the event was sent with. Null when the event has no release | +| `userReport` | object \| null | User feedback submitted for the event. Null when there's none | +| `sdkUpdates` | array | Suggested updates for the SDK that sent the event | +| `resolvedWith` | array | How the event's stack frames were resolved during processing, such as which source map lookup method was used | +| `nextEventID` | string \| null | ID of the next event in the issue. Null for the latest event or when adjacentEvents is collapsed | +| `previousEventID` | string \| null | ID of the previous event in the issue. Null for the first event or when adjacentEvents is collapsed | | `formatted` | object | Event rendered as Markdown, XML, or JSON; only returned for API requests with llmFormat | | `trace` | object \| null | Trace context, or null when unavailable | | `attachments` | array | Event attachments; each includes metadata and an absolute authenticated download URL | diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 1169822cd..7933258e5 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -10,6 +10,7 @@ import { createOrganizationProjectDetector, createOrganizationWorkflow, deleteOrganizationDetector, + deleteOrganizationWorkflow, getOrganizationDetector, type GetOrganizationDetectorResponse, getOrganizationWorkflow, @@ -35,7 +36,6 @@ import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { apiRequestToRegion, - apiRequestToRegionNoContent, getOrgSdkConfig, type PaginatedResponse, parseLinkHeader, @@ -513,12 +513,15 @@ export async function deleteIssueAlertRule( orgSlug: string, ruleId: string, ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - await apiRequestToRegionNoContent( - regionUrl, - `/organizations/${orgSlug}/workflows/${encodeURIComponent(ruleId)}/`, - { method: "DELETE" }, - ); + const config = await getOrgSdkConfig(orgSlug); + const result = await deleteOrganizationWorkflow({ + ...config, + path: { + organization_id_or_slug: orgSlug, + workflow_id: Number(ruleId), + }, + }); + unwrapResult(result, `Failed to delete issue alert rule '${ruleId}'`); } /** diff --git a/packages/cli/src/lib/api/chunk-upload.ts b/packages/cli/src/lib/api/chunk-upload.ts index f10a1e80d..1aac07c97 100644 --- a/packages/cli/src/lib/api/chunk-upload.ts +++ b/packages/cli/src/lib/api/chunk-upload.ts @@ -24,23 +24,29 @@ import { constants as zlibConstants, zstdCompress as zstdCompressCb, } from "node:zlib"; +import { + type AssembleOrganizationArtifactBundleData, + type AssembleProjectDebugFilesData, + assembleOrganizationArtifactBundle, + assembleProjectDebugFiles, + getOrganizationChunkUploadOptions, +} from "@sentry/api"; import pLimit from "p-limit"; import { array, - type GenericSchema, type InferOutput, nullish, number, object, optional, picklist, + record, string, } from "valibot"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; -import { resolveOrgRegion } from "../region.js"; import { getSdkConfig } from "../sentry-client.js"; -import { apiRequestToRegion } from "./infrastructure.js"; +import { getOrgSdkConfig, unwrapValidatedResult } from "./infrastructure.js"; const gzipAsync = promisify(gzipCb); // zstdCompress exists only on Node.js 22.15+. On older runtimes (npm package @@ -96,6 +102,11 @@ export const AssembleResponseSchema = object({ export type AssembleResponse = InferOutput; +/** Response from the DIF assemble endpoint, keyed by file checksum. */ +const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); + +export type DifAssembleResponse = InferOutput; + // ── Types ─────────────────────────────────────────────────────────── /** Chunk metadata after splitting the ZIP for upload. */ @@ -145,11 +156,17 @@ export type UploadEncoding = (typeof UPLOAD_CODECS)[number]; export async function getChunkUploadOptions( orgSlug: string, ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationChunkUploadOptions({ + ...config, + path: { organization_id_or_slug: orgSlug }, + }); + // The schema also reads `maxWait`, which the generated type lacks. + const data = unwrapValidatedResult( + result, + "Failed to get chunk upload options", + ChunkServerOptionsSchema, `organizations/${orgSlug}/chunk-upload/`, - { schema: ChunkServerOptionsSchema }, ); // Self-hosted Sentry instances may return a chunk-upload URL that embeds @@ -572,36 +589,84 @@ export async function uploadMissingBufferChunks(params: { } /** - * Poll an assemble endpoint until the server reports completion. + * Request DIF assembly for one batch of files. + * + * The server answers with one entry per file checksum in the body. Sending the + * same body again polls the assembly state. + * + * @param org - Organization slug + * @param project - Project slug + * @param body - Files to assemble, keyed by overall checksum + */ +export async function assembleDebugFiles( + org: string, + project: string, + body: NonNullable, +): Promise { + const config = await getOrgSdkConfig(org); + const result = await assembleProjectDebugFiles({ + ...config, + path: { organization_id_or_slug: org, project_id_or_slug: project }, + body, + }); + return unwrapValidatedResult( + result, + "Failed to assemble debug files", + DifAssembleResponseSchema, + `projects/${org}/${project}/files/difs/assemble/`, + ); +} + +/** + * Request assembly of an artifact bundle. Sending the same body again polls + * the assembly state. * - * Re-sends the same assemble body on each poll (the server uses + * @param org - Organization slug + * @param body - Bundle checksum, chunk checksums, projects and release + */ +export async function assembleArtifactBundle( + org: string, + body: NonNullable, +): Promise { + const config = await getOrgSdkConfig(org); + const result = await assembleOrganizationArtifactBundle({ + ...config, + path: { organization_id_or_slug: org }, + body, + }); + return unwrapValidatedResult( + result, + "Failed to assemble artifact bundle", + AssembleResponseSchema, + `organizations/${org}/artifactbundle/assemble/`, + ); +} + +/** + * Poll an assemble request until the server reports completion. + * + * `assemble` re-sends the same assemble body on each poll (the server uses * the checksum to look up existing assembly state). By default returns when * the state is `"ok"` or `"created"`; set `waitForOk` to block until the server * finishes processing (`"ok"` only). Throws on `"error"` or timeout. * - * @param params.regionUrl - Region base URL - * @param params.endpoint - The endpoint path to POST to - * @param params.body - The request body to send on each poll + * @param params.assemble - Sends the assemble request and returns its state + * @param params.endpoint - The assemble endpoint path, for error messages * @param params.entityName - Human-readable name for error messages - * @param params.schema - Valibot schema for the response (defaults to {@link AssembleResponseSchema}) * @param params.waitForOk - Keep polling on `"created"`, returning only on `"ok"` * @param params.deadlineMs - Override the default poll timeout window */ export async function pollAssembly(params: { - regionUrl: string; + assemble: () => Promise; endpoint: string; - body: unknown; entityName: string; - schema?: GenericSchema; waitForOk?: boolean; deadlineMs?: number; }): Promise { const { - regionUrl, + assemble, endpoint, - body, entityName, - schema = AssembleResponseSchema, waitForOk = false, deadlineMs = ASSEMBLE_MAX_WAIT_MS, } = params; @@ -610,15 +675,7 @@ export async function pollAssembly(params: { while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - const { data: pollResult } = await apiRequestToRegion( - regionUrl, - endpoint, - { - method: "POST", - body, - schema, - }, - ); + const pollResult = await assemble(); if (pollResult.state === "ok") { return; diff --git a/packages/cli/src/lib/api/conversations.ts b/packages/cli/src/lib/api/conversations.ts index 88e7139d6..d2504c4e4 100644 --- a/packages/cli/src/lib/api/conversations.ts +++ b/packages/cli/src/lib/api/conversations.ts @@ -4,14 +4,14 @@ * Functions for listing and retrieving agent conversation data from the Sentry * Explore agent conversations endpoints. * - * The `/organizations/{org}/agents/conversations/` endpoints are PRIVATE and not - * yet in `@sentry/api` (getsentry/sentry-api-schema). Call them via - * `apiRequestToRegion` with local Valibot schemas (same pattern as `logs.ts` / - * `traces.ts`). Details response shape is documented on - * `AgentConversationDetailsSchema`. Pagination uses `parseLinkHeader`. Revisit - * once these endpoints land in `@sentry/api`. + * Both endpoints are in `@sentry/api`. The list still goes through + * `apiRequestToRegion` because its generated query has no `sort`. The details + * call uses the generated operation; its spans are untyped records there, so + * the response is still checked with `AgentConversationDetailsSchema`. + * Pagination follows the `Link` header. */ +import { retrieveOrganizationAiConversation } from "@sentry/api"; import { array } from "valibot"; import { @@ -29,10 +29,13 @@ import { resolveOrgRegion } from "../region.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, + getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, paginate, parseLinkHeader, + unwrapPaginatedResult, + validateResponse, } from "./infrastructure.js"; const log = logger.withTag("api.conversations"); @@ -136,15 +139,8 @@ export async function getConversationSpans( truncated: boolean; title: string | null; }> { - const regionUrl = await resolveOrgRegion(orgSlug); - - const params: Record = { - per_page: String(options.perPage ?? 1000), - statsPeriod: options.statsPeriod ?? "30d", - }; - if (options.project) { - params.project = options.project; - } + const config = await getOrgSdkConfig(orgSlug); + const endpoint = `/organizations/${orgSlug}/agents/conversations/${encodeURIComponent(conversationId)}/`; const spans: AgentConversationSpan[] = []; let stats: ConversationStats | undefined; @@ -152,24 +148,36 @@ export async function getConversationSpans( let cursor: string | undefined; for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - if (cursor) { - params.cursor = cursor; - } - - const { data, headers } = - await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/agents/conversations/${encodeURIComponent(conversationId)}/`, - { params, schema: AgentConversationDetailsSchema }, - ); + const result = await retrieveOrganizationAiConversation({ + ...config, + path: { + organization_id_or_slug: orgSlug, + conversation_id: conversationId, + }, + query: { + per_page: options.perPage ?? 1000, + statsPeriod: options.statsPeriod ?? "30d", + project: options.project ? [options.project] : undefined, + cursor, + }, + }); + const pageResult = unwrapPaginatedResult( + result, + "Failed to get agent conversation", + ); + const data: AgentConversationDetails = validateResponse( + AgentConversationDetailsSchema, + pageResult.data, + endpoint, + pageResult.response?.status ?? 0, + ); if (page === 0) { stats = data.stats; title = data.title; } spans.push(...data.spans); - const parsed = parseLinkHeader(headers.get("link") ?? null); - cursor = parsed.nextCursor; + cursor = pageResult.nextCursor; if (!cursor) { break; } diff --git a/packages/cli/src/lib/api/dart-symbols.ts b/packages/cli/src/lib/api/dart-symbols.ts index 6ca5212e8..52da154b8 100644 --- a/packages/cli/src/lib/api/dart-symbols.ts +++ b/packages/cli/src/lib/api/dart-symbols.ts @@ -11,7 +11,6 @@ * debug file (dSYM/ELF). */ -import { type InferOutput, record, string } from "valibot"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -19,14 +18,14 @@ import { ASSEMBLE_MAX_WAIT_MS, ASSEMBLE_POLL_INTERVAL_MS, type AssembleResponse, - AssembleResponseSchema, + assembleDebugFiles, type ChunkInfo, + type DifAssembleResponse, getChunkUploadOptions, hashBuffer, pickUploadEncoding, uploadMissingBufferChunks, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; const log = logger.withTag("api.dart-symbols"); @@ -52,16 +51,6 @@ export type DartSymbolMapUploadOptions = { mapping: DartSymbolMap; }; -// Schemas - -/** - * DIF assemble response — keyed by overall checksum, each value has - * the same shape as the standard assemble response. - */ -const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); - -type DifAssembleResponse = InferOutput; - // Helpers /** Result of checking a DIF assemble response. */ @@ -152,15 +141,7 @@ export async function uploadDartSymbolMap( }; // Step 4: Request DIF assembly - const { data: firstAssemble } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: DifAssembleResponseSchema, - }, - ); + const firstAssemble = await assembleDebugFiles(org, project, assembleBody); const { allDone, missingChecksums } = checkAssembleResponse( firstAssemble, @@ -187,15 +168,7 @@ export async function uploadDartSymbolMap( while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - const { data: pollResult } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: DifAssembleResponseSchema, - }, - ); + const pollResult = await assembleDebugFiles(org, project, assembleBody); const { allDone: done } = checkAssembleResponse( pollResult, diff --git a/packages/cli/src/lib/api/debug-files.ts b/packages/cli/src/lib/api/debug-files.ts index c40e5387a..02d212bb1 100644 --- a/packages/cli/src/lib/api/debug-files.ts +++ b/packages/cli/src/lib/api/debug-files.ts @@ -18,7 +18,6 @@ * collecting `error` details so the caller can report and exit non-zero. */ -import { type InferOutput, record, string } from "valibot"; import { ApiError, ValidationError } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -26,18 +25,18 @@ import { ASSEMBLE_MAX_WAIT_MS, ASSEMBLE_POLL_INTERVAL_MS, type AssembleResponse, - AssembleResponseSchema, + assembleDebugFiles, type ChunkInfo, type ChunkPayload, type ChunkServerOptions, DEFAULT_MAX_DIF_SIZE, + type DifAssembleResponse, getChunkUploadOptions, hashBuffer, pickUploadEncoding, type UploadEncoding, uploadChunkPayloads, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; const log = logger.withTag("api.debug-files"); @@ -105,16 +104,6 @@ export type DebugFileUploadResult = { detail: string | null; }; -// ── Schemas ───────────────────────────────────────────────────────── - -/** - * DIF assemble response — keyed by overall checksum, each value has the same - * shape as the standard assemble response. - */ -const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); - -type DifAssembleResponse = InferOutput; - // ── Internal types ────────────────────────────────────────────────── /** @@ -315,22 +304,13 @@ async function uploadMissing(params: { /** POST one assemble request per batch and merge the keyed responses. */ async function postAssemble( - regionUrl: string, - endpoint: string, - bodies: readonly unknown[], + org: string, + project: string, + bodies: readonly Record[], ): Promise { const merged: DifAssembleResponse = {}; for (const body of bodies) { - const { data } = await apiRequestToRegion( - regionUrl, - endpoint, - { - method: "POST", - body, - schema: DifAssembleResponseSchema, - }, - ); - Object.assign(merged, data); + Object.assign(merged, await assembleDebugFiles(org, project, body)); } return merged; } @@ -503,7 +483,7 @@ export async function uploadDebugFiles( const effectiveMaxWaitMs = wait ? clampMaxWait(maxWaitMs, serverOptions.maxWait ?? 0) : maxWaitMs; - let response = await postAssemble(regionUrl, endpoint, bodies); + let response = await postAssemble(org, project, bodies); let evaluation = evaluateAssembly(response, chunked, wait); if (evaluation.missingChecksums.size > 0) { await uploadMissing({ @@ -515,7 +495,7 @@ export async function uploadDebugFiles( }); // The server only starts assembling once it is asked again with the // chunks in place, so re-assemble right after delivering them. - response = await postAssemble(regionUrl, endpoint, bodies); + response = await postAssemble(org, project, bodies); evaluation = evaluateAssembly(response, chunked, wait); } @@ -541,7 +521,7 @@ export async function uploadDebugFiles( } await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - response = await postAssemble(regionUrl, endpoint, bodies); + response = await postAssemble(org, project, bodies); evaluation = evaluateAssembly(response, chunked, wait); await uploadMissing({ chunked, diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index cdf9c3793..610bcdf1c 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -806,35 +806,77 @@ export async function apiRequestToRegion( } if (schema) { - const result = safeParse(schema, data); - if (!result.success) { - // Attach structured validation issues to the Sentry event so we can - // diagnose exactly which field(s) failed validation — the ApiError.detail - // string alone may not be visible in the Sentry issue overview. - // Strip valibot issues to metadata only (path/type/message) before - // attaching — full issues embed the raw failing `input` value. - Sentry.setContext("schema_validation", { - endpoint, - status: response.status, - issues: result.issues.slice(0, 10).map((i) => ({ - // Strip raw input from path items — only keep structural path keys. - path: i.path?.map((p) => ({ key: p.key, type: p.type })), - type: i.type, - message: i.message, - })), - }); - throw new ApiError( - `Unexpected response format from ${endpoint}`, - response.status, - result.issues.map((issue) => issue.message).join(", "), - ); - } - return { data: result.output, headers: response.headers }; + return { + data: validateResponse(schema, data, endpoint, response.status), + headers: response.headers, + }; } return { data: data as T, headers: response.headers }; } +/** + * Validate a response body against the CLI's schema for it. + * + * Used for raw requests and for `@sentry/api` results whose generated type is + * looser than what the CLI reads (e.g. untyped span records). + * + * @param schema - Valibot schema for the response body + * @param data - Parsed response body + * @param endpoint - API path, named in the error message + * @param status - HTTP status of the response + * @throws {ApiError} When the body does not match the schema + */ +export function validateResponse( + schema: GenericSchema, + data: unknown, + endpoint: string, + status: number, +): T { + const result = safeParse(schema, data); + if (!result.success) { + // Attach structured validation issues to the Sentry event so we can + // diagnose exactly which field(s) failed validation — the ApiError.detail + // string alone may not be visible in the Sentry issue overview. + // Strip valibot issues to metadata only (path/type/message) before + // attaching — full issues embed the raw failing `input` value. + Sentry.setContext("schema_validation", { + endpoint, + status, + issues: result.issues.slice(0, 10).map((i) => ({ + // Strip raw input from path items — only keep structural path keys. + path: i.path?.map((p) => ({ key: p.key, type: p.type })), + type: i.type, + message: i.message, + })), + }); + throw new ApiError( + `Unexpected response format from ${endpoint}`, + status, + result.issues.map((issue) => issue.message).join(", "), + ); + } + return result.output; +} + +/** + * Unwrap an @sentry/api SDK result and validate its body with the CLI's schema. + * + * @param result - The result from an SDK function call + * @param context - Human-readable context for HTTP error messages + * @param schema - Valibot schema for the response body + * @param endpoint - API path, named in schema-mismatch errors + */ +export function unwrapValidatedResult( + result: Pick, + context: string, + schema: GenericSchema, + endpoint: string, +): T { + const data = unwrapResult(result, context); + return validateResponse(schema, data, endpoint, result.response?.status ?? 0); +} + /** * Extract error detail from a failed HTTP response, attach diagnostic * headers to the Sentry scope, and throw an enriched {@link ApiError}. diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 654744594..67be8f909 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -5,7 +5,10 @@ */ import type { ListOrganizationIssuesData } from "@sentry/api"; -import { listOrganizationIssues } from "@sentry/api"; +import { + listOrganizationIssues, + resolveOrganizationShortId, +} from "@sentry/api"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; @@ -29,6 +32,7 @@ import { type PaginatedResponse, paginate, unwrapPaginatedResult, + unwrapResult, } from "./infrastructure.js"; const TRAILING_SLASH_RE = /\/$/; @@ -299,9 +303,9 @@ export function getIssue( * Uses the org-scoped endpoint with region-aware routing. * Preferred over {@link getIssue} when the org slug is available. * - * Uses raw `apiRequestToRegion` instead of the SDK's `retrieveAnIssue` - * because the SDK types declare `query?: never`, blocking `collapse` - * and other query parameters. See: https://github.com/getsentry/sentry-api-schema/issues/63 + * Uses raw `apiRequestToRegion` instead of the SDK's `getOrganizationIssue` + * because its `collapse` type allows only `release`, `stats` and `tags`, not + * the `lifetime`, `filtered` and `unhandled` values the CLI sends. * * @param orgSlug - Organization slug (used for region routing) * @param issueId - Numeric issue ID @@ -328,10 +332,6 @@ export async function getIssueInOrg( * Requires organization context to resolve the short ID. * Uses region-aware routing for multi-region support. * - * Uses raw `apiRequestToRegion` instead of the SDK's `resolveAShortId` - * because the SDK types declare `query?: never`, blocking `collapse` - * and other query parameters. See: https://github.com/getsentry/sentry-api-schema/ - * * @param orgSlug - Organization slug * @param shortId - Short ID (e.g., "CLI-G5", "SPOTLIGHT-ELECTRON-4D") * @param options - Optional collapse fields to skip expensive backend queries @@ -342,18 +342,19 @@ export async function getIssueByShortId( options?: { collapse?: IssueCollapseField[] }, ): Promise { const normalizedShortId = shortId.toUpperCase(); - const regionUrl = await resolveOrgRegion(orgSlug); + const config = await getOrgSdkConfig(orgSlug); let data: { group?: SentryIssue }; try { - const result = await apiRequestToRegion<{ group?: SentryIssue }>( - regionUrl, - `/organizations/${orgSlug}/shortids/${normalizedShortId}/`, - { - params: options?.collapse ? { collapse: options.collapse } : undefined, - }, + const result = await resolveOrganizationShortId({ + ...config, + path: { organization_id_or_slug: orgSlug, issue_id: normalizedShortId }, + query: options?.collapse ? { collapse: options.collapse } : undefined, + }); + data = unwrapResult<{ group?: SentryIssue }>( + result, + "Failed to resolve short ID", ); - data = result.data; } catch (error) { // Enrich 404 errors with actionable context. The generic // "Failed to resolve short ID: 404 Not Found" is the most common diff --git a/packages/cli/src/lib/api/preprod-artifacts.ts b/packages/cli/src/lib/api/preprod-artifacts.ts index c96176c97..f39ca3356 100644 --- a/packages/cli/src/lib/api/preprod-artifacts.ts +++ b/packages/cli/src/lib/api/preprod-artifacts.ts @@ -15,6 +15,10 @@ import { createWriteStream } from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { + getOrganizationPreprodArtifactInstallDetails, + getOrganizationPreprodArtifactSnapshotLatestBase, +} from "@sentry/api"; import { array, boolean, @@ -46,6 +50,8 @@ import { import { apiRequestToRegion, apiRequestToRegionNoContent, + getOrgSdkConfig, + unwrapValidatedResult, } from "./infrastructure.js"; const log = logger.withTag("api.preprod-artifacts"); @@ -78,14 +84,19 @@ export async function getBuildInstallDetails( org: string, buildId: string, ): Promise { - const regionUrl = await resolveOrgRegion(org); - const endpoint = `organizations/${org}/preprodartifacts/${encodeURIComponent( - buildId, - )}/install-details/`; - const { data } = await apiRequestToRegion(regionUrl, endpoint, { - schema: BuildInstallDetailsSchema, + const config = await getOrgSdkConfig(org); + const result = await getOrganizationPreprodArtifactInstallDetails({ + ...config, + path: { organization_id_or_slug: org, artifact_id: buildId }, }); - return data; + return unwrapValidatedResult( + result, + "Failed to get build install details", + BuildInstallDetailsSchema, + `organizations/${org}/preprodartifacts/${encodeURIComponent( + buildId, + )}/install-details/`, + ); } /** @@ -380,15 +391,19 @@ export async function getLatestBaseSnapshot( appId: string, opts: { branch?: string; project?: string } = {}, ): Promise { - const regionUrl = await resolveOrgRegion(org); + const config = await getOrgSdkConfig(org); try { - const { data } = await apiRequestToRegion( - regionUrl, + const result = await getOrganizationPreprodArtifactSnapshotLatestBase({ + ...config, + path: { organization_id_or_slug: org }, + query: { app_id: appId, branch: opts.branch, project: opts.project }, + }); + // The generated type has no null body; the CLI treats one as "no base". + const data = unwrapValidatedResult( + result, + "Failed to get latest base snapshot", + nullable(LatestBaseSnapshotSchema), `organizations/${org}/preprodartifacts/snapshots/latest-base/`, - { - params: { app_id: appId, branch: opts.branch, project: opts.project }, - schema: nullable(LatestBaseSnapshotSchema), - }, ); return data ? { headArtifactId: data.head_artifact_id, imageCount: data.image_count } diff --git a/packages/cli/src/lib/api/proguard.ts b/packages/cli/src/lib/api/proguard.ts index 5f2147488..84f8ce8cf 100644 --- a/packages/cli/src/lib/api/proguard.ts +++ b/packages/cli/src/lib/api/proguard.ts @@ -12,7 +12,6 @@ * - Multiple files are sent in a single assemble request */ -import { type InferOutput, record, string } from "valibot"; import { ApiError } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -20,14 +19,14 @@ import { ASSEMBLE_MAX_WAIT_MS, ASSEMBLE_POLL_INTERVAL_MS, type AssembleResponse, - AssembleResponseSchema, + assembleDebugFiles, type ChunkInfo, + type DifAssembleResponse, getChunkUploadOptions, hashBuffer, pickUploadEncoding, uploadMissingBufferChunks, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; const log = logger.withTag("api.proguard"); @@ -53,16 +52,6 @@ export type ProguardUploadOptions = { mappings: ProguardMapping[]; }; -// ── Schemas ───────────────────────────────────────────────────────── - -/** - * DIF assemble response — keyed by overall checksum, each value has - * the same shape as the standard assemble response. - */ -const DifAssembleResponseSchema = record(string(), AssembleResponseSchema); - -type DifAssembleResponse = InferOutput; - // ── Internal types ────────────────────────────────────────────────── /** Per-mapping chunk metadata computed before the assemble request. */ @@ -168,15 +157,7 @@ export async function uploadProguardMappings( } // Step 4: Request DIF assembly — server reports which chunks it needs - const { data: firstAssemble } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: DifAssembleResponseSchema, - }, - ); + const firstAssemble = await assembleDebugFiles(org, project, assembleBody); const checksums = chunkedMappings.map((cm) => cm.overallChecksum); const { allDone, missingChecksums } = checkAssembleResponse( @@ -203,9 +184,8 @@ export async function uploadProguardMappings( // Step 6: Poll assemble endpoint until all mappings are done await pollDifAssembly({ - regionUrl, + assemble: () => assembleDebugFiles(org, project, assembleBody), endpoint: assembleEndpoint, - body: assembleBody, checksums, }); } @@ -217,26 +197,17 @@ export async function uploadProguardMappings( * the state of every checksum entry until all are `"ok"` or `"created"`. */ async function pollDifAssembly(params: { - regionUrl: string; + assemble: () => Promise; endpoint: string; - body: unknown; checksums: string[]; }): Promise { - const { regionUrl, endpoint, body, checksums } = params; + const { assemble, endpoint, checksums } = params; const deadline = Date.now() + ASSEMBLE_MAX_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); - const { data: pollResult } = await apiRequestToRegion( - regionUrl, - endpoint, - { - method: "POST", - body, - schema: DifAssembleResponseSchema, - }, - ); + const pollResult = await assemble(); const { allDone } = checkAssembleResponse(pollResult, checksums, endpoint); if (allDone) { diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 649e210cf..6ae8c3bc0 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -741,24 +741,24 @@ export async function setCommitsWithRefs( version: string, refs: CommitRef[], ): Promise { - const encodedOrg = encodeApiPathSegment(orgSlug); - if (encodedOrg === null) { + // The SDK percent-encodes path values but lets `.` and `..` through, which + // would collapse the path; reject them here. + if (encodeApiPathSegment(orgSlug) === null) { throw new ValidationError("Invalid organization slug", "organization"); } - const encodedVersion = encodeApiPathSegment(version); - if (encodedVersion === null) { + if (encodeApiPathSegment(version) === null) { throw new ValidationError("Invalid release version", "version"); } - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, - `organizations/${encodedOrg}/releases/${encodedVersion}/`, - { - method: "PUT", - body: { refs }, - }, + const config = await getOrgSdkConfig(orgSlug); + const result = await updateOrganizationRelease({ + ...config, + path: { organization_id_or_slug: orgSlug, version }, + body: { refs }, + }); + return unwrapResult( + result, + `Failed to set commits on release '${version}'`, ); - return data; } /** diff --git a/packages/cli/src/lib/api/replays.ts b/packages/cli/src/lib/api/replays.ts index f1a73234b..7a0cb27df 100644 --- a/packages/cli/src/lib/api/replays.ts +++ b/packages/cli/src/lib/api/replays.ts @@ -5,6 +5,7 @@ */ import { + getOrganizationReplay, type ListProjectReplayRecordingSegmentsResponse, listProjectReplayRecordingSegments, } from "@sentry/api"; @@ -35,6 +36,7 @@ import { paginate, parseLinkHeader, unwrapPaginatedResult, + unwrapValidatedResult, } from "./infrastructure.js"; const log = logger.withTag("api.replays"); @@ -253,16 +255,19 @@ export async function getReplay( orgSlug: string, replayId: string, ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationReplay({ + ...config, + path: { organization_id_or_slug: orgSlug, replay_id: replayId }, + }); + const data = unwrapValidatedResult( + result, + "Failed to get replay", + ReplayDetailsResponseSchema as GenericSchema< + unknown, + ReplayDetailsResponse + >, `/organizations/${orgSlug}/replays/${replayId}/`, - { - schema: ReplayDetailsResponseSchema as GenericSchema< - unknown, - ReplayDetailsResponse - >, - }, ); return normalizeReplayProjectId(data.data); } diff --git a/packages/cli/src/lib/api/sourcemaps.ts b/packages/cli/src/lib/api/sourcemaps.ts index 67425a1ad..3cbf7f9f7 100644 --- a/packages/cli/src/lib/api/sourcemaps.ts +++ b/packages/cli/src/lib/api/sourcemaps.ts @@ -23,8 +23,7 @@ import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { type ZipCompression, ZipWriter } from "../sourcemap/zip.js"; import { - type AssembleResponse, - AssembleResponseSchema, + assembleArtifactBundle, type ChunkInfo, type ChunkServerOptions, getChunkUploadOptions, @@ -34,7 +33,6 @@ import { type UploadEncoding, uploadMissingChunks, } from "./chunk-upload.js"; -import { apiRequestToRegion } from "./infrastructure.js"; // ── Re-exports for backward compatibility ─────────────────────────── // These were originally defined in this module. External consumers @@ -333,15 +331,7 @@ async function uploadArtifactBundle(opts: { }; const assembleEndpoint = `organizations/${org}/artifactbundle/assemble/`; - const { data: firstAssemble } = await apiRequestToRegion( - regionUrl, - assembleEndpoint, - { - method: "POST", - body: assembleBody, - schema: AssembleResponseSchema, - }, - ); + const firstAssemble = await assembleArtifactBundle(org, assembleBody); // Fail fast on server-side assembly error if (firstAssemble.state === "error") { @@ -377,9 +367,8 @@ async function uploadArtifactBundle(opts: { // Step 6: Poll assemble endpoint. With --wait, block until fully processed // ("ok"); otherwise return as soon as the chunks are accepted ("created"). await pollAssembly({ - regionUrl, + assemble: () => assembleArtifactBundle(org, assembleBody), endpoint: assembleEndpoint, - body: assembleBody, entityName: "Artifact bundle", waitForOk: wait, deadlineMs: maxWaitMs, diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 5cb23d332..559a300aa 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -5,10 +5,12 @@ */ import { + getOrganizationTraceMeta, type ListOrganizationTraceItemAttributesData, type ListOrganizationTraceItemAttributesQueryAttributeType, type ListOrganizationTraceItemAttributesQueryDataset, type ListOrganizationTraceItemAttributesResponse, + listOrganizationEvents, listOrganizationTraceItemAttributes, } from "@sentry/api"; import pLimit from "p-limit"; @@ -40,8 +42,9 @@ import { getOrgSdkConfig, type PaginatedResponse, paginate, - parseLinkHeader, unwrapPaginatedResult, + unwrapValidatedResult, + validateResponse, } from "./infrastructure.js"; const log = logger.withTag("api.traces"); @@ -383,17 +386,18 @@ export async function getTraceMeta( traceId: string, statsPeriod = "14d", ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - - const { data } = await apiRequestToRegion( - regionUrl, + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationTraceMeta({ + ...config, + path: { organization_id_or_slug: orgSlug, trace_id: traceId }, + query: { statsPeriod }, + }); + return unwrapValidatedResult( + result, + "Failed to get trace metadata", + TraceMetaSchema, `/organizations/${orgSlug}/trace-meta/${traceId}/`, - { - params: { statsPeriod }, - schema: TraceMetaSchema, - }, ); - return data; } // --------------------------------------------------------------------------- @@ -565,7 +569,6 @@ type ListTransactionsOptions = { * multi-page (auto-paginating) fetches. */ async function fetchTransactionsPage( - regionUrl: string, orgSlug: string, projectSlug: string, options: ListTransactionsOptions, @@ -583,38 +586,40 @@ async function fetchTransactionsPage( .filter(Boolean) .join(" "); - const { data: response, headers } = - await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/events/`, - { - params: { - dataset: "spans", - field: TRANSACTION_FIELDS, - project: - numericProjectId === undefined - ? undefined - : String(numericProjectId), - // Convert empty string to undefined so ky omits the param entirely; - // sending `query=` causes the Sentry API to behave differently than - // omitting the parameter. - query: fullQuery || undefined, - per_page: perPage, - statsPeriod: - options.start || options.end - ? undefined - : (options.statsPeriod ?? "7d"), - start: options.start, - end: options.end, - sort: options.sort === "duration" ? "-span.duration" : "-timestamp", - cursor: options.cursor, - }, - schema: TransactionsResponseSchema, - }, - ); - - const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data: response.data, nextCursor }; + const result = await listOrganizationEvents({ + ...(await getOrgSdkConfig(orgSlug)), + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: "spans", + field: TRANSACTION_FIELDS, + project: + numericProjectId === undefined ? undefined : [String(numericProjectId)], + // Convert empty string to undefined so the param is omitted entirely; + // sending `query=` causes the Sentry API to behave differently than + // omitting the parameter. + query: fullQuery || undefined, + per_page: perPage, + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, + sort: options.sort === "duration" ? "-span.duration" : "-timestamp", + cursor: options.cursor, + }, + }); + const page = unwrapPaginatedResult( + result, + "Failed to list transactions", + ); + const response: TransactionsResponse = validateResponse( + TransactionsResponseSchema, + page.data, + `/organizations/${orgSlug}/events/`, + page.response?.status ?? 0, + ); + return { data: response.data, nextCursor: page.nextCursor }; } /** @@ -639,10 +644,8 @@ export async function listTransactions( projectSlug: string, options: ListTransactionsOptions = {}, ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); return await paginate(options, (perPage, cursor) => fetchTransactionsPage( - regionUrl, orgSlug, projectSlug, { ...options, cursor }, @@ -703,7 +706,6 @@ type ListSpansOptions = { * multi-page (auto-paginating) fetches. */ async function fetchSpansPage( - regionUrl: string, orgSlug: string, projectSlug: string, options: ListSpansOptions, @@ -731,31 +733,33 @@ async function fetchSpansPage( projectParam = String(numericProjectId); } - const { data: response, headers } = await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/events/`, - { - params: { - dataset: "spans", - field: fields, - project: projectParam, - query: fullQuery || undefined, - per_page: perPage, - statsPeriod: - options.start || options.end - ? undefined - : (options.statsPeriod ?? "7d"), - start: options.start, - end: options.end, - sort: options.sort === "duration" ? "-span.duration" : "-timestamp", - cursor: options.cursor, - }, - schema: SpansResponseSchema, + const result = await listOrganizationEvents({ + ...(await getOrgSdkConfig(orgSlug)), + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: "spans", + field: fields, + project: projectParam === undefined ? undefined : [projectParam], + query: fullQuery || undefined, + per_page: perPage, + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, + sort: options.sort === "duration" ? "-span.duration" : "-timestamp", + cursor: options.cursor, }, + }); + const page = unwrapPaginatedResult(result, "Failed to list spans"); + const response: SpansResponse = validateResponse( + SpansResponseSchema, + page.data, + `/organizations/${orgSlug}/events/`, + page.response?.status ?? 0, ); - - const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data: response.data, nextCursor }; + return { data: response.data, nextCursor: page.nextCursor }; } /** @@ -775,14 +779,7 @@ export async function listSpans( projectSlug: string, options: ListSpansOptions = {}, ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); return await paginate(options, (perPage, cursor) => - fetchSpansPage( - regionUrl, - orgSlug, - projectSlug, - { ...options, cursor }, - perPage, - ), + fetchSpansPage(orgSlug, projectSlug, { ...options, cursor }, perPage), ); } diff --git a/packages/cli/test/lib/api/chunk-upload.test.ts b/packages/cli/test/lib/api/chunk-upload.test.ts index c42461be5..18f26fba7 100644 --- a/packages/cli/test/lib/api/chunk-upload.test.ts +++ b/packages/cli/test/lib/api/chunk-upload.test.ts @@ -1,33 +1,63 @@ /** - * Tests for the chunk-upload server-options schema. + * Tests for the chunk-upload server options, the assemble requests, and + * assembly polling. * * Focuses on the optional `maxFileSize` / `maxWait` fields added for * `debug-files upload`: a server response that omits them must still parse - * (backward compatibility), and present values must be carried through. + * (backward compatibility), and present values must be carried through. The + * request functions are exercised against a mocked `fetch`. */ import { safeParse } from "valibot"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { + type AssembleResponse, + assembleArtifactBundle, + assembleDebugFiles, ChunkServerOptionsSchema, getChunkUploadOptions, pollAssembly, } from "../../../src/lib/api/chunk-upload.js"; -import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; -import { resolveOrgRegion } from "../../../src/lib/region.js"; - -vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("../../../src/lib/api/infrastructure.js") - >(); - return { ...actual, apiRequestToRegion: vi.fn() }; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("api-chunk-upload-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token"); + setOrgRegion("my-org", "https://us.sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; }); -vi.mock("../../../src/lib/region.js", () => ({ - resolveOrgRegion: vi.fn().mockResolvedValue("https://sentry.example.com"), -})); -const apiMock = vi.mocked(apiRequestToRegion); -const regionMock = vi.mocked(resolveOrgRegion); + +/** Captured request from {@link respondWith}. */ +type CapturedRequest = { url: string; method: string; body: unknown }; + +/** Answer every request with `body` and record what was sent. */ +function respondWith(body: unknown, status = 200): CapturedRequest[] { + const requests: CapturedRequest[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + const text = await req.text(); + requests.push({ + url: req.url, + method: req.method, + body: text ? JSON.parse(text) : undefined, + }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }); + return requests; +} const BASE = { url: "https://us.sentry.io/api/0/chunk-upload/", @@ -39,36 +69,99 @@ const BASE = { compression: ["gzip"], }; -describe("getChunkUploadOptions: credential stripping", () => { - beforeEach(() => { - apiMock.mockReset(); - regionMock.mockResolvedValue("https://sentry.example.com"); - }); - +describe("getChunkUploadOptions", () => { test("strips embedded credentials from the server-returned chunk-upload URL", async () => { - apiMock.mockResolvedValue({ - data: { - ...BASE, - url: "https://08d1f409cc5d402783b7ce48189675e8@bugsink.example.com/8/api/0/organizations/my-org/chunk-upload/", - }, - } as unknown as Awaited>); + const requests = respondWith({ + ...BASE, + url: "https://08d1f409cc5d402783b7ce48189675e8@bugsink.example.com/8/api/0/organizations/my-org/chunk-upload/", + }); const result = await getChunkUploadOptions("my-org"); expect(result.url).toBe( "https://bugsink.example.com/8/api/0/organizations/my-org/chunk-upload/", ); + expect(requests[0]?.url).toBe( + "https://us.sentry.io/api/0/organizations/my-org/chunk-upload/", + ); }); test("leaves a credential-free URL unchanged", async () => { - apiMock.mockResolvedValue({ - data: { ...BASE }, - } as unknown as Awaited>); + respondWith({ ...BASE }); const result = await getChunkUploadOptions("my-org"); expect(result.url).toBe(BASE.url); }); + + test("keeps maxWait, which the generated response type lacks", async () => { + respondWith({ ...BASE, maxWait: 300 }); + + const result = await getChunkUploadOptions("my-org"); + + expect(result.maxWait).toBe(300); + }); + + test("rejects a response that does not match the schema", async () => { + respondWith({ url: BASE.url }); + + await expect(getChunkUploadOptions("my-org")).rejects.toThrow( + "Unexpected response format from organizations/my-org/chunk-upload/", + ); + }); + + test("throws an ApiError on an HTTP error", async () => { + respondWith({ detail: "Organization not found" }, 404); + + const error = await getChunkUploadOptions("my-org").catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(404); + expect((error as ApiError).detail).toBe("Organization not found"); + }); +}); + +describe("assemble requests", () => { + test("assembleDebugFiles posts the body to the project DIF endpoint", async () => { + const requests = respondWith({ abc: { state: "ok", missingChunks: [] } }); + const body = { + abc: { name: "libfoo.so", debug_id: "id-1", chunks: ["c1"] }, + }; + + const result = await assembleDebugFiles("my-org", "my-project", body); + + expect(result).toEqual({ abc: { state: "ok", missingChunks: [] } }); + expect(requests).toEqual([ + { + url: "https://us.sentry.io/api/0/projects/my-org/my-project/files/difs/assemble/", + method: "POST", + body, + }, + ]); + }); + + test("assembleArtifactBundle posts the body to the org bundle endpoint", async () => { + const requests = respondWith({ state: "created", missingChunks: [] }); + const body = { checksum: "abc", chunks: ["c1"], projects: ["my-project"] }; + + const result = await assembleArtifactBundle("my-org", body); + + expect(result.state).toBe("created"); + expect(requests).toEqual([ + { + url: "https://us.sentry.io/api/0/organizations/my-org/artifactbundle/assemble/", + method: "POST", + body, + }, + ]); + }); + + test("assembleArtifactBundle rejects an unknown assemble state", async () => { + respondWith({ state: "bogus", missingChunks: [] }); + + await expect( + assembleArtifactBundle("my-org", { checksum: "abc", chunks: [] }), + ).rejects.toThrow("Unexpected response format"); + }); }); describe("ChunkServerOptionsSchema", () => { @@ -96,43 +189,37 @@ describe("ChunkServerOptionsSchema", () => { }); describe("pollAssembly: waitForOk", () => { + const assemble = vi.fn<() => Promise>(); const params = { - regionUrl: "https://us.sentry.io", + assemble, endpoint: "org/artifactbundle/assemble/", - body: {}, entityName: "Artifact bundle", }; beforeEach(() => { vi.useFakeTimers(); - apiMock.mockReset(); + assemble.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); test("default (waitForOk=false) returns as soon as state is 'created'", async () => { - apiMock.mockResolvedValue({ - data: { state: "created" }, - } as unknown as Awaited>); + assemble.mockResolvedValue({ state: "created" }); const promise = pollAssembly(params); await vi.advanceTimersByTimeAsync(1000); await expect(promise).resolves.toBeUndefined(); - expect(apiMock).toHaveBeenCalledTimes(1); + expect(assemble).toHaveBeenCalledTimes(1); }); test("waitForOk=true keeps polling through 'created' until 'ok'", async () => { - apiMock - .mockResolvedValueOnce({ - data: { state: "created" }, - } as unknown as Awaited>) - .mockResolvedValueOnce({ - data: { state: "ok" }, - } as unknown as Awaited>); + assemble + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "ok" }); const promise = pollAssembly({ ...params, waitForOk: true }); await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000); await expect(promise).resolves.toBeUndefined(); - expect(apiMock).toHaveBeenCalledTimes(2); + expect(assemble).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/cli/test/lib/api/debug-files.test.ts b/packages/cli/test/lib/api/debug-files.test.ts index acd57f8ef..fc3113855 100644 --- a/packages/cli/test/lib/api/debug-files.test.ts +++ b/packages/cli/test/lib/api/debug-files.test.ts @@ -1,7 +1,7 @@ /** * Tests for the debug information file upload API. * - * Mocks the chunk-upload primitives and the region request layer so the + * Mocks the chunk-upload primitives and the assemble request so the * assemble body shape, missing-chunk upload, and the no-wait/wait completion * modes can be exercised without a network. `hashBuffer` and `pickUploadEncoding` * remain real (they are pure). @@ -16,8 +16,8 @@ import { } from "../../../src/lib/api/debug-files.js"; import { ApiError } from "../../../src/lib/errors.js"; -const { apiRequestToRegionMock, uploadChunkPayloadsMock } = vi.hoisted(() => ({ - apiRequestToRegionMock: vi.fn(), +const { assembleMock, uploadChunkPayloadsMock } = vi.hoisted(() => ({ + assembleMock: vi.fn(), uploadChunkPayloadsMock: vi.fn(() => Promise.resolve()), })); @@ -25,10 +25,6 @@ vi.mock("../../../src/lib/region.js", () => ({ resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), })); -vi.mock("../../../src/lib/api/infrastructure.js", () => ({ - apiRequestToRegion: apiRequestToRegionMock, -})); - vi.mock("../../../src/lib/api/chunk-upload.js", async (importOriginal) => { const actual = await importOriginal< @@ -46,6 +42,7 @@ vi.mock("../../../src/lib/api/chunk-upload.js", async (importOriginal) => { compression: ["gzip"], })), uploadChunkPayloads: uploadChunkPayloadsMock, + assembleDebugFiles: assembleMock, }; }); @@ -63,9 +60,9 @@ function lastAssembleBody(): Record< string, { name: string; debug_id?: string; chunks: string[] } > { - const calls = apiRequestToRegionMock.mock.calls; + const calls = assembleMock.mock.calls; const lastCall = calls.at(-1); - return lastCall?.[2].body; + return lastCall?.[2]; } const SOLE = { @@ -93,7 +90,7 @@ function setServerOptions(overrides: Record): void { } beforeEach(() => { - apiRequestToRegionMock.mockReset(); + assembleMock.mockReset(); uploadChunkPayloadsMock.mockClear(); vi.mocked(getChunkUploadOptions).mockResolvedValue( BASE_SERVER_OPTIONS as Awaited>, @@ -113,7 +110,7 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }); expect(results).toEqual([]); - expect(apiRequestToRegionMock).not.toHaveBeenCalled(); + expect(assembleMock).not.toHaveBeenCalled(); }); test("assemble body keys by checksum and includes name + debug_id + chunks", async () => { @@ -121,14 +118,14 @@ describe("uploadDebugFiles", () => { // The checksum is content-derived, so we capture the request body and // echo its keys back as "ok" — server already holds the file, so no-wait // completes on the first assemble. - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -155,14 +152,14 @@ describe("uploadDebugFiles", () => { }); test("omits debug_id from the body when not provided", async () => { - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -183,21 +180,19 @@ describe("uploadDebugFiles", () => { const dif = makeDif("libbar.so", "more debug bytes", "id-1"); // First assemble: server is missing the chunk. Second: held (created). - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const key = Object.keys(body)[0] as string; return { - data: { - [key]: { state: "not_found", missingChunks: body[key]?.chunks }, - }, + [key]: { state: "not_found", missingChunks: body[key]?.chunks }, }; }, ); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "created" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "created" } }; }, ); @@ -211,7 +206,7 @@ describe("uploadDebugFiles", () => { const results = await promise; expect(uploadChunkPayloadsMock).toHaveBeenCalled(); - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); + expect(assembleMock).toHaveBeenCalledTimes(2); expect(results[0]?.state).toBe("created"); }); @@ -219,16 +214,16 @@ describe("uploadDebugFiles", () => { vi.useFakeTimers(); const dif = makeDif("libbaz.so", "debug bytes", "id-2"); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "assembling" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "assembling" } }; }, ); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "ok" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "ok" } }; }, ); @@ -241,18 +236,16 @@ describe("uploadDebugFiles", () => { await vi.runAllTimersAsync(); const results = await promise; - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); + expect(assembleMock).toHaveBeenCalledTimes(2); expect(results[0]?.state).toBe("ok"); }); test("wait mode collects an 'error' state without throwing", async () => { const dif = makeDif("broken.so", "debug bytes", "id-3"); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { - data: { [key]: { state: "error", detail: "corrupt object" } }, - }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "error", detail: "corrupt object" } }; }, ); @@ -270,10 +263,10 @@ describe("uploadDebugFiles", () => { test("wait mode throws ApiError when assembly does not finish in time", async () => { const dif = makeDif("slow.so", "debug bytes", "id-4"); // Always "assembling" → never terminal. maxWaitMs=0 trips the deadline. - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "assembling" } } }; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "assembling" } }; }, ); @@ -288,14 +281,14 @@ describe("uploadDebugFiles", () => { }); test("batches multiple files into one assemble request", async () => { - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -306,20 +299,20 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }); - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(1); + expect(assembleMock).toHaveBeenCalledTimes(1); expect(Object.keys(lastAssembleBody())).toHaveLength(2); expect(results).toHaveLength(2); }); test("includes code_id and shader_group only when provided", async () => { - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -361,14 +354,14 @@ describe("uploadDebugFiles", () => { }); test("splits the assemble request into batches and merges the responses", async () => { - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); const difs = Array.from({ length: ASSEMBLE_BATCH_SIZE + 1 }, (_, i) => @@ -382,9 +375,9 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }); - expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); - const sizes = apiRequestToRegionMock.mock.calls.map( - (call) => Object.keys(call[2].body as object).length, + expect(assembleMock).toHaveBeenCalledTimes(2); + const sizes = assembleMock.mock.calls.map( + (call) => Object.keys(call[2] as object).length, ); expect(sizes).toEqual([ASSEMBLE_BATCH_SIZE, 1]); expect(results).toHaveLength(ASSEMBLE_BATCH_SIZE + 1); @@ -395,15 +388,15 @@ describe("uploadDebugFiles", () => { // First call: server has no record of either file (entries missing). // Second call: server holds both files. No-wait completes on the second // call after the chunks are re-uploaded. - apiRequestToRegionMock.mockImplementationOnce(async () => ({ data: {} })); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce(async () => ({})); + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -426,16 +419,16 @@ describe("uploadDebugFiles", () => { // Server returns `not_found` but omits the `missingChunks` field — the // client must still upload all chunks for that file, otherwise the loop // would poll forever without sending anything. - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "not_found" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "not_found" } }; }, ); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "ok" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "ok" } }; }, ); @@ -454,14 +447,14 @@ describe("uploadDebugFiles", () => { test("does not assemble oversized files but reports them as failures", async () => { setServerOptions({ maxFileSize: 5 }); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const body = init.body as Record; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const body = assembleBody as Record; const data: Record = {}; for (const key of Object.keys(body)) { data[key] = { state: "ok" }; } - return { data }; + return data; }, ); @@ -502,7 +495,7 @@ describe("uploadDebugFiles", () => { maxWaitMs: 1000, }), ).rejects.toThrow(/exceed the maximum file size/); - expect(apiRequestToRegionMock).not.toHaveBeenCalled(); + expect(assembleMock).not.toHaveBeenCalled(); }); test("clamps the wait to the server maxWait (reflected in the timeout)", async () => { @@ -510,10 +503,10 @@ describe("uploadDebugFiles", () => { vi.useFakeTimers(); // Always "assembling" → never terminal. The clamped 1s deadline trips // well before the requested 60s. - apiRequestToRegionMock.mockImplementation( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "assembling" } } }; + assembleMock.mockImplementation( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "assembling" } }; }, ); @@ -533,10 +526,10 @@ describe("uploadDebugFiles", () => { test("does not clamp when the server advertises no maxWait (0)", async () => { setServerOptions({ maxWait: 0 }); const dif = makeDif("ok.so", "debug bytes", "id-ok"); - apiRequestToRegionMock.mockImplementationOnce( - async (_url: string, _endpoint: string, init: { body: object }) => { - const key = Object.keys(init.body as object)[0] as string; - return { data: { [key]: { state: "ok" } } }; + assembleMock.mockImplementationOnce( + async (_org: string, _project: string, assembleBody: object) => { + const key = Object.keys(assembleBody as object)[0] as string; + return { [key]: { state: "ok" } }; }, ); diff --git a/packages/cli/test/lib/api/preprod-artifacts.test.ts b/packages/cli/test/lib/api/preprod-artifacts.test.ts index dca4acca5..3f2ee2630 100644 --- a/packages/cli/test/lib/api/preprod-artifacts.test.ts +++ b/packages/cli/test/lib/api/preprod-artifacts.test.ts @@ -1,8 +1,10 @@ /** * Tests for the preprod-artifacts (mobile build) API. * - * Pure URL helpers are tested directly. `getBuildInstallDetails` mocks the - * region request layer; `downloadBuildArtifact` mocks `customFetch` and the + * Pure URL helpers are tested directly. Calls that use `@sentry/api` + * operations get an SDK config whose `fetch` is mocked; calls still on + * `apiRequestToRegion` mock that helper. `downloadBuildArtifact` mocks + * `customFetch` and the * auth-token getter so the streaming write and — critically — the * same-origin-only auth attachment can be verified without a network. */ @@ -19,22 +21,36 @@ const { apiRequestToRegionMock, apiRequestToRegionNoContentMock, getAuthTokenMock, + sdkFetchMock, uploadMissingChunksMock, } = vi.hoisted(() => ({ customFetchMock: vi.fn(), apiRequestToRegionMock: vi.fn(), apiRequestToRegionNoContentMock: vi.fn(() => Promise.resolve()), getAuthTokenMock: vi.fn<() => string | undefined>(() => "secret-token"), + sdkFetchMock: vi.fn<(request: Request) => Promise>(), uploadMissingChunksMock: vi.fn(() => Promise.resolve()), })); vi.mock("../../../src/lib/region.js", () => ({ resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), })); -vi.mock("../../../src/lib/api/infrastructure.js", () => ({ - apiRequestToRegion: apiRequestToRegionMock, - apiRequestToRegionNoContent: apiRequestToRegionNoContentMock, -})); +vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/infrastructure.js") + >(); + return { + ...actual, + apiRequestToRegion: apiRequestToRegionMock, + apiRequestToRegionNoContent: apiRequestToRegionNoContentMock, + getOrgSdkConfig: vi.fn(async () => ({ + baseUrl: "https://us.sentry.io", + fetch: sdkFetchMock, + throwOnError: false as const, + })), + }; +}); vi.mock("../../../src/lib/custom-ca.js", () => ({ customFetch: customFetchMock, })); @@ -105,25 +121,31 @@ describe("buildFormatFromUrl", () => { describe("getBuildInstallDetails", () => { beforeEach(() => { - apiRequestToRegionMock.mockReset(); + sdkFetchMock.mockReset(); }); test("hits the install-details endpoint and returns parsed data", async () => { - apiRequestToRegionMock.mockResolvedValue({ - data: { isInstallable: true, installUrl: "https://u/" }, - headers: new Headers(), - }); + sdkFetchMock.mockResolvedValue( + Response.json({ isInstallable: true, installUrl: "https://u/" }), + ); const result = await getBuildInstallDetails("my-org", "build 1"); expect(result).toEqual({ isInstallable: true, installUrl: "https://u/" }); - const lastCall = apiRequestToRegionMock.mock.calls.at(-1); - expect(lastCall?.[0]).toBe("https://us.sentry.io"); + const request = sdkFetchMock.mock.calls.at(-1)?.[0]; + expect(request?.method).toBe("GET"); // Build id is URL-encoded into the path. - expect(lastCall?.[1]).toBe( - "organizations/my-org/preprodartifacts/build%201/install-details/", + expect(request?.url).toBe( + "https://us.sentry.io/api/0/organizations/my-org/preprodartifacts/build%201/install-details/", + ); + }); + + test("rejects a response that does not match the schema", async () => { + sdkFetchMock.mockResolvedValue(Response.json({ installUrl: 42 })); + + await expect(getBuildInstallDetails("my-org", "b1")).rejects.toThrow( + "Unexpected response format from organizations/my-org/preprodartifacts/b1/install-details/", ); - expect(lastCall?.[2]?.schema).toBeDefined(); }); }); @@ -453,11 +475,10 @@ describe("snapshots", () => { }); test("getLatestBaseSnapshot maps the response and forwards params", async () => { - // apiRequestToRegion returns schema-validated snake_case data. - apiRequestToRegionMock.mockResolvedValue({ - data: { head_artifact_id: "art-1", image_count: 5 }, - headers: new Headers(), - }); + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue( + Response.json({ head_artifact_id: "art-1", image_count: 5 }), + ); await expect( getLatestBaseSnapshot("my-org", "my-app", { @@ -466,23 +487,45 @@ describe("snapshots", () => { }), ).resolves.toEqual({ headArtifactId: "art-1", imageCount: 5 }); // app_id + optional branch/project go through as query params. - const params = apiRequestToRegionMock.mock.calls.at(-1)?.[2]?.params; - expect(params).toEqual({ + const url = new URL(sdkFetchMock.mock.calls.at(-1)?.[0].url ?? ""); + expect(url.pathname).toBe( + "/api/0/organizations/my-org/preprodartifacts/snapshots/latest-base/", + ); + expect(Object.fromEntries(url.searchParams)).toEqual({ app_id: "my-app", branch: "main", project: "proj-1", }); }); + test("getLatestBaseSnapshot returns null for a null body", async () => { + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue(Response.json(null)); + await expect(getLatestBaseSnapshot("my-org", "my-app")).resolves.toBeNull(); + }); + test("getLatestBaseSnapshot returns null on 404", async () => { - apiRequestToRegionMock.mockRejectedValue( - new ApiError("not found", 404, "", "endpoint"), + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue( + Response.json({ detail: "Not found" }, { status: 404 }), ); await expect( getLatestBaseSnapshot("my-org", "missing"), ).resolves.toBeNull(); }); + test("getLatestBaseSnapshot rethrows other HTTP errors", async () => { + sdkFetchMock.mockReset(); + sdkFetchMock.mockResolvedValue( + Response.json({ detail: "Boom" }, { status: 500 }), + ); + const error = await getLatestBaseSnapshot("my-org", "my-app").catch( + (e) => e, + ); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(500); + }); + test("waitForSnapshotArchive triggers a build then resolves when ready", async () => { apiRequestToRegionMock .mockResolvedValueOnce({ data: { ready: false }, headers: new Headers() }) diff --git a/packages/cli/test/lib/api/proguard.test.ts b/packages/cli/test/lib/api/proguard.test.ts index ed2988e8f..4b63eb493 100644 --- a/packages/cli/test/lib/api/proguard.test.ts +++ b/packages/cli/test/lib/api/proguard.test.ts @@ -5,28 +5,14 @@ * ProGuard mappings are chunked as raw bytes (no ZIP wrapping). */ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { hashBuffer } from "../../../src/lib/api/chunk-upload.js"; -import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; import { uploadProguardMappings } from "../../../src/lib/api/proguard.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; -vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("../../../src/lib/api/infrastructure.js") - >(); - return { ...actual, apiRequestToRegion: vi.fn() }; -}); -vi.mock("../../../src/lib/region.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), - }; -}); - -const apiMock = vi.mocked(apiRequestToRegion); +useTestConfigDir("api-proguard-"); describe("hashBuffer", () => { test("single chunk for small content", () => { @@ -106,28 +92,38 @@ describe("uploadProguardMappings", () => { compression: ["gzip"], }; + let originalFetch: typeof globalThis.fetch; + beforeEach(() => { - apiMock.mockReset(); + originalFetch = globalThis.fetch; + setAuthToken("test-token"); + setOrgRegion("test-org", "https://us.sentry.io"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; }); test("assemble request names each mapping with a leading-slash /proguard/ path", async () => { let assembleBody: Record = {}; - apiMock.mockImplementation( - async (_regionUrl, endpoint, options?: { body?: unknown }) => { - if (endpoint.includes("chunk-upload/")) { - return { data: CHUNK_UPLOAD_OPTIONS } as never; - } - // DIF assemble endpoint: report every checksum as already "ok" so the - // upload short-circuits without needing to mock chunk uploads too. - assembleBody = options?.body as typeof assembleBody; - const response: Record = {}; - for (const checksum of Object.keys(assembleBody)) { - response[checksum] = { state: "ok" }; - } - return { data: response } as never; - }, - ); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.endsWith("/chunk-upload/")) { + return Response.json(CHUNK_UPLOAD_OPTIONS); + } + // DIF assemble endpoint: report every checksum as already "ok" so the + // upload short-circuits without needing to mock chunk uploads too. + expect(req.url).toBe( + "https://us.sentry.io/api/0/projects/test-org/test-project/files/difs/assemble/", + ); + assembleBody = (await req.json()) as typeof assembleBody; + const response: Record = {}; + for (const checksum of Object.keys(assembleBody)) { + response[checksum] = { state: "ok" }; + } + return Response.json(response); + }); await uploadProguardMappings({ org: "test-org", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0527ff5c..fb33914d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 1.26.0 - version: 1.26.0 + specifier: 1.44.0 + version: 1.44.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -513,7 +513,7 @@ importers: version: 2.1.5(hono@4.13.13) '@sentry/api': specifier: 'catalog:' - version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -865,7 +865,7 @@ importers: version: 2.4.0 '@sentry/api': specifier: 'catalog:' - version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -1048,7 +1048,7 @@ importers: dependencies: '@sentry/api': specifier: 'catalog:' - version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/search-syntax': specifier: 'catalog:' version: 1.0.0 @@ -4275,8 +4275,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@1.26.0': - resolution: {integrity: sha512-LkzMT2mn2Qrbnf2s+2t/mrXpN1/2o0GO2fodhds2Ntp8kRt/NkilWIETAGUU6vB7XKlLhPZloZT4Yv/HEzCmFA==} + '@sentry/api@1.44.0': + resolution: {integrity: sha512-8PPlt5YuYLOn3btCatpogIa+Q2NW9dmtB5JPJA6IF7fKNLPUHqkojVBfOSNUb9fD5UE0ZxUzZsBUnVhW5/JvrQ==} engines: {node: '>=22'} peerDependencies: typescript: '*' @@ -7691,6 +7691,10 @@ packages: resolution: {integrity: sha512-Ll0w3fU24vYpXoZmjjZIee6bJQDgG0oAyo1PdmFYI8UDwJJddaHAypxIH9avUu+t+lSsAwKVsb1jDCMIIChliw==} engines: {node: '>=20'} + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -12108,9 +12112,10 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@1.44.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': dependencies: http-cache-semantics: 4.2.0 + p-limit: 7.3.3 optionalDependencies: typescript: 5.9.3 valibot: 1.4.2(typescript@5.9.3) @@ -16149,6 +16154,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aebcf7b2e..d198de786 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,7 @@ catalog: "@modelcontextprotocol/server": 2.4.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 1.26.0 + "@sentry/api": 1.44.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -168,7 +168,7 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@1.26.0" + - "@sentry/api@1.44.0" - miniflare@5.20261101.0-beta - wrangler@4.156.1 - "@sentry/symbolic@14.0.0-alpha.5"