Skip to content
Open
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
3 changes: 1 addition & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@
"uuidv7": "^1.2.1",
"valibot": "^1.4.2",
"vitest": "^4.1.9",
"wrap-ansi": "^10.0.0",
"zod": "^3.25.76"
"wrap-ansi": "^10.0.0"
}
}
12 changes: 8 additions & 4 deletions packages/cli/src/commands/code-mappings/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { readFile } from "node:fs/promises";

import { getDotPath, safeParse } from "valibot";
import type { SentryContext } from "../../context.js";
import {
CodeMappingSchema,
Expand Down Expand Up @@ -141,17 +142,20 @@ async function readAndValidateMappings(
// Validate each entry
const mappings: Array<{ stackRoot: string; sourceRoot: string }> = [];
for (let i = 0; i < parsed.length; i++) {
const result = CodeMappingSchema.safeParse(parsed[i]);
const result = safeParse(CodeMappingSchema, parsed[i]);
if (!result.success) {
const issues = result.error.issues
.map((iss) => `${iss.path.join(".")}: ${iss.message}`)
const issues = result.issues
.map((iss) => {
const dotPath = getDotPath(iss);
return dotPath ? `${dotPath}: ${iss.message}` : iss.message;
})
.join(", ");
throw new ValidationError(
`Invalid code mapping at index ${i}: ${issues}`,
"path"
);
}
mappings.push(result.data);
mappings.push(result.output);
}

return mappings;
Expand Down
48 changes: 29 additions & 19 deletions packages/cli/src/lib/api/chunk-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,17 @@ import {
zstdCompress as zstdCompressCb,
} from "node:zlib";
import pLimit from "p-limit";
import { z } from "zod";
import {
array,
type GenericSchema,
type InferOutput,
nullish,
number,
object,
optional,
picklist,
string,
} from "valibot";
import { ApiError } from "../errors.js";
import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
Expand All @@ -45,46 +55,46 @@ const log = logger.withTag("api.chunk-upload");
// ── Schemas ─────────────────────────────────────────────────────────

/** Server-provided chunk upload configuration. */
export const ChunkServerOptionsSchema = z.object({
export const ChunkServerOptionsSchema = object({
/** Absolute URL to upload chunks to. */
url: z.string(),
url: string(),
/** Maximum size of a single chunk in bytes. */
chunkSize: z.number(),
chunkSize: number(),
/** Maximum number of chunks per upload request. */
chunksPerRequest: z.number(),
chunksPerRequest: number(),
/** Maximum total request body size in bytes. */
maxRequestSize: z.number(),
maxRequestSize: number(),
/**
* Maximum size of a single uploaded file in bytes. Omitted or `0` means the
* server advertises no per-file cap, in which case the client falls back to
* {@link DEFAULT_MAX_DIF_SIZE}.
*/
maxFileSize: z.number().optional(),
maxFileSize: optional(number()),
/**
* Maximum time, in seconds, the server is willing to spend assembling an
* upload. Omitted or `0` means no server-imposed cap; a non-zero value clamps
* the caller's requested wait. Mirrors the legacy `dif_upload` `max_wait`
* semantics.
*/
maxWait: z.number().optional(),
maxWait: optional(number()),
/** Hash algorithm for chunk checksums (always "sha1"). */
hashAlgorithm: z.string(),
hashAlgorithm: string(),
/** Maximum concurrent upload requests. */
concurrency: z.number(),
concurrency: number(),
/** Supported compression methods (e.g., ["gzip"]). */
compression: z.array(z.string()),
compression: array(string()),
});

export type ChunkServerOptions = z.infer<typeof ChunkServerOptionsSchema>;
export type ChunkServerOptions = InferOutput<typeof ChunkServerOptionsSchema>;

/** Response from an assemble endpoint (shared by artifact bundle and DIF). */
export const AssembleResponseSchema = z.object({
state: z.enum(["not_found", "created", "assembling", "ok", "error"]),
missingChunks: z.array(z.string()).optional(),
detail: z.string().nullable().optional(),
export const AssembleResponseSchema = object({
state: picklist(["not_found", "created", "assembling", "ok", "error"]),
missingChunks: optional(array(string())),
detail: nullish(string()),
});

export type AssembleResponse = z.infer<typeof AssembleResponseSchema>;
export type AssembleResponse = InferOutput<typeof AssembleResponseSchema>;

// ── Types ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -494,7 +504,7 @@ export async function uploadMissingBufferChunks(params: {
* @param params.endpoint - The endpoint path to POST to
* @param params.body - The request body to send on each poll
* @param params.entityName - Human-readable name for error messages
* @param params.schema - Zod schema for the response (defaults to {@link AssembleResponseSchema})
* @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
*/
Expand All @@ -503,7 +513,7 @@ export async function pollAssembly(params: {
endpoint: string;
body: unknown;
entityName: string;
schema?: z.ZodType<AssembleResponse>;
schema?: GenericSchema<unknown, AssembleResponse>;
waitForOk?: boolean;
deadlineMs?: number;
}): Promise<void> {
Expand Down
43 changes: 26 additions & 17 deletions packages/cli/src/lib/api/code-mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@
* Auth: requires `org:ci` scope.
*/

import { z } from "zod";
import {
array,
type InferOutput,
minLength,
nullish,
number,
object,
pipe,
string,
} from "valibot";

import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
Expand All @@ -20,34 +29,34 @@ const log = logger.withTag("api.code-mappings");
// Schemas

/** A single code mapping entry. */
export const CodeMappingSchema = z.object({
stackRoot: z.string().min(1),
sourceRoot: z.string().min(1),
export const CodeMappingSchema = object({
stackRoot: pipe(string(), minLength(1)),
sourceRoot: pipe(string(), minLength(1)),
});

export type CodeMapping = z.infer<typeof CodeMappingSchema>;
export type CodeMapping = InferOutput<typeof CodeMappingSchema>;

/** Per-mapping result from the server. */
const CodeMappingResultSchema = z.object({
stackRoot: z.string(),
sourceRoot: z.string(),
status: z.string(),
detail: z.string().nullable().optional(),
const CodeMappingResultSchema = object({
stackRoot: string(),
sourceRoot: string(),
status: string(),
detail: nullish(string()),
});

/** Bulk upload response from the server. */
const BulkCodeMappingsResponseSchema = z.object({
created: z.number(),
updated: z.number(),
errors: z.number(),
mappings: z.array(CodeMappingResultSchema),
const BulkCodeMappingsResponseSchema = object({
created: number(),
updated: number(),
errors: number(),
mappings: array(CodeMappingResultSchema),
});

export type BulkCodeMappingsResponse = z.infer<
export type BulkCodeMappingsResponse = InferOutput<
typeof BulkCodeMappingsResponseSchema
>;

export type CodeMappingResult = z.infer<typeof CodeMappingResultSchema>;
export type CodeMappingResult = InferOutput<typeof CodeMappingResultSchema>;

// Constants

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
*
* The `/organizations/{org}/ai-conversations/` endpoints are PRIVATE and not
* yet in `@sentry/api` (getsentry/sentry-api-schema). Call them via
* `apiRequestToRegion` with local Zod schemas (same pattern as `logs.ts` /
* `apiRequestToRegion` with local Valibot schemas (same pattern as `logs.ts` /
* `traces.ts`). Details response shape is documented on
* `AIConversationDetailsSchema`. Pagination uses `parseLinkHeader`. Revisit
* once these endpoints land in `@sentry/api`.
*/

import { z } from "zod";
import { array } from "valibot";

import {
type AIConversationDetails,
Expand Down Expand Up @@ -73,7 +73,7 @@ export async function listConversations(
const { data, headers } = await apiRequestToRegion<ConversationListItem[]>(
regionUrl,
`/organizations/${orgSlug}/ai-conversations/`,
{ params, schema: z.array(ConversationListItemSchema) }
{ params, schema: array(ConversationListItemSchema) }
);

const { nextCursor } = parseLinkHeader(headers.get("link") ?? null);
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/api/dart-symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* debug file (dSYM/ELF).
*/

import { z } from "zod";
import { type InferOutput, record, string } from "valibot";
import { ApiError } from "../errors.js";
import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
Expand Down Expand Up @@ -58,9 +58,9 @@ export type DartSymbolMapUploadOptions = {
* DIF assemble response — keyed by overall checksum, each value has
* the same shape as the standard assemble response.
*/
const DifAssembleResponseSchema = z.record(z.string(), AssembleResponseSchema);
const DifAssembleResponseSchema = record(string(), AssembleResponseSchema);

type DifAssembleResponse = z.infer<typeof DifAssembleResponseSchema>;
type DifAssembleResponse = InferOutput<typeof DifAssembleResponseSchema>;

// Helpers

Expand Down
16 changes: 8 additions & 8 deletions packages/cli/src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";

import { z } from "zod";
import { array, safeParse } from "valibot";

import {
type DashboardDetail,
Expand Down Expand Up @@ -194,7 +194,7 @@ export async function listDashboardRevisionsPaginated(
const { data, headers } = await apiRequestToRegion<DashboardRevision[]>(
regionUrl,
`/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/`,
{ params, schema: z.array(DashboardRevisionSchema) }
{ params, schema: array(DashboardRevisionSchema) }
);

const { nextCursor, prevCursor } = parseLinkHeader(
Expand Down Expand Up @@ -363,13 +363,13 @@ function parseEventsStatsResponse(
const series: TimeseriesResult["series"] = [];

// Try parsing as a single series first (simple query, no grouping)
const singleResult = EventsStatsSeriesSchema.safeParse(raw);
const singleResult = safeParse(EventsStatsSeriesSchema, raw);
if (singleResult.success) {
for (const axis of yAxis) {
series.push({
label: axis,
values: extractTimeseriesValues(singleResult.data),
unit: singleResult.data.meta?.units?.[axis] ?? null,
values: extractTimeseriesValues(singleResult.output),
unit: singleResult.output.meta?.units?.[axis] ?? null,
});
}
return { type: "timeseries", series };
Expand All @@ -389,12 +389,12 @@ function parseEventsStatsResponse(
});

for (const [groupLabel, groupData] of entries) {
const parsed = EventsStatsSeriesSchema.safeParse(groupData);
const parsed = safeParse(EventsStatsSeriesSchema, groupData);
if (parsed.success) {
series.push({
label: groupLabel,
values: extractTimeseriesValues(parsed.data),
unit: parsed.data.meta?.units?.[yAxis[0] ?? ""] ?? null,
values: extractTimeseriesValues(parsed.output),
unit: parsed.output.meta?.units?.[yAxis[0] ?? ""] ?? null,
});
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/api/debug-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* collecting `error` details so the caller can report and exit non-zero.
*/

import { z } from "zod";
import { type InferOutput, record, string } from "valibot";
import { ApiError, ValidationError } from "../errors.js";
import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
Expand Down Expand Up @@ -103,9 +103,9 @@ export type DebugFileUploadResult = {
* DIF assemble response — keyed by overall checksum, each value has the same
* shape as the standard assemble response.
*/
const DifAssembleResponseSchema = z.record(z.string(), AssembleResponseSchema);
const DifAssembleResponseSchema = record(string(), AssembleResponseSchema);

type DifAssembleResponse = z.infer<typeof DifAssembleResponseSchema>;
type DifAssembleResponse = InferOutput<typeof DifAssembleResponseSchema>;

// ── Internal types ──────────────────────────────────────────────────

Expand Down
28 changes: 17 additions & 11 deletions packages/cli/src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { zstdCompress as zstdCompressCb } from "node:zlib";
import { parseSentryLinkHeader } from "@sentry/api";
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import type { z } from "zod";
import { type GenericSchema, safeParse } from "valibot";

import { extractRequiredScopes } from "../api-scope.js";
import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js";
Expand Down Expand Up @@ -189,8 +189,8 @@ export type ApiRequestOptions<T = unknown> = {
bodyEncoding?: "zstd";
/** Query parameters. String arrays create repeated keys (e.g., tags=1&tags=2) */
params?: Record<string, string | number | boolean | string[] | undefined>;
/** Optional Zod schema for runtime validation of response data */
schema?: z.ZodType<T>;
/** Optional valibot schema for runtime validation of response data */
schema?: GenericSchema<unknown, T>;
};

/**
Expand Down Expand Up @@ -524,23 +524,29 @@ export async function apiRequestToRegion<T>(
}

if (schema) {
const result = schema.safeParse(data);
const result = safeParse(schema, data);
if (!result.success) {
// Attach structured Zod 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.
Sentry.setContext("zod_validation", {
// 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.error.issues.slice(0, 10),
issues: result.issues.slice(0, 10).map((i) => ({
path: i.path,
type: i.type,
message: i.message,
})),
Comment thread
jared-outpost[bot] marked this conversation as resolved.
});
throw new ApiError(
`Unexpected response format from ${endpoint}`,
response.status,
result.error.message
result.issues.map((issue) => issue.message).join(", ")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation errors omit failing field paths

Low Severity

Valibot issue messages do not include their paths, so joining only issue.message removes the failing field names previously present in ZodError.message. API format failures become ambiguous, especially when several fields produce identical type errors.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0108cd2. Configure here.

);
}
return { data: result.data, headers: response.headers };
return { data: result.output, headers: response.headers };
}

return { data: data as T, headers: response.headers };
Expand Down
Loading
Loading