schema.ts•18.9 kB
/**
* Zod schemas for Sentry API response validation.
*
* This module contains comprehensive Zod schemas that validate and type-check
* responses from Sentry's REST API. All schemas are designed to handle Sentry's
* flexible data model where most fields can be null or optional.
*
* Key Design Principles:
* - Use .passthrough() for objects that may contain additional fields
* - Support both string and number IDs (Sentry's legacy/modern ID formats)
* - Handle nullable fields gracefully throughout the schema hierarchy
* - Use union types for polymorphic data (events, assignedTo, etc.)
*
* Schema Categories:
* - **Core Resources**: Users, Organizations, Teams, Projects
* - **Issue Management**: Issues, Events, Assignments
* - **Release Management**: Releases, Commits, Deployments
* - **Search & Discovery**: Tags, Error Search, Span Search
* - **Integrations**: Client Keys (DSNs), Autofix
*
* @example Schema Usage
* ```typescript
* import { IssueListSchema } from "./schema";
*
* const response = await fetch("/api/0/organizations/my-org/issues/");
* const issues = IssueListSchema.parse(await response.json());
* // TypeScript now knows the exact shape of issues
* ```
*
* @example Error Handling
* ```typescript
* const { data, success, error } = ApiErrorSchema.safeParse(response);
* if (success) {
* throw new ApiError(data.detail, statusCode);
* }
* ```
*/
import { z } from "zod";
/**
* Schema for Sentry API error responses.
*
* Uses .passthrough() to allow additional fields that may be present
* in different error scenarios.
*/
export const ApiErrorSchema = z
.object({
detail: z.string(),
})
.passthrough();
export const UserSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string().nullable(),
email: z.string(),
});
export const UserRegionsSchema = z.object({
regions: z.array(
z.object({
name: z.string(),
url: z.string().url(),
}),
),
});
/**
* Schema for Sentry organization API responses.
*
* Handles organizations from both Sentry's Cloud Service and self-hosted installations.
* The links object and regionUrl field are optional to support self-hosted Sentry
* instances that may not include these fields or return empty values.
*/
export const OrganizationSchema = z.object({
id: z.union([z.string(), z.number()]),
slug: z.string(),
name: z.string(),
links: z
.object({
regionUrl: z
.string()
.refine(
(value) => !value || z.string().url().safeParse(value).success,
{
message:
"Must be a valid URL or empty string (for self-hosted Sentry)",
},
)
.optional(),
organizationUrl: z.string().url(),
})
.optional(),
});
export const OrganizationListSchema = z.array(OrganizationSchema);
export const TeamSchema = z.object({
id: z.union([z.string(), z.number()]),
slug: z.string(),
name: z.string(),
});
export const TeamListSchema = z.array(TeamSchema);
export const ProjectSchema = z.object({
id: z.union([z.string(), z.number()]),
slug: z.string(),
name: z.string(),
platform: z.string().nullable().optional(),
});
export const ProjectListSchema = z.array(ProjectSchema);
export const ClientKeySchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
dsn: z.object({
public: z.string(),
}),
isActive: z.boolean(),
dateCreated: z.string().datetime(),
});
export const ClientKeyListSchema = z.array(ClientKeySchema);
export const ReleaseSchema = z.object({
id: z.union([z.string(), z.number()]),
version: z.string(),
shortVersion: z.string(),
dateCreated: z.string().datetime(),
dateReleased: z.string().datetime().nullable(),
firstEvent: z.string().datetime().nullable(),
lastEvent: z.string().datetime().nullable(),
newGroups: z.number(),
lastCommit: z
.object({
id: z.union([z.string(), z.number()]),
message: z.string(),
dateCreated: z.string().datetime(),
author: z.object({
name: z.string(),
email: z.string(),
}),
})
.nullable(),
lastDeploy: z
.object({
id: z.union([z.string(), z.number()]),
environment: z.string(),
dateStarted: z.string().datetime().nullable(),
dateFinished: z.string().datetime().nullable(),
})
.nullable(),
projects: z.array(ProjectSchema),
});
export const ReleaseListSchema = z.array(ReleaseSchema);
export const TagSchema = z.object({
key: z.string(),
name: z.string(),
totalValues: z.number(),
});
export const TagListSchema = z.array(TagSchema);
// Schema for assignedTo field - can be a user object, team object, string, or null
export const AssignedToSchema = z.union([
z.null(),
z.string(), // username or actor ID
z
.object({
type: z.enum(["user", "team"]),
id: z.union([z.string(), z.number()]),
name: z.string(),
email: z.string().optional(), // only for users
})
.passthrough(), // Allow additional fields we might not know about
]);
export const IssueSchema = z.object({
id: z.union([z.string(), z.number()]),
shortId: z.string(),
title: z.string(),
firstSeen: z.string().datetime(),
lastSeen: z.string().datetime(),
count: z.union([z.string(), z.number()]),
userCount: z.union([z.string(), z.number()]),
permalink: z.string().url(),
project: ProjectSchema,
platform: z.string().nullable().optional(),
status: z.string(),
culprit: z.string(),
type: z.union([z.literal("error"), z.literal("transaction"), z.unknown()]),
assignedTo: AssignedToSchema.optional(),
issueType: z.string().optional(),
issueCategory: z.string().optional(),
metadata: z
.object({
title: z.string().nullable().optional(),
location: z.string().nullable().optional(),
value: z.string().nullable().optional(),
})
.optional(),
});
export const IssueListSchema = z.array(IssueSchema);
export const FrameInterface = z
.object({
filename: z.string().nullable(),
function: z.string().nullable(),
lineNo: z.number().nullable(),
colNo: z.number().nullable(),
absPath: z.string().nullable(),
module: z.string().nullable(),
// lineno, source code
context: z.array(z.tuple([z.number(), z.string()])),
inApp: z.boolean().optional(),
vars: z.record(z.string(), z.unknown()).optional(),
})
.partial();
// XXX: Sentry's schema generally speaking is "assume all user input is missing"
// so we need to handle effectively every field being optional or nullable.
export const ExceptionInterface = z
.object({
mechanism: z
.object({
type: z.string().nullable(),
handled: z.boolean().nullable(),
})
.partial(),
type: z.string().nullable(),
value: z.string().nullable(),
stacktrace: z.object({
frames: z.array(FrameInterface),
}),
})
.partial();
export const ErrorEntrySchema = z
.object({
// XXX: Sentry can return either of these. Not sure why we never normalized it.
values: z.array(ExceptionInterface.optional()),
value: ExceptionInterface.nullable().optional(),
})
.partial();
export const RequestEntrySchema = z
.object({
method: z.string().nullable(),
url: z.string().url().nullable(),
// TODO:
// query: z.array(z.tuple([z.string(), z.string()])).nullable(),
// data: z.unknown().nullable(),
// headers: z.array(z.tuple([z.string(), z.string()])).nullable(),
})
.partial();
export const MessageEntrySchema = z
.object({
formatted: z.string().nullable(),
message: z.string().nullable(),
params: z.array(z.unknown()).optional(),
})
.partial();
export const ThreadEntrySchema = z
.object({
id: z.number().nullable(),
name: z.string().nullable(),
current: z.boolean().nullable(),
crashed: z.boolean().nullable(),
state: z.string().nullable(),
stacktrace: z
.object({
frames: z.array(FrameInterface),
})
.nullable(),
})
.partial();
export const ThreadsEntrySchema = z
.object({
values: z.array(ThreadEntrySchema),
})
.partial();
export const BreadcrumbSchema = z
.object({
timestamp: z.string().nullable(),
type: z.string().nullable(),
category: z.string().nullable(),
level: z.string().nullable(),
message: z.string().nullable(),
data: z.record(z.unknown()).nullable(),
})
.partial();
export const BreadcrumbsEntrySchema = z
.object({
values: z.array(BreadcrumbSchema),
})
.partial();
const BaseEventSchema = z.object({
id: z.string(),
title: z.string(),
message: z.string().nullable(),
platform: z.string().nullable().optional(),
type: z.unknown(),
entries: z.array(
z.union([
z.object({
type: z.literal("exception"),
data: ErrorEntrySchema,
}),
z.object({
type: z.literal("message"),
data: MessageEntrySchema,
}),
z.object({
type: z.literal("threads"),
data: ThreadsEntrySchema,
}),
z.object({
type: z.literal("request"),
data: RequestEntrySchema,
}),
z.object({
type: z.literal("breadcrumbs"),
data: BreadcrumbsEntrySchema,
}),
z.object({
type: z.literal("spans"),
data: z.unknown(),
}),
z.object({
type: z.string(),
data: z.unknown(),
}),
]),
),
contexts: z
.record(
z.string(),
z
.object({
type: z.union([
z.literal("default"),
z.literal("runtime"),
z.literal("os"),
z.literal("trace"),
z.unknown(),
]),
})
.passthrough(),
)
.optional(),
tags: z
.array(
z.object({
key: z.string(),
value: z.string().nullable(),
}),
)
.optional(),
// The _meta field contains metadata about fields in the response
// It's safer to type as unknown since its structure varies
_meta: z.unknown().optional(),
// dateReceived is when the server received the event (may not be present in all contexts)
dateReceived: z.string().datetime().optional(),
});
export const ErrorEventSchema = BaseEventSchema.omit({
type: true,
}).extend({
type: z.literal("error"),
culprit: z.string().nullable(),
dateCreated: z.string().datetime(),
});
export const DefaultEventSchema = BaseEventSchema.omit({
type: true,
}).extend({
type: z.literal("default"),
culprit: z.string().nullable().optional(),
dateCreated: z.string().datetime(),
});
export const TransactionEventSchema = BaseEventSchema.omit({
type: true,
}).extend({
type: z.literal("transaction"),
occurrence: z
.object({
id: z.string().optional(),
projectId: z.number().optional(),
eventId: z.string().optional(),
fingerprint: z.array(z.string()).optional(),
issueTitle: z.string(),
subtitle: z.string().optional(),
resourceId: z.string().nullable().optional(),
evidenceData: z.record(z.string(), z.any()).optional(),
evidenceDisplay: z
.array(
z.object({
name: z.string(),
value: z.string(),
important: z.boolean().optional(),
}),
)
.optional(),
type: z.number().optional(),
detectionTime: z.number().optional(),
level: z.string().optional(),
culprit: z.string().nullable(),
priority: z.number().optional(),
assignee: z.string().nullable().optional(),
})
.nullish(), // Allow both null and undefined
});
export const UnknownEventSchema = BaseEventSchema.omit({
type: true,
}).extend({
type: z.unknown(),
});
// XXX: This API response is kind of a disaster. We are not propagating the appropriate
// columns and it makes this really hard to work with. Errors and Transaction-based issues
// are completely different, for example.
export const EventSchema = z.union([
ErrorEventSchema,
DefaultEventSchema,
TransactionEventSchema,
UnknownEventSchema,
]);
export const EventsResponseSchema = z.object({
data: z.array(z.unknown()),
meta: z
.object({
fields: z.record(z.string(), z.string()),
})
.passthrough(),
});
// https://us.sentry.io/api/0/organizations/sentry/events/?dataset=errors&field=issue&field=title&field=project&field=timestamp&field=trace&per_page=5&query=event.type%3Aerror&referrer=sentry-mcp&sort=-timestamp&statsPeriod=1w
export const ErrorsSearchResponseSchema = EventsResponseSchema.extend({
data: z.array(
z.object({
issue: z.string(),
"issue.id": z.union([z.string(), z.number()]),
project: z.string(),
title: z.string(),
"count()": z.number(),
"last_seen()": z.string(),
}),
),
});
export const SpansSearchResponseSchema = EventsResponseSchema.extend({
data: z.array(
z.object({
id: z.string(),
trace: z.string(),
"span.op": z.string(),
"span.description": z.string(),
"span.duration": z.number(),
transaction: z.string(),
project: z.string(),
timestamp: z.string(),
}),
),
});
export const AutofixRunSchema = z
.object({
run_id: z.union([z.string(), z.number()]),
})
.passthrough();
const AutofixStatusSchema = z.enum([
"PENDING",
"PROCESSING",
"IN_PROGRESS",
"NEED_MORE_INFORMATION",
"COMPLETED",
"FAILED",
"ERROR",
"CANCELLED",
"WAITING_FOR_USER_RESPONSE",
]);
const AutofixRunStepBaseSchema = z.object({
type: z.string(),
key: z.string(),
index: z.number(),
status: AutofixStatusSchema,
title: z.string(),
output_stream: z.string().nullable(),
progress: z.array(
z.object({
data: z.unknown().nullable(),
message: z.string(),
timestamp: z.string(),
type: z.enum(["INFO", "WARNING", "ERROR"]),
}),
),
});
export const AutofixRunStepDefaultSchema = AutofixRunStepBaseSchema.extend({
type: z.literal("default"),
insights: z
.array(
z.object({
change_diff: z.unknown().nullable(),
generated_at_memory_index: z.number(),
insight: z.string(),
justification: z.string(),
type: z.literal("insight"),
}),
)
.nullable(),
}).passthrough();
export const AutofixRunStepRootCauseAnalysisSchema =
AutofixRunStepBaseSchema.extend({
type: z.literal("root_cause_analysis"),
causes: z.array(
z.object({
description: z.string(),
id: z.number(),
root_cause_reproduction: z.array(
z.object({
code_snippet_and_analysis: z.string(),
is_most_important_event: z.boolean(),
relevant_code_file: z
.object({
file_path: z.string(),
repo_name: z.string(),
})
.nullable(),
timeline_item_type: z.string(),
title: z.string(),
}),
),
}),
),
}).passthrough();
export const AutofixRunStepSolutionSchema = AutofixRunStepBaseSchema.extend({
type: z.literal("solution"),
solution: z.array(
z.object({
code_snippet_and_analysis: z.string().nullable(),
is_active: z.boolean(),
is_most_important_event: z.boolean(),
relevant_code_file: z.null(),
timeline_item_type: z.union([
z.literal("internal_code"),
z.literal("repro_test"),
]),
title: z.string(),
}),
),
}).passthrough();
export const AutofixRunStepSchema = z.union([
AutofixRunStepDefaultSchema,
AutofixRunStepRootCauseAnalysisSchema,
AutofixRunStepSolutionSchema,
AutofixRunStepBaseSchema.passthrough(),
]);
export const AutofixRunStateSchema = z.object({
autofix: z
.object({
run_id: z.number(),
request: z.unknown(),
updated_at: z.string(),
status: AutofixStatusSchema,
steps: z.array(AutofixRunStepSchema),
})
.passthrough()
.nullable(),
});
export const EventAttachmentSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
size: z.number(),
mimetype: z.string(),
dateCreated: z.string().datetime(),
sha1: z.string(),
headers: z.record(z.string(), z.string()).optional(),
});
export const EventAttachmentListSchema = z.array(EventAttachmentSchema);
/**
* Schema for Sentry trace metadata response.
*
* Contains high-level statistics about a trace including span counts,
* transaction breakdown, and operation type distribution.
*/
export const TraceMetaSchema = z.object({
logs: z.number(),
errors: z.number(),
performance_issues: z.number(),
span_count: z.number(),
transaction_child_count_map: z.array(
z.object({
"transaction.event_id": z.string().nullable(),
"count()": z.number(),
}),
),
span_count_map: z.record(z.string(), z.number()),
});
/**
* Schema for individual spans within a trace.
*
* Represents the hierarchical structure of spans with timing information,
* operation details, and nested children spans.
*/
export const TraceSpanSchema: z.ZodType<any> = z.lazy(() =>
z.object({
children: z.array(TraceSpanSchema),
errors: z.array(z.any()),
occurrences: z.array(z.any()),
event_id: z.string(),
transaction_id: z.string(),
project_id: z.union([z.string(), z.number()]),
project_slug: z.string(),
profile_id: z.string(),
profiler_id: z.string(),
parent_span_id: z.string().nullable(),
start_timestamp: z.number(),
end_timestamp: z.number(),
measurements: z.record(z.string(), z.number()).optional(),
duration: z.number(),
transaction: z.string(),
is_transaction: z.boolean(),
description: z.string(),
sdk_name: z.string(),
op: z.string(),
name: z.string(),
event_type: z.string(),
additional_attributes: z.record(z.string(), z.any()),
}),
);
/**
* Schema for issue objects that can appear in trace responses.
*
* When Sentry's trace API returns standalone errors, they are returned as
* SerializedIssue objects that lack the span-specific fields.
*/
export const TraceIssueSchema = z
.object({
id: z.union([z.string(), z.number()]).optional(),
issue_id: z.union([z.string(), z.number()]).optional(),
project_id: z.union([z.string(), z.number()]).optional(),
project_slug: z.string().optional(),
title: z.string().optional(),
culprit: z.string().optional(),
type: z.string().optional(),
timestamp: z.union([z.string(), z.number()]).optional(),
})
.passthrough();
/**
* Schema for Sentry trace response.
*
* Contains the complete trace tree starting from root spans.
* The response is an array that can contain both root-level spans
* and standalone issue objects. The Sentry API's query_trace_data
* function returns a mixed list of SerializedSpan and SerializedIssue
* objects when there are errors not directly associated with spans.
*/
export const TraceSchema = z.array(
z.union([TraceSpanSchema, TraceIssueSchema]),
);