diff --git a/bun.lock b/bun.lock index 8d4ae71c..76ba3323 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "sentry", "devDependencies": { "@biomejs/biome": "2.3.8", + "@sentry/api": "^0.1.0", "@sentry/bun": "10.38.0", "@sentry/esbuild-plugin": "^2.23.0", "@sentry/node": "10.38.0", @@ -19,7 +20,6 @@ "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", - "ky": "^1.14.2", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", @@ -227,6 +227,8 @@ "@prisma/instrumentation": ["@prisma/instrumentation@7.2.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g=="], + "@sentry/api": ["@sentry/api@0.1.0", "", {}, "sha512-MD7fuza1n+dkLT+rCbom3GX4/IvwI36WBWdjCZxmNJbxUH/PXzBZN7leoK5q/5KrtrzdxrBHVV5OA03HBf1SqA=="], + "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@2.23.1", "", {}, "sha512-l1z8AvI6k9I+2z49OgvP3SlzB1M0Lw24KtceiJibNaSyQwxsItoT9/XftZ/8BBtkosVmNOTQhL1eUsSkuSv1LA=="], "@sentry/bun": ["@sentry/bun@10.38.0", "", { "dependencies": { "@sentry/core": "10.38.0", "@sentry/node": "10.38.0" } }, "sha512-8a2s+FVeqI2l12RNMFFEjAXpAUkqNZeGXTvHtjzcyWASW9szBNhOpiKN8oy0R/wUeIWgHpdnUeOSBhVKzH5YfQ=="], @@ -375,8 +377,6 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "ky": ["ky@1.14.2", "", {}, "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], diff --git a/package.json b/package.json index 51953b33..fdc8bd83 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.8", + "@sentry/api": "^0.1.0", "@sentry/bun": "10.38.0", "@sentry/esbuild-plugin": "^2.23.0", "@sentry/node": "10.38.0", @@ -40,7 +41,6 @@ "esbuild": "^0.25.0", "fast-check": "^4.5.3", "ignore": "^7.0.5", - "ky": "^1.14.2", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 38f3528f..f79841a8 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,34 +1,43 @@ /** * Sentry API Client * - * Handles authenticated requests to the Sentry API. - * Uses ky for retry logic, timeouts, and better error handling. + * Wraps @sentry/api SDK functions with multi-region support, + * telemetry, and custom error handling. + * + * Uses @sentry/api for type-safe API calls to public endpoints. + * Falls back to raw requests for internal/undocumented endpoints. */ -import kyHttpClient, { type KyInstance } from "ky"; -import { z } from "zod"; import { - type DetailedLogsResponse, + listAnOrganization_sIssues, + listAnOrganization_sProjects, + listAnOrganization_sTeams, + listAProject_sClientKeys, + queryExploreEventsInTableFormat, + resolveAShortId, + retrieveAnEventForAProject, + retrieveAnIssueEvent, + retrieveAnOrganization, + retrieveAProject, + retrieveSeerIssueFixState, + listYourOrganizations as sdkListOrganizations, + startSeerIssueFix, +} from "@sentry/api"; +import type { z } from "zod"; + +import { DetailedLogsResponseSchema, type DetailedSentryLog, - type LogsResponse, LogsResponseSchema, type ProjectKey, - ProjectKeySchema, type Region, type SentryEvent, - SentryEventSchema, type SentryIssue, - SentryIssueSchema, type SentryLog, type SentryOrganization, - SentryOrganizationSchema, type SentryProject, - SentryProjectSchema, type SentryRepository, - SentryRepositorySchema, type SentryTeam, - SentryTeamSchema, type SentryUser, SentryUserSchema, type TraceSpan, @@ -38,53 +47,19 @@ import { type UserRegionsResponse, UserRegionsResponseSchema, } from "../types/index.js"; + import type { AutofixResponse, AutofixState } from "../types/seer.js"; -import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; -import { refreshToken } from "./db/auth.js"; import { ApiError, AuthError } from "./errors.js"; -import { withHttpSpan } from "./telemetry.js"; +import { resolveOrgRegion } from "./region.js"; +import { + getApiBaseUrl, + getControlSiloUrl, + getDefaultSdkConfig, + getSdkConfig, +} from "./sentry-client.js"; import { isAllDigits } from "./utils.js"; -/** - * Control silo URL - handles OAuth, user accounts, and region routing. - * This is always sentry.io for SaaS, or the base URL for self-hosted. - */ -const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; - -/** Request timeout in milliseconds */ -const REQUEST_TIMEOUT_MS = 30_000; - -/** Maximum retry attempts for failed requests */ -const MAX_RETRIES = 2; - -/** Maximum backoff delay between retries in milliseconds */ -const MAX_BACKOFF_MS = 10_000; - -/** HTTP status codes that trigger automatic retry */ -const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; - -/** Regex to extract org slug from /organizations/{slug}/... endpoints */ -const ORG_ENDPOINT_REGEX = /^\/?organizations\/([^/]+)/; - -/** Regex to extract org slug from /projects/{org}/{project}/... endpoints */ -const PROJECT_ENDPOINT_REGEX = /^\/?projects\/([^/]+)\/[^/]+/; - -/** - * Get the Sentry API base URL. - * Supports self-hosted instances via SENTRY_URL env var. - */ -function getApiBaseUrl(): string { - const baseUrl = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; - return `${baseUrl}/api/0/`; -} - -/** - * Normalize endpoint path for use with ky's prefixUrl. - * Removes leading slash since ky handles URL joining. - */ -function normalizePath(endpoint: string): string { - return endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; -} +// Helpers type ApiRequestOptions = { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -95,69 +70,62 @@ type ApiRequestOptions = { schema?: z.ZodType; }; -/** Header to mark requests as retries, preventing infinite retry loops */ -const RETRY_MARKER_HEADER = "x-sentry-cli-retry"; +/** + * Throw an ApiError from a failed @sentry/api SDK response. + * + * @param error - The error object from the SDK (contains status code and detail) + * @param response - The raw Response object + * @param context - Human-readable context for the error message + */ +function throwApiError( + error: unknown, + response: Response | undefined, + context: string +): never { + const status = response?.status ?? 0; + const detail = + error && typeof error === "object" && "detail" in error + ? String((error as { detail: unknown }).detail) + : String(error); + throw new ApiError( + `${context}: ${status} ${response?.statusText ?? "Unknown"}`, + status, + detail + ); +} /** - * Create a configured ky instance with retry, timeout, and authentication. + * Unwrap an @sentry/api SDK result, throwing ApiError on failure. * - * @throws {AuthError} When not authenticated - * @throws {ApiError} When API request fails + * When `throwOnError` is false (our default), the SDK catches errors from + * the fetch function and returns them in `{ error }`. This includes our + * AuthError from refreshToken(). We must re-throw known error types (AuthError, + * ApiError) directly so callers can distinguish auth failures from API errors. + * + * @param result - The result from an SDK function call + * @param context - Human-readable context for error messages + * @returns The data from the successful response */ -async function createApiClient(): Promise { - const { token } = await refreshToken(); - - return kyHttpClient.create({ - prefixUrl: getApiBaseUrl(), - timeout: REQUEST_TIMEOUT_MS, - retry: { - limit: MAX_RETRIES, - methods: ["get", "put", "delete", "patch"], - statusCodes: RETRYABLE_STATUS_CODES, - backoffLimit: MAX_BACKOFF_MS, - }, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": getUserAgent(), - }, - hooks: { - afterResponse: [ - async (request, options, response) => { - // On 401, force token refresh and retry once - const isRetry = request.headers.get(RETRY_MARKER_HEADER) === "1"; - if (response.status === 401 && !isRetry) { - try { - const { token: newToken, refreshed } = await refreshToken({ - force: true, - }); - - // Don't retry if token wasn't refreshed (e.g., manual API token) - if (!refreshed) { - return response; - } - - const retryHeaders = new Headers(options.headers); - retryHeaders.set("Authorization", `Bearer ${newToken}`); - retryHeaders.set(RETRY_MARKER_HEADER, "1"); - - // Spread options but remove prefixUrl since request.url is already absolute - const { prefixUrl: _, ...retryOptions } = options; - return kyHttpClient(request.url, { - ...retryOptions, - headers: retryHeaders, - retry: 0, - }); - } catch { - // Token refresh failed, return original 401 response - return response; - } - } - return response; - }, - ], - }, - }); +function unwrapResult( + result: { data: T; error: undefined } | { data: undefined; error: unknown }, + context: string +): T { + const { data, error } = result as { + data: unknown; + error: unknown; + response?: Response; + }; + + if (error !== undefined) { + // Preserve known error types that were caught by the SDK from our fetch function + if (error instanceof AuthError || error instanceof ApiError) { + throw error; + } + const response = (result as { response?: Response }).response; + throwApiError(error, response, context); + } + + return data as T; } /** @@ -181,7 +149,6 @@ export function buildSearchParams( continue; } if (Array.isArray(value)) { - // Repeated keys for arrays: tags=1&tags=2&tags=3 for (const item of value) { searchParams.append(key, item); } @@ -194,61 +161,93 @@ export function buildSearchParams( } /** - * Make an authenticated request to the Sentry API. + * Get SDK config for an organization's region. + * Resolves the org's region URL and returns the config. + */ +async function getOrgSdkConfig(orgSlug: string) { + const regionUrl = await resolveOrgRegion(orgSlug); + return getSdkConfig(regionUrl); +} + +// Raw request functions (for internal/generic endpoints) + +/** + * Make an authenticated request to a specific Sentry region. + * Used for internal endpoints not covered by @sentry/api SDK functions. * - * @param endpoint - API endpoint path (e.g., "/organizations/") - * @param options - Request options including method, body, query params, and validation schema - * @returns Parsed JSON response (validated if schema provided) - * @throws {AuthError} When not authenticated - * @throws {ApiError} On API errors - * @throws {z.ZodError} When response fails schema validation + * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) + * @param endpoint - API endpoint path (e.g., "/users/me/regions/") + * @param options - Request options */ -export function apiRequest( +export async function apiRequestToRegion( + regionUrl: string, endpoint: string, options: ApiRequestOptions = {} ): Promise { const { method = "GET", body, params, schema } = options; + const config = getSdkConfig(regionUrl); + + const searchParams = buildSearchParams(params); + const normalizedEndpoint = endpoint.startsWith("/") + ? endpoint.slice(1) + : endpoint; + const queryString = searchParams ? `?${searchParams.toString()}` : ""; + // getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests + const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`; + + const fetchFn = config.fetch; + const headers: Record = { + "Content-Type": "application/json", + }; + const response = await fetchFn(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); - return withHttpSpan(method, endpoint, async () => { - const client = await createApiClient(); - - let response: Response; + if (!response.ok) { + let detail: string | undefined; try { - response = await client(normalizePath(endpoint), { - method, - json: body, - searchParams: buildSearchParams(params), - }); - } catch (error) { - // Transform ky HTTPError into ApiError - if (error && typeof error === "object" && "response" in error) { - const kyError = error as { response: Response }; - const text = await kyError.response.text(); - let detail: string | undefined; - try { - const parsed = JSON.parse(text) as { detail?: string }; - detail = parsed.detail ?? JSON.stringify(parsed); - } catch { - detail = text; - } - throw new ApiError( - `API request failed: ${kyError.response.status} ${kyError.response.statusText}`, - kyError.response.status, - detail - ); + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { detail?: string }; + detail = parsed.detail ?? JSON.stringify(parsed); + } catch { + detail = text; } - throw error; + } catch { + detail = response.statusText; } + throw new ApiError( + `API request failed: ${response.status} ${response.statusText}`, + response.status, + detail + ); + } - const data = await response.json(); + const data = await response.json(); - // Validate response if schema provided - if (schema) { - return schema.parse(data); - } + if (schema) { + return schema.parse(data); + } - return data as T; - }); + return data as T; +} + +/** + * Make an authenticated request to the default Sentry API. + * + * @param endpoint - API endpoint path (e.g., "/organizations/") + * @param options - Request options including method, body, query params, and validation schema + * @returns Parsed JSON response (validated if schema provided) + * @throws {AuthError} When not authenticated + * @throws {ApiError} On API errors + */ +export function apiRequest( + endpoint: string, + options: ApiRequestOptions = {} +): Promise { + return apiRequestToRegion(getApiBaseUrl(), endpoint, options); } /** @@ -261,172 +260,64 @@ export function apiRequest( * @returns Response status, headers, and parsed body * @throws {AuthError} Only on authentication failure (not on API errors) */ -export function rawApiRequest( +export async function rawApiRequest( endpoint: string, options: ApiRequestOptions & { headers?: Record } = {} ): Promise<{ status: number; headers: Headers; body: unknown }> { const { method = "GET", body, params, headers: customHeaders = {} } = options; - return withHttpSpan(method, endpoint, async () => { - const client = await createApiClient(); - - // Handle body based on type: - // - Objects: use ky's json option (auto-stringifies and sets Content-Type) - // - Strings: send as raw body (user can set Content-Type via custom headers if needed) - // - undefined: no body - const isStringBody = typeof body === "string"; - - // For string bodies, remove the default Content-Type: application/json from createApiClient - // unless the user explicitly provides one. This allows sending non-JSON content. - // Check is case-insensitive since HTTP headers are case-insensitive. - const hasContentType = Object.keys(customHeaders).some( - (k) => k.toLowerCase() === "content-type" - ); - const headers = - isStringBody && !hasContentType - ? { ...customHeaders, "Content-Type": undefined } - : customHeaders; - - const requestOptions: Parameters[1] = { - method, - searchParams: buildSearchParams(params), - headers, - throwHttpErrors: false, - }; - - if (body !== undefined) { - if (isStringBody) { - requestOptions.body = body; - } else { - requestOptions.json = body; - } - } + const config = getDefaultSdkConfig(); + + const searchParams = buildSearchParams(params); + const normalizedEndpoint = endpoint.startsWith("/") + ? endpoint.slice(1) + : endpoint; + const queryString = searchParams ? `?${searchParams.toString()}` : ""; + // getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests + const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`; + + // Build request headers and body. + // String bodies: no Content-Type unless the caller explicitly provides one. + // Object bodies: application/json (auto-stringified). + const isStringBody = typeof body === "string"; + const hasContentType = Object.keys(customHeaders).some( + (k) => k.toLowerCase() === "content-type" + ); - const response = await client(normalizePath(endpoint), requestOptions); + const headers: Record = { ...customHeaders }; + if (!(isStringBody || hasContentType) && body !== undefined) { + headers["Content-Type"] = "application/json"; + } - const text = await response.text(); - let responseBody: unknown; - try { - responseBody = JSON.parse(text); - } catch { - responseBody = text; - } + let requestBody: string | undefined; + if (body !== undefined) { + requestBody = isStringBody ? body : JSON.stringify(body); + } - return { - status: response.status, - headers: response.headers, - body: responseBody, - }; + const fetchFn = config.fetch; + const response = await fetchFn(url, { + method, + headers, + body: requestBody, }); -} -/** - * Create a ky client configured for a specific region URL. - * Used for making requests to region-specific endpoints. - * - * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) - */ -async function createRegionApiClient(regionUrl: string): Promise { - const { token } = await refreshToken(); - const baseUrl = regionUrl.endsWith("/") ? regionUrl : `${regionUrl}/`; - - return kyHttpClient.create({ - prefixUrl: `${baseUrl}api/0/`, - timeout: REQUEST_TIMEOUT_MS, - retry: { - limit: MAX_RETRIES, - methods: ["get", "put", "delete", "patch"], - statusCodes: RETRYABLE_STATUS_CODES, - backoffLimit: MAX_BACKOFF_MS, - }, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": getUserAgent(), - }, - hooks: { - afterResponse: [ - async (request, options, response) => { - const isRetry = request.headers.get(RETRY_MARKER_HEADER) === "1"; - if (response.status === 401 && !isRetry) { - try { - const { token: newToken, refreshed } = await refreshToken({ - force: true, - }); - if (!refreshed) { - return response; - } - const retryHeaders = new Headers(options.headers); - retryHeaders.set("Authorization", `Bearer ${newToken}`); - retryHeaders.set(RETRY_MARKER_HEADER, "1"); - const { prefixUrl: _, ...retryOptions } = options; - return kyHttpClient(request.url, { - ...retryOptions, - headers: retryHeaders, - retry: 0, - }); - } catch { - return response; - } - } - return response; - }, - ], - }, - }); -} - -/** - * Make an authenticated request to a specific Sentry region. - * - * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) - * @param endpoint - API endpoint path (e.g., "/organizations/") - * @param options - Request options - */ -export async function apiRequestToRegion( - regionUrl: string, - endpoint: string, - options: ApiRequestOptions = {} -): Promise { - const { method = "GET", body, params, schema } = options; - const client = await createRegionApiClient(regionUrl); - - let response: Response; + const text = await response.text(); + let responseBody: unknown; try { - response = await client(normalizePath(endpoint), { - method, - json: body, - searchParams: buildSearchParams(params), - }); - } catch (error) { - if (error && typeof error === "object" && "response" in error) { - const kyError = error as { response: Response }; - const text = await kyError.response.text(); - let detail: string | undefined; - try { - const parsed = JSON.parse(text) as { detail?: string }; - detail = parsed.detail ?? JSON.stringify(parsed); - } catch { - detail = text; - } - throw new ApiError( - `API request failed: ${kyError.response.status} ${kyError.response.statusText}`, - kyError.response.status, - detail - ); - } - throw error; + responseBody = JSON.parse(text); + } catch { + responseBody = text; } - const data = await response.json(); - - if (schema) { - return schema.parse(data); - } - - return data as T; + return { + status: response.status, + headers: response.headers, + body: responseBody, + }; } +// Organization functions + /** * Get the list of regions the user has organization membership in. * This endpoint is on the control silo (sentry.io) and returns all regions. @@ -434,9 +325,9 @@ export async function apiRequestToRegion( * @returns Array of regions with name and URL */ export async function getUserRegions(): Promise { - // Always use control silo for this endpoint + // /users/me/regions/ is an internal endpoint - use raw request const response = await apiRequestToRegion( - CONTROL_SILO_URL, + getControlSiloUrl(), "/users/me/regions/", { schema: UserRegionsResponseSchema } ); @@ -449,64 +340,17 @@ export async function getUserRegions(): Promise { * @param regionUrl - The region's base URL * @returns Organizations in that region */ -export function listOrganizationsInRegion( +export async function listOrganizationsInRegion( regionUrl: string ): Promise { - return apiRequestToRegion( - regionUrl, - "/organizations/", - { - schema: z.array(SentryOrganizationSchema), - } - ); -} + const config = getSdkConfig(regionUrl); -/** - * Extract organization slug from an endpoint path. - * Supports: - * - `/organizations/{slug}/...` - standard organization endpoints - * - `/projects/{org}/{project}/...` - project-scoped endpoints - */ -function extractOrgSlugFromEndpoint(endpoint: string): string | null { - // Try organization path first: /organizations/{slug}/... - const orgMatch = endpoint.match(ORG_ENDPOINT_REGEX); - if (orgMatch?.[1]) { - return orgMatch[1]; - } - - // Try project path: /projects/{org}/{project}/... - const projectMatch = endpoint.match(PROJECT_ENDPOINT_REGEX); - if (projectMatch?.[1]) { - return projectMatch[1]; - } - - return null; -} + const result = await sdkListOrganizations({ + ...config, + }); -/** - * Make an org-scoped API request, automatically resolving the correct region. - * This is the preferred way to make org-scoped requests. - * - * The endpoint must contain the org slug in the path (e.g., `/organizations/{slug}/...`). - * The org slug is extracted to look up the correct region URL. - * - * @param endpoint - API endpoint path containing the org slug - * @param options - Request options - */ -async function orgScopedRequest( - endpoint: string, - options: ApiRequestOptions = {} -): Promise { - const orgSlug = extractOrgSlugFromEndpoint(endpoint); - if (!orgSlug) { - throw new Error( - `Cannot extract org slug from endpoint: ${endpoint}. ` + - "Endpoint must match /organizations/{slug}/..." - ); - } - const { resolveOrgRegion } = await import("./region.js"); - const regionUrl = await resolveOrgRegion(orgSlug); - return apiRequestToRegion(regionUrl, endpoint, options); + const data = unwrapResult(result, "Failed to list organizations"); + return data as unknown as SentryOrganization[]; } /** @@ -521,7 +365,6 @@ export async function listOrganizations(): Promise { try { regions = await getUserRegions(); } catch (error) { - // Re-throw auth errors - user needs to login if (error instanceof AuthError) { throw error; } @@ -531,9 +374,7 @@ export async function listOrganizations(): Promise { if (regions.length === 0) { // Fall back to default API for self-hosted instances - return apiRequest("/organizations/", { - schema: z.array(SentryOrganizationSchema), - }); + return listOrganizationsInRegion(getApiBaseUrl()); } const results = await Promise.all( @@ -566,23 +407,36 @@ export async function listOrganizations(): Promise { * Get a specific organization. * Uses region-aware routing for multi-region support. */ -export function getOrganization(orgSlug: string): Promise { - return orgScopedRequest(`/organizations/${orgSlug}/`, { - schema: SentryOrganizationSchema, +export async function getOrganization( + orgSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveAnOrganization({ + ...config, + path: { organization_id_or_slug: orgSlug }, }); + + const data = unwrapResult(result, "Failed to get organization"); + return data as unknown as SentryOrganization; } +// Project functions + /** * List projects in an organization. * Uses region-aware routing for multi-region support. */ -export function listProjects(orgSlug: string): Promise { - return orgScopedRequest( - `/organizations/${orgSlug}/projects/`, - { - schema: z.array(SentryProjectSchema), - } - ); +export async function listProjects(orgSlug: string): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await listAnOrganization_sProjects({ + ...config, + path: { organization_id_or_slug: orgSlug }, + }); + + const data = unwrapResult(result, "Failed to list projects"); + return data as unknown as SentryProject[]; } /** Project with its organization context */ @@ -595,12 +449,14 @@ export type ProjectWithOrg = SentryProject & { * List repositories in an organization. * Uses region-aware routing for multi-region support. */ -export function listRepositories(orgSlug: string): Promise { - return orgScopedRequest( - `/organizations/${orgSlug}/repos/`, - { - schema: z.array(SentryRepositorySchema), - } +export async function listRepositories( + orgSlug: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/repos/` ); } @@ -608,10 +464,16 @@ export function listRepositories(orgSlug: string): Promise { * List teams in an organization. * Uses region-aware routing for multi-region support. */ -export function listTeams(orgSlug: string): Promise { - return orgScopedRequest(`/organizations/${orgSlug}/teams/`, { - schema: z.array(SentryTeamSchema), +export async function listTeams(orgSlug: string): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await listAnOrganization_sTeams({ + ...config, + path: { organization_id_or_slug: orgSlug }, }); + + const data = unwrapResult(result, "Failed to list teams"); + return data as unknown as SentryTeam[]; } /** @@ -628,7 +490,6 @@ export async function findProjectsBySlug( ): Promise { const orgs = await listOrganizations(); - // Search in parallel for performance const searchResults = await Promise.all( orgs.map(async (org) => { try { @@ -639,11 +500,9 @@ export async function findProjectsBySlug( } return null; } catch (error) { - // Re-throw auth errors - user needs to login if (error instanceof AuthError) { throw error; } - // Skip orgs where user lacks access (permission errors, etc.) return null; } }) @@ -712,7 +571,6 @@ export async function findProjectsByPattern( if (error instanceof AuthError) { throw error; } - // Skip orgs where user lacks access (permission errors, etc.) return []; } }) @@ -738,20 +596,20 @@ export async function findProjectByDsnKey( try { regions = await getUserRegions(); } catch (error) { - // Re-throw auth errors - user needs to login if (error instanceof AuthError) { throw error; } - // Self-hosted instances may not have the regions endpoint (404) regions = []; } if (regions.length === 0) { // Fall back to default region for self-hosted - const projects = await apiRequest("/projects/", { - params: { query: `dsn:${publicKey}` }, - schema: z.array(SentryProjectSchema), - }); + // This uses an internal query parameter not in the public API + const projects = await apiRequestToRegion( + getApiBaseUrl(), + "/projects/", + { params: { query: `dsn:${publicKey}` } } + ); return projects[0] ?? null; } @@ -761,10 +619,7 @@ export async function findProjectByDsnKey( return await apiRequestToRegion( region.url, "/projects/", - { - params: { query: `dsn:${publicKey}` }, - schema: z.array(SentryProjectSchema), - } + { params: { query: `dsn:${publicKey}` } } ); } catch { return []; @@ -785,95 +640,132 @@ export async function findProjectByDsnKey( * Get a specific project. * Uses region-aware routing for multi-region support. */ -export function getProject( +export async function getProject( orgSlug: string, projectSlug: string ): Promise { - return orgScopedRequest( - `/projects/${orgSlug}/${projectSlug}/`, - { - schema: SentryProjectSchema, - } - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveAProject({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + }, + }); + + const data = unwrapResult(result, "Failed to get project"); + return data as unknown as SentryProject; } /** * Get project keys (DSNs) for a project. * Uses region-aware routing for multi-region support. */ -export function getProjectKeys( +export async function getProjectKeys( orgSlug: string, projectSlug: string ): Promise { - return orgScopedRequest( - `/projects/${orgSlug}/${projectSlug}/keys/`, - { - schema: z.array(ProjectKeySchema), - } - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await listAProject_sClientKeys({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + }, + }); + + const data = unwrapResult(result, "Failed to get project keys"); + return data as unknown as ProjectKey[]; } +// Issue functions + /** * List issues for a project. + * Uses the org-scoped endpoint (the project-scoped one is deprecated). * Uses region-aware routing for multi-region support. */ -export function listIssues( +export async function listIssues( orgSlug: string, projectSlug: string, options: { query?: string; cursor?: string; limit?: number; - sort?: "date" | "new" | "priority" | "freq" | "user"; + sort?: "date" | "new" | "freq" | "user"; statsPeriod?: string; } = {} ): Promise { - return orgScopedRequest( - `/projects/${orgSlug}/${projectSlug}/issues/`, - { - params: { - query: options.query, - cursor: options.cursor, - limit: options.limit, - sort: options.sort, - statsPeriod: options.statsPeriod, - }, - schema: z.array(SentryIssueSchema), - } - ); + const config = await getOrgSdkConfig(orgSlug); + + // Build query with project filter: "project:{slug}" prefix + const projectFilter = `project:${projectSlug}`; + const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); + + const result = await listAnOrganization_sIssues({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { + query: fullQuery, + cursor: options.cursor, + limit: options.limit, + sort: options.sort, + statsPeriod: options.statsPeriod, + }, + }); + + const data = unwrapResult(result, "Failed to list issues"); + return data as unknown as SentryIssue[]; } /** - * Get a specific issue by numeric ID + * Get a specific issue by numeric ID. */ export function getIssue(issueId: string): Promise { - return apiRequest(`/issues/${issueId}/`, { - schema: SentryIssueSchema, - }); + // The @sentry/api SDK's retrieveAnIssue requires org slug in path, + // but the legacy endpoint /issues/{id}/ works without org context. + // Use raw request for backward compatibility. + return apiRequest(`/issues/${issueId}/`); } /** * Get an issue by short ID (e.g., SPOTLIGHT-ELECTRON-4D). * Requires organization context to resolve the short ID. - * The shortId is normalized to uppercase for case-insensitive matching. * Uses region-aware routing for multi-region support. - * - * @see https://docs.sentry.io/api/events/retrieve-an-issue/ */ -export function getIssueByShortId( +export async function getIssueByShortId( orgSlug: string, shortId: string ): Promise { - // Normalize to uppercase for case-insensitive matching const normalizedShortId = shortId.toUpperCase(); - return orgScopedRequest( - `/organizations/${orgSlug}/issues/${normalizedShortId}/`, - { - schema: SentryIssueSchema, - } - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await resolveAShortId({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: normalizedShortId, + }, + }); + + const data = unwrapResult(result, "Failed to resolve short ID"); + + // resolveAShortId returns a ShortIdLookupResponse with a group (issue) + const resolved = data as unknown as { group?: SentryIssue }; + if (!resolved.group) { + throw new ApiError( + `Short ID ${normalizedShortId} resolved but no issue group returned`, + 404, + "Issue not found" + ); + } + return resolved.group; } +// Event functions + /** * Get the latest event for an issue. * Uses region-aware routing for multi-region support. @@ -881,37 +773,52 @@ export function getIssueByShortId( * @param orgSlug - Organization slug (required for multi-region routing) * @param issueId - Issue ID (numeric) */ -export function getLatestEvent( +export async function getLatestEvent( orgSlug: string, issueId: string ): Promise { - return orgScopedRequest( - `/organizations/${orgSlug}/issues/${issueId}/events/latest/` - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveAnIssueEvent({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: Number(issueId), + event_id: "latest", + }, + }); + + const data = unwrapResult(result, "Failed to get latest event"); + return data as unknown as SentryEvent; } /** * Get a specific event by ID. * Uses region-aware routing for multi-region support. - * - * @see https://docs.sentry.io/api/events/retrieve-an-event-for-a-project/ */ -export function getEvent( +export async function getEvent( orgSlug: string, projectSlug: string, eventId: string ): Promise { - return orgScopedRequest( - `/projects/${orgSlug}/${projectSlug}/events/${eventId}/`, - { - schema: SentryEventSchema, - } - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveAnEventForAProject({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + event_id: eventId, + }, + }); + + const data = unwrapResult(result, "Failed to get event"); + return data as unknown as SentryEvent; } /** * Get detailed trace with nested children structure. - * Uses the same endpoint as Sentry's dashboard for hierarchical span trees. + * This is an internal endpoint not covered by the public API. * Uses region-aware routing for multi-region support. * * @param orgSlug - Organization slug @@ -919,20 +826,20 @@ export function getEvent( * @param timestamp - Unix timestamp (seconds) from the event's dateCreated * @returns Array of root spans with nested children */ -export function getDetailedTrace( +export async function getDetailedTrace( orgSlug: string, traceId: string, timestamp: number ): Promise { - return orgScopedRequest( + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, `/organizations/${orgSlug}/trace/${traceId}/`, { params: { timestamp, - // Maximum spans to fetch - 10k is sufficient for most traces while - // preventing excessive response sizes for very large traces limit: 10_000, - // -1 means "all projects" - required since trace can span multiple projects project: -1, }, } @@ -978,12 +885,15 @@ export async function listTransactions( projectSlug: string, options: ListTransactionsOptions = {} ): Promise { - // API only accepts numeric project IDs as param, slugs go in query const isNumericProject = isAllDigits(projectSlug); const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); - const response = await orgScopedRequest( + const regionUrl = await resolveOrgRegion(orgSlug); + + // Use raw request: the SDK's dataset type doesn't include "transactions" + const response = await apiRequestToRegion( + regionUrl, `/organizations/${orgSlug}/events/`, { params: { @@ -1003,20 +913,25 @@ export async function listTransactions( return response.data; } +// Issue update functions + /** - * Update an issue's status + * Update an issue's status. */ export function updateIssueStatus( issueId: string, status: "resolved" | "unresolved" | "ignored" ): Promise { + // Use raw request - the SDK's updateAnIssue requires org slug but + // the legacy /issues/{id}/ endpoint works without it return apiRequest(`/issues/${issueId}/`, { method: "PUT", body: { status }, - schema: SentryIssueSchema, }); } +// Seer AI functions + /** * Trigger root cause analysis for an issue using Seer AI. * Uses region-aware routing for multi-region support. @@ -1026,17 +941,25 @@ export function updateIssueStatus( * @returns The trigger response with run_id * @throws {ApiError} On API errors (402 = no budget, 403 = not enabled) */ -export function triggerRootCauseAnalysis( +export async function triggerRootCauseAnalysis( orgSlug: string, issueId: string ): Promise<{ run_id: number }> { - return orgScopedRequest<{ run_id: number }>( - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - body: { step: "root_cause" }, - } - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await startSeerIssueFix({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: Number(issueId), + }, + body: { + stopping_point: "root_cause", + }, + }); + + const data = unwrapResult(result, "Failed to trigger root cause analysis"); + return data as unknown as { run_id: number }; } /** @@ -1051,16 +974,23 @@ export async function getAutofixState( orgSlug: string, issueId: string ): Promise { - const response = await orgScopedRequest( - `/organizations/${orgSlug}/issues/${issueId}/autofix/` - ); + const config = await getOrgSdkConfig(orgSlug); - return response.autofix; + const result = await retrieveSeerIssueFixState({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: Number(issueId), + }, + }); + + const data = unwrapResult(result, "Failed to get autofix state"); + const autofixResponse = data as unknown as AutofixResponse; + return autofixResponse.autofix; } /** * Trigger solution planning for an existing autofix run. - * Continues from root cause analysis to generate a solution. * Uses region-aware routing for multi-region support. * * @param orgSlug - The organization slug @@ -1068,12 +998,15 @@ export async function getAutofixState( * @param runId - The autofix run ID * @returns The response from the API */ -export function triggerSolutionPlanning( +export async function triggerSolutionPlanning( orgSlug: string, issueId: string, runId: number ): Promise { - return orgScopedRequest( + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, `/organizations/${orgSlug}/issues/${issueId}/autofix/`, { method: "POST", @@ -1085,19 +1018,20 @@ export function triggerSolutionPlanning( ); } +// User functions + /** * Get the currently authenticated user's information. - * Used for setting user context in telemetry. - * - * Note: This endpoint may not work with OAuth App tokens, but works with - * manually created API tokens. Callers should handle failures gracefully. + * Uses the /users/me/ endpoint on the control silo. */ export function getCurrentUser(): Promise { - return apiRequest("/users/me/", { + return apiRequestToRegion(getControlSiloUrl(), "/users/me/", { schema: SentryUserSchema, }); } +// Log functions + /** Fields to request from the logs API */ const LOG_FIELDS = [ "sentry.item_id", @@ -1123,10 +1057,6 @@ type ListLogsOptions = { * List logs for an organization/project. * Uses the Explore/Events API with dataset=logs. * - * Handles project slug vs numeric ID automatically: - * - Numeric IDs are passed as the `project` parameter - * - Slugs are added to the query string as `project:{slug}` - * * @param orgSlug - Organization slug * @param projectSlug - Project slug or numeric ID * @param options - Query options (query, limit, statsPeriod) @@ -1137,10 +1067,8 @@ export async function listLogs( projectSlug: string, options: ListLogsOptions = {} ): Promise { - // API only accepts numeric project IDs as param, slugs go in query const isNumericProject = isAllDigits(projectSlug); - // Build query parts const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; const timestampFilter = options.afterTimestamp ? `timestamp_precise:>${options.afterTimestamp}` @@ -1150,23 +1078,25 @@ export async function listLogs( .filter(Boolean) .join(" "); - const response = await orgScopedRequest( - `/organizations/${orgSlug}/events/`, - { - params: { - dataset: "logs", - field: LOG_FIELDS, - project: isNumericProject ? projectSlug : undefined, - query: fullQuery || undefined, - per_page: options.limit || 100, - statsPeriod: options.statsPeriod ?? "7d", - sort: "-timestamp", - }, - schema: LogsResponseSchema, - } - ); + const config = await getOrgSdkConfig(orgSlug); + + const result = await queryExploreEventsInTableFormat({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: "logs", + field: LOG_FIELDS, + project: isNumericProject ? [Number(projectSlug)] : undefined, + query: fullQuery || undefined, + per_page: options.limit || 100, + statsPeriod: options.statsPeriod ?? "7d", + sort: "-timestamp", + }, + }); - return response.data; + const data = unwrapResult(result, "Failed to list logs"); + const logsResponse = LogsResponseSchema.parse(data); + return logsResponse.data; } /** All fields to request for detailed log view */ @@ -1206,20 +1136,21 @@ export async function getLog( logId: string ): Promise { const query = `project:${projectSlug} sentry.item_id:${logId}`; + const config = await getOrgSdkConfig(orgSlug); + + const result = await queryExploreEventsInTableFormat({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { + dataset: "logs", + field: DETAILED_LOG_FIELDS, + query, + per_page: 1, + statsPeriod: "90d", + }, + }); - const response = await orgScopedRequest( - `/organizations/${orgSlug}/events/`, - { - params: { - dataset: "logs", - field: DETAILED_LOG_FIELDS, - query, - per_page: 1, - statsPeriod: "90d", - }, - schema: DetailedLogsResponseSchema, - } - ); - - return response.data[0] ?? null; + const data = unwrapResult(result, "Failed to get log"); + const logsResponse = DetailedLogsResponseSchema.parse(data); + return logsResponse.data[0] ?? null; } diff --git a/src/lib/region.ts b/src/lib/region.ts index 55353cad..8b546f14 100644 --- a/src/lib/region.ts +++ b/src/lib/region.ts @@ -5,7 +5,10 @@ * using cached data when available or fetching from the API when needed. */ +import { retrieveAnOrganization } from "@sentry/api"; import { getOrgRegion, setOrgRegion } from "./db/regions.js"; +import { AuthError } from "./errors.js"; +import { getSdkConfig } from "./sentry-client.js"; import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; /** @@ -13,9 +16,11 @@ import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; * * Resolution order: * 1. Check SQLite cache - * 2. Fetch organization details to get region URL + * 2. Fetch organization details via SDK to get region URL * 3. Fall back to default URL if resolution fails * + * Uses the SDK directly (not api-client) to avoid circular dependency. + * * @param orgSlug - The organization slug * @returns The region URL for the organization */ @@ -26,28 +31,36 @@ export async function resolveOrgRegion(orgSlug: string): Promise { return cached; } - // 2. Try to fetch org details to get region - // Import dynamically to avoid circular dependency - const { apiRequestToRegion } = await import("./api-client.js"); - const { SentryOrganizationSchema } = await import("../types/sentry.js"); + // 2. Fetch org details via SDK to discover the region URL + const baseUrl = getSentryBaseUrl(); + const config = getSdkConfig(baseUrl); try { - // First try the default URL - it may route correctly - const baseUrl = getSentryBaseUrl(); - const org = await apiRequestToRegion( - baseUrl, - `/organizations/${orgSlug}/`, - { schema: SentryOrganizationSchema } - ); + const result = await retrieveAnOrganization({ + ...config, + path: { organization_id_or_slug: orgSlug }, + }); + + if (result.error !== undefined) { + // Propagate auth errors so callers can prompt login + if (result.error instanceof AuthError) { + throw result.error; + } + return baseUrl; + } - const regionUrl = org.links?.regionUrl ?? baseUrl; + const regionUrl = result.data?.links?.regionUrl ?? baseUrl; // Cache for future use await setOrgRegion(orgSlug, regionUrl); return regionUrl; - } catch { - // If fetch fails, fall back to default + } catch (error) { + // Propagate auth errors so callers can prompt login + if (error instanceof AuthError) { + throw error; + } + // Other errors (network, 404, etc.) fall back to default // This handles self-hosted instances without multi-region return getSentryBaseUrl(); } diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts new file mode 100644 index 00000000..00e46045 --- /dev/null +++ b/src/lib/sentry-client.ts @@ -0,0 +1,351 @@ +/** + * Sentry API Client Configuration + * + * Provides request configuration for @sentry/api SDK functions, + * including authentication, retry logic, timeout, and multi-region support. + * + * Instead of managing client instances, we pass configuration per-request + * through the SDK function options (baseUrl, fetch, headers). + */ + +import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; +import { refreshToken } from "./db/auth.js"; +import { withHttpSpan } from "./telemetry.js"; + +/** + * Control silo URL - handles OAuth, user accounts, and region routing. + * This is always sentry.io for SaaS, or the base URL for self-hosted. + */ +const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; + +/** Request timeout in milliseconds */ +const REQUEST_TIMEOUT_MS = 30_000; + +/** Maximum retry attempts for failed requests */ +const MAX_RETRIES = 2; + +/** Maximum backoff delay between retries in milliseconds */ +const MAX_BACKOFF_MS = 10_000; + +/** HTTP status codes that trigger automatic retry */ +const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; + +/** Header to mark requests as retries, preventing infinite token refresh loops */ +const RETRY_MARKER_HEADER = "x-sentry-cli-retry"; + +/** Calculate exponential backoff delay, capped at MAX_BACKOFF_MS */ +function backoffDelay(attempt: number): number { + return Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); +} + +/** Check if an error is a user-initiated abort */ +function isUserAbort(error: unknown, signal?: AbortSignal | null): boolean { + return ( + error instanceof DOMException && + error.name === "AbortError" && + Boolean(signal?.aborted) + ); +} + +/** + * Prepare request headers with auth token and default headers. + * + * Only sets Authorization and User-Agent. Content-Type is intentionally NOT + * set here — callers are responsible for setting it based on their needs: + * - SDK functions set their own Content-Type + * - apiRequestToRegion always sends JSON and sets it explicitly + * - rawApiRequest may or may not want Content-Type (e.g., string bodies) + * + * The returned Headers instance is intentionally shared and mutated across + * retry attempts (e.g., handleUnauthorized updates the Authorization header + * and sets the retry marker). Do not clone before passing to retry logic. + */ +function prepareHeaders(init: RequestInit | undefined, token: string): Headers { + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + if (!headers.has("User-Agent")) { + headers.set("User-Agent", getUserAgent()); + } + return headers; +} + +/** + * Handle 401 response by refreshing the token. + * @returns true if the token was refreshed and request should be retried + */ +async function handleUnauthorized(headers: Headers): Promise { + if (headers.get(RETRY_MARKER_HEADER)) { + return false; + } + try { + const { token: newToken, refreshed } = await refreshToken({ force: true }); + if (refreshed) { + headers.set("Authorization", `Bearer ${newToken}`); + headers.set(RETRY_MARKER_HEADER, "1"); + return true; + } + } catch { + // Token refresh failed + } + return false; +} + +/** Link an external abort signal to an AbortController */ +function linkAbortSignal( + signal: AbortSignal | undefined | null, + controller: AbortController +): void { + if (!signal) { + return; + } + if (signal.aborted) { + controller.abort(); + return; + } + signal.addEventListener("abort", () => controller.abort(), { once: true }); +} + +/** + * Execute a single fetch attempt with timeout. + * + * @returns The Response, or throws on network/timeout errors + */ +async function fetchWithTimeout( + input: Request | string | URL, + init: RequestInit | undefined, + headers: Headers, + externalSignal?: AbortSignal | null +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + linkAbortSignal(externalSignal, controller); + + try { + const response = await fetch(input, { + ...init, + headers, + signal: controller.signal, + }); + return response; + } finally { + clearTimeout(timeout); + } +} + +/** Result of a single fetch attempt - drives the retry loop */ +type AttemptResult = + | { action: "done"; response: Response } + | { action: "retry" } + | { action: "throw"; error: unknown }; + +/** + * Decide what to do with a successful HTTP response. + * Returns 'done' for final responses, 'retry' for retryable errors and 401s. + */ +async function handleResponse( + response: Response, + headers: Headers, + isLastAttempt: boolean +): Promise { + if (response.status === 401) { + const refreshed = await handleUnauthorized(headers); + return refreshed ? { action: "retry" } : { action: "done", response }; + } + + if (RETRYABLE_STATUS_CODES.includes(response.status) && !isLastAttempt) { + return { action: "retry" }; + } + + return { action: "done", response }; +} + +/** + * Decide what to do with a fetch error. + * Throws immediately for user-initiated aborts or last attempt; + * returns 'retry' otherwise. + */ +function handleFetchError( + error: unknown, + signal: AbortSignal | undefined | null, + isLastAttempt: boolean +): AttemptResult { + if (isUserAbort(error, signal)) { + return { action: "throw", error }; + } + if (isLastAttempt) { + return { action: "throw", error }; + } + return { action: "retry" }; +} + +/** Extract the URL pathname for span naming */ +function extractUrlPath(input: Request | string | URL): string { + let raw: string; + if (typeof input === "string") { + raw = input; + } else if (input instanceof URL) { + raw = input.href; + } else { + raw = input.url; + } + try { + return new URL(raw).pathname; + } catch { + return raw; + } +} + +/** + * Create a fetch function with authentication, timeout, retry, and 401 refresh. + * + * This wraps the native fetch with: + * - Auth token injection (Bearer token) + * - Request timeout via AbortController + * - Automatic retry on transient HTTP errors (408, 429, 5xx) + * - 401 handling: force-refreshes the token and retries once + * - Exponential backoff between retries + * - User-Agent header for API analytics + * - Automatic HTTP span tracing for every request + * + * @returns A fetch-compatible function for use with @sentry/api SDK functions + */ +function createAuthenticatedFetch(): ( + input: Request | string | URL, + init?: RequestInit +) => Promise { + return function authenticatedFetch( + input: Request | string | URL, + init?: RequestInit + ): Promise { + const method = init?.method ?? "GET"; + const urlPath = extractUrlPath(input); + + return withHttpSpan(method, urlPath, async () => { + const { token } = await refreshToken(); + const headers = prepareHeaders(init, token); + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const isLastAttempt = attempt === MAX_RETRIES; + const result = await executeAttempt( + input, + init, + headers, + isLastAttempt + ); + + if (result.action === "done") { + return result.response; + } + if (result.action === "throw") { + throw result.error; + } + + await Bun.sleep(backoffDelay(attempt)); + } + + // Unreachable: the last attempt always returns 'done' or 'throw' + throw new Error("Exhausted all retry attempts"); + }); + }; +} + +/** + * Execute a single fetch attempt and classify the outcome. + */ +async function executeAttempt( + input: Request | string | URL, + init: RequestInit | undefined, + headers: Headers, + isLastAttempt: boolean +): Promise { + try { + const response = await fetchWithTimeout(input, init, headers, init?.signal); + return handleResponse(response, headers, isLastAttempt); + } catch (error) { + return handleFetchError(error, init?.signal, isLastAttempt); + } +} + +/** Singleton authenticated fetch instance - reused across all requests */ +let cachedFetch: typeof fetch | null = null; + +/** + * Get the shared authenticated fetch instance. + * Cast to `typeof fetch` for compatibility with @sentry/api SDK options. + */ +function getAuthenticatedFetch(): typeof fetch { + if (!cachedFetch) { + cachedFetch = createAuthenticatedFetch() as unknown as typeof fetch; + } + return cachedFetch; +} + +/** + * Get the Sentry API base URL. + * Supports self-hosted instances via SENTRY_URL env var. + */ +export function getApiBaseUrl(): string { + return process.env.SENTRY_URL || DEFAULT_SENTRY_URL; +} + +/** + * Get the control silo URL. + * This is always sentry.io for SaaS, or the custom URL for self-hosted. + */ +export function getControlSiloUrl(): string { + return CONTROL_SILO_URL; +} + +/** + * Get request configuration for an @sentry/api SDK function call. + * + * Returns the common options needed by every SDK function call: + * - `baseUrl`: The API base URL for the target region + * - `fetch`: Authenticated fetch with retry, timeout, and 401 refresh + * - `throwOnError`: Always false (we handle errors ourselves) + * + * @param regionUrl - The base URL for the target region (e.g., https://us.sentry.io) + * @returns Configuration object to spread into SDK function options + * + * @example + * ```ts + * const config = getSdkConfig("https://us.sentry.io"); + * const result = await listYourOrganizations({ ...config }); + * ``` + */ +export function getSdkConfig(regionUrl: string) { + const normalizedBase = regionUrl.endsWith("/") + ? regionUrl.slice(0, -1) + : regionUrl; + + return { + // SDK functions already include /api/0/ in their URL paths, + // so baseUrl should be the plain region URL without /api/0. + baseUrl: normalizedBase, + fetch: getAuthenticatedFetch(), + throwOnError: false as const, + }; +} + +/** + * Get SDK config for the default API (control silo or self-hosted). + */ +export function getDefaultSdkConfig() { + return getSdkConfig(getApiBaseUrl()); +} + +/** + * Get SDK config for the control silo. + * Used for endpoints that are always on the control silo (OAuth, user accounts, regions). + */ +export function getControlSdkConfig() { + return getSdkConfig(CONTROL_SILO_URL); +} + +/** + * Reset the cached fetch instance. + * Useful for testing or when auth state changes. + */ +export function resetAuthenticatedFetch(): void { + cachedFetch = null; +} diff --git a/src/types/index.ts b/src/types/index.ts index ccb9fd16..a9bdd291 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,7 +25,6 @@ export type { TokenErrorResponse, TokenResponse, } from "./oauth.js"; -// OAuth types and schemas export { DeviceCodeResponseSchema, TokenErrorResponseSchema, @@ -45,6 +44,7 @@ export { SolutionArtifactSchema, TERMINAL_STATUSES, } from "./seer.js"; +// Sentry API types (SDK-derived + internal) export type { Breadcrumb, BreadcrumbsEntry, @@ -55,17 +55,12 @@ export type { ExceptionEntry, ExceptionValue, IssueLevel, - IssuePriority, IssueStatus, - IssueSubstatus, - LogSeverity, LogsResponse, Mechanism, - OrganizationLinks, OsContext, ProjectKey, Region, - Release, RepositoryProvider, RequestEntry, SentryEvent, @@ -76,54 +71,29 @@ export type { SentryRepository, SentryTeam, SentryUser, - Span, StackFrame, Stacktrace, TraceContext, TraceSpan, TransactionListItem, TransactionsResponse, - UserGeo, UserRegionsResponse, } from "./sentry.js"; export { - BreadcrumbSchema, - BreadcrumbsEntrySchema, - BrowserContextSchema, DetailedLogsResponseSchema, DetailedSentryLogSchema, - DeviceContextSchema, - ExceptionEntrySchema, - ExceptionValueSchema, ISSUE_LEVELS, - ISSUE_PRIORITIES, ISSUE_STATUSES, - LOG_SEVERITIES, LogsResponseSchema, - MechanismSchema, - OrganizationLinksSchema, - OsContextSchema, - ProjectKeySchema, RegionSchema, - ReleaseSchema, RepositoryProviderSchema, - RequestEntrySchema, - SentryEventSchema, - SentryIssueSchema, SentryLogSchema, - SentryOrganizationSchema, - SentryProjectSchema, SentryRepositorySchema, SentryTeamSchema, SentryUserSchema, - SpanSchema, - StackFrameSchema, - StacktraceSchema, - TraceContextSchema, TransactionListItemSchema, TransactionsResponseSchema, - UserGeoSchema, UserRegionsResponseSchema, } from "./sentry.js"; diff --git a/src/types/sentry.ts b/src/types/sentry.ts index ffea05e7..2ab097c5 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -2,12 +2,195 @@ * Sentry API Types * * Types representing Sentry API resources. - * Zod schemas provide runtime validation, types are inferred from schemas. - * Schemas are lenient to handle API variations - only core identifiers are required. + * + * SDK-backed types (Organization, Project, Issue, Event, ProjectKey) are derived + * from `@sentry/api` response types using `Partial & RequiredCore`. + * This keeps all SDK-documented fields available with correct types while making + * non-core fields optional for flexibility (test mocks, partial API responses). + * + * Internal types not covered by the SDK (Region, User, logs) use Zod schemas + * for runtime validation. Event entry types (exceptions, breadcrumbs, etc.) + * are plain TypeScript interfaces since they are only used for type annotations. */ +import type { + IssueEventDetailsResponse, + RetrieveAnIssueResponse as SdkIssueDetail, + ListOrganizations as SdkOrganizationList, + ProjectKey as SdkProjectKey, + OrganizationProjectResponseDict as SdkProjectList, +} from "@sentry/api"; import { z } from "zod"; +// SDK-derived types + +// Organization + +/** + * A Sentry organization. + * + * Based on the `@sentry/api` list-organizations response type. + * Core identifiers are required; other SDK fields are available but optional, + * allowing test mocks and list-endpoint responses to omit them. + */ +export type SentryOrganization = Partial & { + id: string; + slug: string; + name: string; +}; + +// Project + +/** Element type of the SDK's list-projects response */ +type SdkProjectListItem = SdkProjectList[number]; + +/** + * A Sentry project. + * + * Based on the `@sentry/api` list-projects response type. + * The `organization` field is present in detail responses but absent in list responses, + * so it is declared as an optional extension. + */ +export type SentryProject = Partial & { + id: string; + slug: string; + name: string; + /** Organization context (present in detail responses, absent in list) */ + organization?: { + id: string; + slug: string; + name: string; + [key: string]: unknown; + }; + /** Project status (returned by API but not in the OpenAPI spec) */ + status?: string; +}; + +// Issue Constants + +export const ISSUE_STATUSES = ["resolved", "unresolved", "ignored"] as const; +export type IssueStatus = (typeof ISSUE_STATUSES)[number]; + +export const ISSUE_LEVELS = [ + "fatal", + "error", + "warning", + "info", + "debug", +] as const; +export type IssueLevel = (typeof ISSUE_LEVELS)[number]; + +// Issue + +/** + * A Sentry issue. + * + * Based on the `@sentry/api` retrieve-issue response type. + * Core identifiers are required; other SDK fields are available but optional. + * Includes extensions for fields returned by the API but not in the OpenAPI spec. + * + * The `metadata` field is overridden from the SDK's discriminated union to a single + * object with all optional fields, matching how the API actually returns data. + */ +export type SentryIssue = Omit, "metadata"> & { + id: string; + shortId: string; + title: string; + /** Issue metadata (value, filename, function, etc.) */ + metadata?: { + value?: string; + type?: string; + filename?: string; + function?: string; + title?: string; + display_title_with_tree_label?: boolean; + [key: string]: unknown; + }; + /** Issue substatus (not in OpenAPI spec) */ + substatus?: string | null; + /** Issue priority (not in OpenAPI spec) */ + priority?: string; + /** Whether the issue is unhandled (not in OpenAPI spec) */ + isUnhandled?: boolean; + /** Platform of the issue (not in OpenAPI spec) */ + platform?: string; + /** + * Seer AI fixability score (0-1). Higher = easier to fix automatically. + * `null` when Seer has not analyzed this issue; absent when the org has Seer disabled. + */ + seerFixabilityScore?: number | null; +}; + +// Event + +/** + * A Sentry event. + * + * Based on the `@sentry/api` IssueEventDetailsResponse type. + * Core identifier (eventID) is required; other SDK fields are available but optional. + * + * The `contexts` field is overridden from the SDK's generic `Record` + * to include typed sub-contexts (trace, browser, os, device) that our formatters access. + * Additional fields not in the OpenAPI spec are also included. + */ +export type SentryEvent = Omit< + Partial, + "contexts" +> & { + eventID: string; + /** Event contexts with typed sub-contexts */ + contexts?: { + trace?: TraceContext; + browser?: BrowserContext; + os?: OsContext; + device?: DeviceContext; + [key: string]: unknown; + } | null; + /** Date the event was created (not in OpenAPI spec) */ + dateCreated?: string; + /** Event fingerprints (not in OpenAPI spec) */ + fingerprints?: string[]; + /** Release associated with the event (not in OpenAPI spec) */ + release?: { + version: string; + shortVersion?: string; + dateCreated?: string; + dateReleased?: string | null; + [key: string]: unknown; + } | null; + /** SDK update suggestions (not in OpenAPI spec) */ + sdkUpdates?: Array<{ + type?: string; + sdkName?: string; + newSdkVersion?: string; + sdkUrl?: string; + [key: string]: unknown; + }>; + /** URL/function where the error occurred (not in OpenAPI spec for events) */ + culprit?: string | null; +}; + +// Project Keys (DSN) + +/** + * A Sentry project key (DSN). + * + * Based on the `@sentry/api` ProjectKey type. + * Core fields are required; other SDK fields are available but optional. + */ +export type ProjectKey = Partial & { + id: string; + name: string; + isActive: boolean; + dsn: { + public: string; + secret: string; + [key: string]: unknown; + }; +}; + +// Internal types with Zod schemas (runtime-validated, not in @sentry/api) + // Region /** A Sentry region (e.g., US, EU) */ @@ -25,48 +208,11 @@ export const UserRegionsResponseSchema = z.object({ export type UserRegionsResponse = z.infer; -// Organization - -/** Organization links with region URL for multi-region support */ -export const OrganizationLinksSchema = z.object({ - organizationUrl: z.string(), - regionUrl: z.string(), -}); - -export type OrganizationLinks = z.infer; - -export const SentryOrganizationSchema = z - .object({ - // Core identifiers (required) - id: z.string(), - slug: z.string(), - name: z.string(), - // Optional metadata - dateCreated: z.string().optional(), - isEarlyAdopter: z.boolean().optional(), - require2FA: z.boolean().optional(), - avatar: z - .object({ - avatarType: z.string(), - avatarUuid: z.string().nullable(), - }) - .passthrough() - .optional(), - features: z.array(z.string()).optional(), - // Multi-region support: links contain the region URL for this org - links: OrganizationLinksSchema.optional(), - }) - .passthrough(); - -export type SentryOrganization = z.infer; - // User export const SentryUserSchema = z .object({ - // Core identifiers (required) id: z.string(), - // Optional user info email: z.string().optional(), username: z.string().optional(), name: z.string().optional(), @@ -75,66 +221,45 @@ export const SentryUserSchema = z export type SentryUser = z.infer; -// Project +// Plain TypeScript interfaces (type annotations only, no runtime validation) -export const SentryProjectSchema = z - .object({ - // Core identifiers (required) - id: z.string(), - slug: z.string(), - name: z.string(), - // Optional metadata - platform: z.string().nullable().optional(), - dateCreated: z.string().optional(), - isBookmarked: z.boolean().optional(), - isMember: z.boolean().optional(), - features: z.array(z.string()).optional(), - firstEvent: z.string().nullable().optional(), - firstTransactionEvent: z.boolean().optional(), - access: z.array(z.string()).optional(), - hasAccess: z.boolean().optional(), - hasMinifiedStackTrace: z.boolean().optional(), - hasMonitors: z.boolean().optional(), - hasProfiles: z.boolean().optional(), - hasReplays: z.boolean().optional(), - hasSessions: z.boolean().optional(), - isInternal: z.boolean().optional(), - isPublic: z.boolean().optional(), - avatar: z - .object({ - avatarType: z.string(), - avatarUuid: z.string().nullable(), - }) - .passthrough() - .optional(), - color: z.string().optional(), - status: z.string().optional(), - organization: z - .object({ - id: z.string(), - slug: z.string(), - name: z.string(), - }) - .passthrough() - .optional(), - }) - .passthrough(); +// Event Contexts -export type SentryProject = z.infer; +/** Trace context from event.contexts.trace */ +export type TraceContext = { + trace_id?: string; + span_id?: string; + parent_span_id?: string | null; + op?: string; + status?: string; + description?: string | null; + [key: string]: unknown; +}; -// Issue Status & Level Constants +/** Browser context from event.contexts.browser */ +export type BrowserContext = { + name?: string; + version?: string; + type?: "browser"; + [key: string]: unknown; +}; -export const ISSUE_STATUSES = ["resolved", "unresolved", "ignored"] as const; -export type IssueStatus = (typeof ISSUE_STATUSES)[number]; +/** Operating system context from event.contexts.os */ +export type OsContext = { + name?: string; + version?: string; + type?: "os"; + [key: string]: unknown; +}; -export const ISSUE_LEVELS = [ - "fatal", - "error", - "warning", - "info", - "debug", -] as const; -export type IssueLevel = (typeof ISSUE_LEVELS)[number]; +/** Device context from event.contexts.device */ +export type DeviceContext = { + family?: string; + model?: string; + brand?: string; + type?: "device"; + [key: string]: unknown; +}; export const ISSUE_PRIORITIES = ["high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; @@ -183,106 +308,6 @@ export type Release = z.infer; // Issue -export const SentryIssueSchema = z - .object({ - // Core identifiers (required) - id: z.string(), - shortId: z.string(), - title: z.string(), - // Optional metadata - culprit: z.string().optional(), - permalink: z.string().optional(), - logger: z.string().nullable().optional(), - level: z.string().optional(), - status: z.enum(ISSUE_STATUSES).optional(), - statusDetails: z.record(z.unknown()).optional(), - substatus: z.string().optional().nullable(), - priority: z.string().optional(), - isPublic: z.boolean().optional(), - platform: z.string().optional(), - project: z - .object({ - id: z.string(), - name: z.string(), - slug: z.string(), - platform: z.string().nullable().optional(), - }) - .passthrough() - .optional(), - type: z.string().optional(), - metadata: z - .object({ - value: z.string().optional(), - type: z.string().optional(), - filename: z.string().optional(), - function: z.string().optional(), - display_title_with_tree_label: z.boolean().optional(), - }) - .passthrough() - .optional(), - numComments: z.number().optional(), - assignedTo: z - .object({ - id: z.string(), - name: z.string(), - type: z.string(), - }) - .passthrough() - .nullable() - .optional(), - isBookmarked: z.boolean().optional(), - isSubscribed: z.boolean().optional(), - subscriptionDetails: z - .object({ - reason: z.string().optional(), - }) - .passthrough() - .nullable() - .optional(), - hasSeen: z.boolean().optional(), - annotations: z - .array( - z - .object({ - displayName: z.string(), - url: z.string(), - }) - .passthrough() - ) - .optional(), - isUnhandled: z.boolean().optional(), - count: z.string().optional(), - userCount: z.number().optional(), - firstSeen: z.string().datetime({ offset: true }).optional(), - lastSeen: z.string().datetime({ offset: true }).optional(), - // Release information - firstRelease: ReleaseSchema.nullable().optional(), - lastRelease: ReleaseSchema.nullable().optional(), - /** - * Seer AI fixability score (0-1). Higher = easier to fix automatically. - * `null` when Seer has not analyzed this issue; absent when the org has Seer disabled. - */ - seerFixabilityScore: z.number().nullable().optional(), - }) - .passthrough(); - -export type SentryIssue = z.infer; - -// Trace Context - -export const TraceContextSchema = z - .object({ - trace_id: z.string().optional(), - span_id: z.string().optional(), - parent_span_id: z.string().nullable().optional(), - op: z.string().optional(), - status: z.string().optional(), - description: z.string().nullable().optional(), - }) - .passthrough(); - -export type TraceContext = z.infer; - // Span (for trace tree display) /** A single span in a trace */ @@ -335,318 +360,115 @@ export type TraceSpan = { // Stack Frame & Exception Entry /** A single frame in a stack trace */ -export const StackFrameSchema = z - .object({ - filename: z.string().nullable().optional(), - absPath: z.string().nullable().optional(), - module: z.string().nullable().optional(), - package: z.string().nullable().optional(), - platform: z.string().nullable().optional(), - function: z.string().nullable().optional(), - rawFunction: z.string().nullable().optional(), - symbol: z.string().nullable().optional(), - lineNo: z.number().nullable().optional(), - colNo: z.number().nullable().optional(), - /** Whether this frame is in the user's application code */ - inApp: z.boolean().nullable().optional(), - /** Surrounding code lines: [[lineNo, code], ...] */ - context: z - .array(z.tuple([z.number(), z.string()])) - .nullable() - .optional(), - vars: z.record(z.unknown()).nullable().optional(), - instructionAddr: z.string().nullable().optional(), - symbolAddr: z.string().nullable().optional(), - trust: z.string().nullable().optional(), - errors: z.array(z.unknown()).nullable().optional(), - }) - .passthrough(); - -export type StackFrame = z.infer; +export type StackFrame = { + filename?: string | null; + absPath?: string | null; + module?: string | null; + package?: string | null; + platform?: string | null; + function?: string | null; + rawFunction?: string | null; + symbol?: string | null; + lineNo?: number | null; + colNo?: number | null; + /** Whether this frame is in the user's application code */ + inApp?: boolean | null; + /** Surrounding code lines: [[lineNo, code], ...] */ + context?: [number, string][] | null; + vars?: Record | null; + instructionAddr?: string | null; + symbolAddr?: string | null; + trust?: string | null; + errors?: unknown[] | null; + [key: string]: unknown; +}; /** Stack trace containing frames */ -export const StacktraceSchema = z - .object({ - frames: z.array(StackFrameSchema).optional(), - framesOmitted: z.array(z.number()).nullable().optional(), - registers: z.record(z.string()).nullable().optional(), - hasSystemFrames: z.boolean().optional(), - }) - .passthrough(); - -export type Stacktrace = z.infer; +export type Stacktrace = { + frames?: StackFrame[]; + framesOmitted?: number[] | null; + registers?: Record | null; + hasSystemFrames?: boolean; + [key: string]: unknown; +}; /** Exception mechanism (how the error was captured) */ -export const MechanismSchema = z - .object({ - type: z.string().optional(), - handled: z.boolean().optional(), - synthetic: z.boolean().optional(), - description: z.string().nullable().optional(), - data: z.record(z.unknown()).optional(), - }) - .passthrough(); - -export type Mechanism = z.infer; +export type Mechanism = { + type?: string; + handled?: boolean; + synthetic?: boolean; + description?: string | null; + data?: Record; + [key: string]: unknown; +}; /** A single exception value in the exception entry */ -export const ExceptionValueSchema = z - .object({ - type: z.string().nullable().optional(), - value: z.string().nullable().optional(), - module: z.string().nullable().optional(), - threadId: z.union([z.string(), z.number()]).nullable().optional(), - mechanism: MechanismSchema.nullable().optional(), - stacktrace: StacktraceSchema.nullable().optional(), - rawStacktrace: StacktraceSchema.nullable().optional(), - }) - .passthrough(); - -export type ExceptionValue = z.infer; +export type ExceptionValue = { + type?: string | null; + value?: string | null; + module?: string | null; + threadId?: string | number | null; + mechanism?: Mechanism | null; + stacktrace?: Stacktrace | null; + rawStacktrace?: Stacktrace | null; + [key: string]: unknown; +}; /** Exception entry in event.entries */ -export const ExceptionEntrySchema = z.object({ - type: z.literal("exception"), - data: z - .object({ - values: z.array(ExceptionValueSchema).optional(), - excOmitted: z.array(z.number()).nullable().optional(), - hasSystemFrames: z.boolean().optional(), - }) - .passthrough(), -}); - -export type ExceptionEntry = z.infer; +export type ExceptionEntry = { + type: "exception"; + data: { + values?: ExceptionValue[]; + excOmitted?: number[] | null; + hasSystemFrames?: boolean; + [key: string]: unknown; + }; +}; // Breadcrumbs Entry /** A single breadcrumb */ -export const BreadcrumbSchema = z - .object({ - type: z.string().optional(), - category: z.string().nullable().optional(), - level: z.string().optional(), - message: z.string().nullable().optional(), - timestamp: z.string().optional(), - event_id: z.string().nullable().optional(), - data: z.record(z.unknown()).nullable().optional(), - }) - .passthrough(); - -export type Breadcrumb = z.infer; +export type Breadcrumb = { + type?: string; + category?: string | null; + level?: string; + message?: string | null; + timestamp?: string; + event_id?: string | null; + data?: Record | null; + [key: string]: unknown; +}; /** Breadcrumbs entry in event.entries */ -export const BreadcrumbsEntrySchema = z.object({ - type: z.literal("breadcrumbs"), - data: z - .object({ - values: z.array(BreadcrumbSchema).optional(), - }) - .passthrough(), -}); - -export type BreadcrumbsEntry = z.infer; +export type BreadcrumbsEntry = { + type: "breadcrumbs"; + data: { + values?: Breadcrumb[]; + [key: string]: unknown; + }; +}; // Request Entry /** HTTP request entry in event.entries */ -export const RequestEntrySchema = z.object({ - type: z.literal("request"), - data: z - .object({ - url: z.string().nullable().optional(), - method: z.string().nullable().optional(), - fragment: z.string().nullable().optional(), - query: z - .union([ - z.array(z.tuple([z.string(), z.string()])), - z.string(), - z.record(z.string()), - ]) - .nullable() - .optional(), - data: z.unknown().nullable().optional(), - headers: z - .array(z.tuple([z.string(), z.string()])) - .nullable() - .optional(), - cookies: z - .union([ - z.array(z.tuple([z.string(), z.string()])), - z.record(z.string()), - ]) - .nullable() - .optional(), - env: z.record(z.string()).nullable().optional(), - inferredContentType: z.string().nullable().optional(), - apiTarget: z.string().nullable().optional(), - }) - .passthrough(), -}); - -export type RequestEntry = z.infer; - -// Event Contexts - -/** Browser context */ -export const BrowserContextSchema = z - .object({ - name: z.string().optional(), - version: z.string().optional(), - type: z.literal("browser").optional(), - }) - .passthrough(); - -export type BrowserContext = z.infer; - -/** Operating system context */ -export const OsContextSchema = z - .object({ - name: z.string().optional(), - version: z.string().optional(), - type: z.literal("os").optional(), - }) - .passthrough(); - -export type OsContext = z.infer; - -/** Device context */ -export const DeviceContextSchema = z - .object({ - family: z.string().optional(), - model: z.string().optional(), - brand: z.string().optional(), - type: z.literal("device").optional(), - }) - .passthrough(); - -export type DeviceContext = z.infer; - -/** User geo information */ -export const UserGeoSchema = z - .object({ - country_code: z.string().optional(), - city: z.string().optional(), - region: z.string().optional(), - }) - .passthrough(); - -export type UserGeo = z.infer; - -// Event - -export const SentryEventSchema = z - .object({ - // Core identifier (required) - eventID: z.string(), - // Optional metadata - id: z.string().optional(), - projectID: z.string().optional(), - context: z.record(z.unknown()).optional(), - contexts: z - .object({ - trace: TraceContextSchema.optional(), - browser: BrowserContextSchema.optional(), - os: OsContextSchema.optional(), - device: DeviceContextSchema.optional(), - }) - .passthrough() - .optional(), - dateCreated: z.string().optional(), - dateReceived: z.string().optional(), - /** Event entries: exception, breadcrumbs, request, spans, etc. */ - entries: z.array(z.unknown()).optional(), - errors: z.array(z.unknown()).optional(), - fingerprints: z.array(z.string()).optional(), - groupID: z.string().optional(), - message: z.string().optional(), - metadata: z.record(z.unknown()).optional(), - platform: z.string().optional(), - /** File location where the error occurred */ - location: z.string().nullable().optional(), - /** URL where the event occurred */ - culprit: z.string().nullable().optional(), - sdk: z - .object({ - name: z.string().nullable().optional(), - version: z.string().nullable().optional(), - }) - .passthrough() - .nullable() - .optional(), - tags: z - .array( - z.object({ - key: z.string(), - value: z.string(), - }) - ) - .optional(), - title: z.string().optional(), - type: z.string().optional(), - user: z - .object({ - id: z.string().nullable().optional(), - email: z.string().nullable().optional(), - username: z.string().nullable().optional(), - ip_address: z.string().nullable().optional(), - name: z.string().nullable().optional(), - geo: UserGeoSchema.nullable().optional(), - data: z.record(z.unknown()).nullable().optional(), - }) - .passthrough() - .nullable() - .optional(), - /** Release information for this event */ - release: ReleaseSchema.nullable().optional(), - /** SDK update suggestions */ - sdkUpdates: z - .array( - z - .object({ - type: z.string().optional(), - sdkName: z.string().optional(), - newSdkVersion: z.string().optional(), - sdkUrl: z.string().optional(), - }) - .passthrough() - ) - .optional(), - }) - .passthrough(); - -export type SentryEvent = z.infer; - -// Project Keys (DSN) - -export const ProjectKeyDsnSchema = z.object({ - public: z.string(), - secret: z.string().optional(), -}); - -export const ProjectKeySchema = z - .object({ - id: z.string(), - name: z.string(), - dsn: ProjectKeyDsnSchema, - isActive: z.boolean(), - dateCreated: z.string().optional(), - }) - .passthrough(); - -export type ProjectKey = z.infer; +export type RequestEntry = { + type: "request"; + data: { + url?: string | null; + method?: string | null; + fragment?: string | null; + query?: [string, string][] | string | Record | null; + data?: unknown; + headers?: [string, string][] | null; + cookies?: [string, string][] | Record | null; + env?: Record | null; + inferredContentType?: string | null; + apiTarget?: string | null; + [key: string]: unknown; + }; +}; -/** Log severity levels (similar to issue levels but includes trace) */ -export const LOG_SEVERITIES = [ - "fatal", - "error", - "warning", - "warn", - "info", - "debug", - "trace", -] as const; -export type LogSeverity = (typeof LOG_SEVERITIES)[number]; +// Log types (runtime-validated, internal explore API) /** * Individual log entry from the logs dataset. diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 3d0c7ac3..69306281 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -114,17 +114,22 @@ describe("resolveOrgAndIssueId", () => { const req = new Request(input, init); const url = req.url; - if (url.includes("organizations/my-org/issues/PROJECT-ABC")) { + if (url.includes("organizations/my-org/shortids/PROJECT-ABC")) { return new Response( JSON.stringify({ - id: "987654321", - shortId: "PROJECT-ABC", - title: "Test Issue", - status: "unresolved", - platform: "javascript", - type: "error", - count: "10", - userCount: 5, + organizationSlug: "my-org", + projectSlug: "project", + groupId: "987654321", + group: { + id: "987654321", + shortId: "PROJECT-ABC", + title: "Test Issue", + status: "unresolved", + platform: "javascript", + type: "error", + count: "10", + userCount: 5, + }, }), { status: 200, @@ -166,17 +171,22 @@ describe("resolveOrgAndIssueId", () => { const req = new Request(input, init); const url = req.url; - if (url.includes("organizations/cached-org/issues/FRONTEND-G")) { + if (url.includes("organizations/cached-org/shortids/FRONTEND-G")) { return new Response( JSON.stringify({ - id: "111222333", - shortId: "FRONTEND-G", - title: "Test Issue from alias", - status: "unresolved", - platform: "javascript", - type: "error", - count: "5", - userCount: 2, + organizationSlug: "cached-org", + projectSlug: "frontend", + groupId: "111222333", + group: { + id: "111222333", + shortId: "FRONTEND-G", + title: "Test Issue from alias", + status: "unresolved", + platform: "javascript", + type: "error", + count: "5", + userCount: 2, + }, }), { status: 200, @@ -207,17 +217,22 @@ describe("resolveOrgAndIssueId", () => { const url = req.url; // With explicit org, we try project-suffix format: dashboard-4y -> DASHBOARD-4Y - if (url.includes("organizations/org1/issues/DASHBOARD-4Y")) { + if (url.includes("organizations/org1/shortids/DASHBOARD-4Y")) { return new Response( JSON.stringify({ - id: "999888777", - shortId: "DASHBOARD-4Y", - title: "Test Issue with explicit org", - status: "unresolved", - platform: "javascript", - type: "error", - count: "1", - userCount: 1, + organizationSlug: "org1", + projectSlug: "dashboard", + groupId: "999888777", + group: { + id: "999888777", + shortId: "DASHBOARD-4Y", + title: "Test Issue with explicit org", + status: "unresolved", + platform: "javascript", + type: "error", + count: "1", + userCount: 1, + }, }), { status: 200, @@ -250,17 +265,22 @@ describe("resolveOrgAndIssueId", () => { const req = new Request(input, init); const url = req.url; - if (url.includes("organizations/my-org/issues/MY-PROJECT-G")) { + if (url.includes("organizations/my-org/shortids/MY-PROJECT-G")) { return new Response( JSON.stringify({ - id: "444555666", - shortId: "MY-PROJECT-G", - title: "Test Issue from short suffix", - status: "unresolved", - platform: "python", - type: "error", - count: "3", - userCount: 1, + organizationSlug: "my-org", + projectSlug: "my-project", + groupId: "444555666", + group: { + id: "444555666", + shortId: "MY-PROJECT-G", + title: "Test Issue from short suffix", + status: "unresolved", + platform: "python", + type: "error", + count: "3", + userCount: 1, + }, }), { status: 200, @@ -323,7 +343,8 @@ describe("resolveOrgAndIssueId", () => { if ( url.includes("/organizations/") && !url.includes("/projects/") && - !url.includes("/issues/") + !url.includes("/issues/") && + !url.includes("/shortids/") ) { return new Response( JSON.stringify([{ id: "1", slug: "my-org", name: "My Org" }]), @@ -353,17 +374,22 @@ describe("resolveOrgAndIssueId", () => { ); } - if (url.includes("organizations/my-org/issues/CRAFT-G")) { + if (url.includes("organizations/my-org/shortids/CRAFT-G")) { return new Response( JSON.stringify({ - id: "777888999", - shortId: "CRAFT-G", - title: "Test Issue fallback", - status: "unresolved", - platform: "javascript", - type: "error", - count: "1", - userCount: 1, + organizationSlug: "my-org", + projectSlug: "craft", + groupId: "777888999", + group: { + id: "777888999", + shortId: "CRAFT-G", + title: "Test Issue fallback", + status: "unresolved", + platform: "javascript", + type: "error", + count: "1", + userCount: 1, + }, }), { status: 200, @@ -410,7 +436,8 @@ describe("resolveOrgAndIssueId", () => { if ( url.includes("/organizations/") && !url.includes("/projects/") && - !url.includes("/issues/") + !url.includes("/issues/") && + !url.includes("/shortids/") ) { return new Response( JSON.stringify([{ id: "1", slug: "my-org", name: "My Org" }]), @@ -478,7 +505,8 @@ describe("resolveOrgAndIssueId", () => { if ( url.includes("/organizations/") && !url.includes("/projects/") && - !url.includes("/issues/") + !url.includes("/issues/") && + !url.includes("/shortids/") ) { return new Response( JSON.stringify([ diff --git a/test/lib/api-client.multiregion.test.ts b/test/lib/api-client.multiregion.test.ts index 90ab2a5a..193856bb 100644 --- a/test/lib/api-client.multiregion.test.ts +++ b/test/lib/api-client.multiregion.test.ts @@ -385,7 +385,10 @@ describe("listOrganizations (fan-out)", () => { } ); } - return new Response(JSON.stringify([]), { status: 200 }); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); }, }); diff --git a/test/lib/api-client.seer.test.ts b/test/lib/api-client.seer.test.ts index 5b6a528f..2aac6b40 100644 --- a/test/lib/api-client.seer.test.ts +++ b/test/lib/api-client.seer.test.ts @@ -74,7 +74,7 @@ describe("triggerRootCauseAnalysis", () => { await triggerRootCauseAnalysis("test-org", "123456789"); - expect(capturedBody).toEqual({ step: "root_cause" }); + expect(capturedBody).toEqual({ stopping_point: "root_cause" }); }); test("throws ApiError on 402 response", async () => { diff --git a/test/mocks/multiregion.ts b/test/mocks/multiregion.ts index 0633fb85..c4fe47e3 100644 --- a/test/mocks/multiregion.ts +++ b/test/mocks/multiregion.ts @@ -142,7 +142,32 @@ function createRegionRoutes( return { status: 404, body: notFoundFixture }; }, }, - // Issues list for project + // Issues list (org-scoped endpoint used by @sentry/api SDK) + // Filters by project slug from the "query" search param (e.g., query=project:my-project) + { + method: "GET", + path: "/api/0/organizations/:orgSlug/issues/", + response: (req, params) => { + if (orgSet.has(params.orgSlug)) { + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; + const projectMatch = query.match(/project:(\S+)/); + const projectSlug = projectMatch?.[1]; + if (projectSlug) { + const issues = issuesByProject.get(projectSlug) ?? []; + return { body: issues }; + } + // No project filter: return all issues for all projects in this org + const orgProjects = projectsByOrg.get(params.orgSlug) ?? []; + const allIssues = (orgProjects as Array<{ slug: string }>).flatMap( + (p) => issuesByProject.get(p.slug) ?? [] + ); + return { body: allIssues }; + } + return { status: 404, body: notFoundFixture }; + }, + }, + // Issues list (legacy project-scoped endpoint) { method: "GET", path: "/api/0/projects/:orgSlug/:projectSlug/issues/", diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index d5dc14f0..0d0e8c0e 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -131,7 +131,18 @@ export const apiRoutes: MockRoute[] = [ }, }, - // Issues + // Issues (org-scoped endpoint used by @sentry/api SDK) + { + method: "GET", + path: "/api/0/organizations/:orgSlug/issues/", + response: (_req, params) => { + if (params.orgSlug === TEST_ORG) { + return { body: issuesFixture }; + } + return { status: 404, body: notFoundFixture }; + }, + }, + // Issues (legacy project-scoped endpoint) { method: "GET", path: "/api/0/projects/:orgSlug/:projectSlug/issues/",