From 98ae5a772f410b208e08cc0ef8eb8c11363c80d3 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 00:40:55 +0530 Subject: [PATCH 01/13] feat: added the @sentry/api package --- bun.lock | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 7536316a..9e46b8e9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "sentry", "dependencies": { + "@sentry/api": "^0.1.0", "ignore": "^7.0.5", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0", @@ -23,7 +24,6 @@ "chalk": "^5.6.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", - "ky": "^1.14.2", "qrcode-terminal": "^0.12.0", "semver": "^7.7.3", "tinyglobby": "^0.2.15", @@ -229,6 +229,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=="], @@ -377,8 +379,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 dd418975..0002e8b2 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "chalk": "^5.6.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", - "ky": "^1.14.2", "qrcode-terminal": "^0.12.0", "semver": "^7.7.3", "tinyglobby": "^0.2.15", @@ -61,6 +60,7 @@ "@sentry/core@10.38.0": "patches/@sentry%2Fcore@10.38.0.patch" }, "dependencies": { + "@sentry/api": "^0.1.0", "ignore": "^7.0.5", "p-limit": "^7.2.0", "pretty-ms": "^9.3.0" From fcd2e85e77c8b36cad49b4db0fa1c9a61db1c6c7 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 01:44:03 +0530 Subject: [PATCH 02/13] feat: added functions from api client --- src/lib/api-client.ts | 959 ++++++++++++------------ src/lib/region.ts | 7 +- src/lib/sentry-client.ts | 322 ++++++++ src/types/index.ts | 8 - src/types/sentry.ts | 498 +++++------- test/commands/issue/utils.test.ts | 124 +-- test/lib/api-client.multiregion.test.ts | 5 +- test/lib/api-client.seer.test.ts | 2 +- 8 files changed, 1064 insertions(+), 861 deletions(-) create mode 100644 src/lib/sentry-client.ts diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e21a94da..31b3fc95 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,30 +1,39 @@ /** * 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_sProjects, + 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 SentryUser, SentryUserSchema, type TraceSpan, @@ -32,52 +41,17 @@ import { 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 { + getApiBaseUrl, + getControlSiloUrl, + getDefaultSdkConfig, + getSdkConfig, +} from "./sentry-client.js"; import { withHttpSpan } from "./telemetry.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"; @@ -88,69 +62,53 @@ 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 + * @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) { + const response = (result as { response?: Response }).response; + throwApiError(error, response, context); + } + + return data as T; } /** @@ -174,7 +132,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); } @@ -187,61 +144,97 @@ 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 { resolveOrgRegion } = await import("./region.js"); + 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 { + const { method = "GET" } = options; + return withHttpSpan(method, endpoint, () => + apiRequestToRegion(getApiBaseUrl(), endpoint, options) + ); } /** @@ -261,41 +254,40 @@ export function rawApiRequest( 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 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"; - - // 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, - }; + const headers: Record = { ...customHeaders }; + if (!(isStringBody || hasContentType) && body !== undefined) { + headers["Content-Type"] = "application/json"; + } + let requestBody: string | undefined; if (body !== undefined) { - if (isStringBody) { - requestOptions.body = body; - } else { - requestOptions.json = body; - } + requestBody = isStringBody ? body : JSON.stringify(body); } - const response = await client(normalizePath(endpoint), requestOptions); + const fetchFn = config.fetch; + const response = await fetchFn(url, { + method, + headers, + body: requestBody, + }); const text = await response.text(); let responseBody: unknown; @@ -313,112 +305,7 @@ export function rawApiRequest( }); } -/** - * 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; - 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; - } - - const data = await response.json(); - - if (schema) { - return schema.parse(data); - } - - return data as T; -} +// Organization functions /** * Get the list of regions the user has organization membership in. @@ -427,9 +314,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 } ); @@ -442,64 +329,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[]; } /** @@ -514,7 +354,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; } @@ -524,9 +363,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( @@ -560,20 +397,39 @@ export async function listOrganizations(): Promise { * Uses region-aware routing for multi-region support. */ export function getOrganization(orgSlug: string): Promise { - return orgScopedRequest(`/organizations/${orgSlug}/`, { - schema: SentryOrganizationSchema, + return withHttpSpan("GET", `/organizations/${orgSlug}/`, async () => { + 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( + return withHttpSpan( + "GET", `/organizations/${orgSlug}/projects/`, - { - schema: z.array(SentryProjectSchema), + async () => { + 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[]; } ); } @@ -598,7 +454,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 { @@ -609,11 +464,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; } }) @@ -655,14 +508,6 @@ export function matchesWordBoundary(a: string, b: string): boolean { * Find projects matching a pattern with bidirectional word-boundary matching. * Used for directory name inference when DSN detection fails. * - * Uses `\b` regex word boundary, which matches: - * - Start/end of string - * - Between word char (`\w`) and non-word char (like "-") - * - * Matching is bidirectional: - * - Directory name in project slug: dir "cli" matches project "cli-website" - * - Project slug in directory name: project "docs" matches dir "sentry-docs" - * * @param pattern - Directory name to match against project slugs * @returns Array of matching projects with their org context */ @@ -682,7 +527,6 @@ export async function findProjectsByPattern( if (error instanceof AuthError) { throw error; } - // Skip orgs where user lacks access (permission errors, etc.) return []; } }) @@ -695,8 +539,7 @@ export async function findProjectsByPattern( * Find a project by DSN public key. * * Uses the /api/0/projects/ endpoint with query=dsn: to search - * across all accessible projects in all regions. This works for both - * SaaS and self-hosted DSNs, even when the org ID is not embedded in the DSN. + * across all accessible projects in all regions. * * @param publicKey - The DSN public key (username portion of DSN URL) * @returns The matching project, or null if not found @@ -708,20 +551,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; } @@ -731,10 +574,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 []; @@ -759,10 +599,22 @@ export function getProject( orgSlug: string, projectSlug: string ): Promise { - return orgScopedRequest( + return withHttpSpan( + "GET", `/projects/${orgSlug}/${projectSlug}/`, - { - schema: SentryProjectSchema, + async () => { + 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; } ); } @@ -775,14 +627,28 @@ export function getProjectKeys( orgSlug: string, projectSlug: string ): Promise { - return orgScopedRequest( + return withHttpSpan( + "GET", `/projects/${orgSlug}/${projectSlug}/keys/`, - { - schema: z.array(ProjectKeySchema), + async () => { + 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 region-aware routing for multi-region support. @@ -798,52 +664,86 @@ export function listIssues( statsPeriod?: string; } = {} ): Promise { - return orgScopedRequest( + return withHttpSpan( + "GET", `/projects/${orgSlug}/${projectSlug}/issues/`, - { - params: { - query: options.query, - cursor: options.cursor, - limit: options.limit, - sort: options.sort, - statsPeriod: options.statsPeriod, - }, - schema: z.array(SentryIssueSchema), + async () => { + const { resolveOrgRegion } = await import("./region.js"); + const regionUrl = await resolveOrgRegion(orgSlug); + + // Use raw request: the SDK type doesn't support limit/sort params + return apiRequestToRegion( + regionUrl, + `/projects/${orgSlug}/${projectSlug}/issues/`, + { + params: { + query: options.query, + cursor: options.cursor, + limit: options.limit, + sort: options.sort, + statsPeriod: options.statsPeriod, + }, + } + ); } ); } /** - * 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, + return withHttpSpan("GET", `/issues/${issueId}/`, () => { + // 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( orgSlug: string, shortId: string ): Promise { - // Normalize to uppercase for case-insensitive matching const normalizedShortId = shortId.toUpperCase(); - return orgScopedRequest( + + return withHttpSpan( + "GET", `/organizations/${orgSlug}/issues/${normalizedShortId}/`, - { - schema: SentryIssueSchema, + async () => { + 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. @@ -855,33 +755,60 @@ export function getLatestEvent( orgSlug: string, issueId: string ): Promise { - return orgScopedRequest( - `/organizations/${orgSlug}/issues/${issueId}/events/latest/` + return withHttpSpan( + "GET", + `/organizations/${orgSlug}/issues/${issueId}/events/latest/`, + async () => { + 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( orgSlug: string, projectSlug: string, eventId: string ): Promise { - return orgScopedRequest( + return withHttpSpan( + "GET", `/projects/${orgSlug}/${projectSlug}/events/${eventId}/`, - { - schema: SentryEventSchema, + async () => { + 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 @@ -894,35 +821,49 @@ export function getDetailedTrace( traceId: string, timestamp: number ): Promise { - return orgScopedRequest( + return withHttpSpan( + "GET", `/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, - }, + async () => { + const { resolveOrgRegion } = await import("./region.js"); + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/trace/${traceId}/`, + { + params: { + timestamp, + limit: 10_000, + project: -1, + }, + } + ); } ); } +// Issue update functions + /** - * Update an issue's status + * Update an issue's status. */ export function updateIssueStatus( issueId: string, status: "resolved" | "unresolved" | "ignored" ): Promise { - return apiRequest(`/issues/${issueId}/`, { - method: "PUT", - body: { status }, - schema: SentryIssueSchema, + return withHttpSpan("PUT", `/issues/${issueId}/`, () => { + // 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 }, + }); }); } +// Seer AI functions + /** * Trigger root cause analysis for an issue using Seer AI. * Uses region-aware routing for multi-region support. @@ -936,11 +877,28 @@ export function triggerRootCauseAnalysis( orgSlug: string, issueId: string ): Promise<{ run_id: number }> { - return orgScopedRequest<{ run_id: number }>( + return withHttpSpan( + "POST", `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - body: { step: "root_cause" }, + async () => { + 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 }; } ); } @@ -953,20 +911,33 @@ export function triggerRootCauseAnalysis( * @param issueId - The numeric Sentry issue ID * @returns The autofix state, or null if no autofix has been run */ -export async function getAutofixState( +export function getAutofixState( orgSlug: string, issueId: string ): Promise { - const response = await orgScopedRequest( - `/organizations/${orgSlug}/issues/${issueId}/autofix/` - ); + return withHttpSpan( + "GET", + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, + async () => { + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveSeerIssueFixState({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: Number(issueId), + }, + }); - return response.autofix; + 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 @@ -979,31 +950,44 @@ export function triggerSolutionPlanning( issueId: string, runId: number ): Promise { - return orgScopedRequest( + return withHttpSpan( + "POST", `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - body: { - run_id: runId, - step: "solution", - }, + async () => { + const { resolveOrgRegion } = await import("./region.js"); + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, + { + method: "POST", + body: { + run_id: runId, + step: "solution", + }, + } + ); } ); } +// 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/", { - schema: SentryUserSchema, - }); + return withHttpSpan("GET", "/users/me/", () => + apiRequestToRegion(getControlSiloUrl(), "/users/me/", { + schema: SentryUserSchema, + }) + ); } +// Log functions + /** Fields to request from the logs API */ const LOG_FIELDS = [ "sentry.item_id", @@ -1029,50 +1013,48 @@ 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) * @returns Array of log entries */ -export async function listLogs( +export function listLogs( orgSlug: string, 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}` - : ""; - - const fullQuery = [projectFilter, options.query, timestampFilter] - .filter(Boolean) - .join(" "); - - const response = await orgScopedRequest( - `/organizations/${orgSlug}/events/`, - { - params: { + return withHttpSpan("GET", `/organizations/${orgSlug}/events/`, async () => { + const isNumericProject = isAllDigits(projectSlug); + + const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const timestampFilter = options.afterTimestamp + ? `timestamp_precise:>${options.afterTimestamp}` + : ""; + + const fullQuery = [projectFilter, options.query, timestampFilter] + .filter(Boolean) + .join(" "); + + const config = await getOrgSdkConfig(orgSlug); + + const result = await queryExploreEventsInTableFormat({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { dataset: "logs", field: LOG_FIELDS, - project: isNumericProject ? projectSlug : undefined, + project: isNumericProject ? [Number(projectSlug)] : undefined, query: fullQuery || undefined, per_page: options.limit || 100, statsPeriod: options.statsPeriod ?? "7d", sort: "-timestamp", }, - schema: LogsResponseSchema, - } - ); + }); - 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 */ @@ -1106,26 +1088,29 @@ const DETAILED_LOG_FIELDS = [ * @param logId - The sentry.item_id of the log entry * @returns The detailed log entry, or null if not found */ -export async function getLog( +export function getLog( orgSlug: string, projectSlug: string, logId: string ): Promise { - const query = `project:${projectSlug} sentry.item_id:${logId}`; - - const response = await orgScopedRequest( - `/organizations/${orgSlug}/events/`, - { - params: { + return withHttpSpan("GET", `/organizations/${orgSlug}/events/`, async () => { + 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", }, - 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..50583960 100644 --- a/src/lib/region.ts +++ b/src/lib/region.ts @@ -29,15 +29,14 @@ export async function resolveOrgRegion(orgSlug: string): Promise { // 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"); + type OrgWithLinks = { links?: { regionUrl?: string } }; try { // First try the default URL - it may route correctly const baseUrl = getSentryBaseUrl(); - const org = await apiRequestToRegion( + const org = await apiRequestToRegion( baseUrl, - `/organizations/${orgSlug}/`, - { schema: SentryOrganizationSchema } + `/organizations/${orgSlug}/` ); const regionUrl = org.links?.regionUrl ?? baseUrl; diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts new file mode 100644 index 00000000..9486be29 --- /dev/null +++ b/src/lib/sentry-client.ts @@ -0,0 +1,322 @@ +/** + * 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"; + +/** + * 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" }; +} + +/** + * 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 + * + * @returns A fetch-compatible function for use with @sentry/api SDK functions + */ +function createAuthenticatedFetch(): ( + input: Request | string | URL, + init?: RequestInit +) => Promise { + return async function authenticatedFetch( + input: Request | string | URL, + init?: RequestInit + ): Promise { + 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 fc6e4f04..dd502134 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -65,7 +65,6 @@ export type { OsContext, ProjectKey, Region, - Release, RequestEntry, SentryEvent, SentryIssue, @@ -97,17 +96,10 @@ export { LOG_SEVERITIES, LogsResponseSchema, MechanismSchema, - OrganizationLinksSchema, OsContextSchema, - ProjectKeySchema, RegionSchema, - ReleaseSchema, RequestEntrySchema, - SentryEventSchema, - SentryIssueSchema, SentryLogSchema, - SentryOrganizationSchema, - SentryProjectSchema, SentryUserSchema, SpanSchema, StackFrameSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 9f9d8267..0bce0876 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -2,125 +2,77 @@ * 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 (Region, User, logs, event entries, traces) that are not covered + * by the SDK use Zod schemas for runtime validation. */ +import type { + IssueEventDetailsResponse, + RetrieveAnIssueResponse as SdkIssueDetail, + ListOrganizations as SdkOrganizationList, + ProjectKey as SdkProjectKey, + OrganizationProjectResponseDict as SdkProjectList, +} from "@sentry/api"; import { z } from "zod"; -// Region - -/** A Sentry region (e.g., US, EU) */ -export const RegionSchema = z.object({ - name: z.string(), - url: z.string().url(), -}); - -export type Region = z.infer; - -/** Response from /api/0/users/me/regions/ endpoint */ -export const UserRegionsResponseSchema = z.object({ - regions: z.array(RegionSchema), -}); - -export type UserRegionsResponse = z.infer; +// SDK-derived types // 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(), - }) - .passthrough(); +/** + * Organization links with region URL for multi-region support. + * Derived from the SDK's organization `links` field. + */ +export type OrganizationLinks = { + organizationUrl: string; + regionUrl: string; +}; -export type SentryUser = z.infer; +/** + * 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 -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(); +/** Element type of the SDK's list-projects response */ +type SdkProjectListItem = SdkProjectList[number]; -export type SentryProject = z.infer; +/** + * 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 Status & Level Constants @@ -150,118 +102,143 @@ export const ISSUE_SUBSTATUSES = [ ] as const; export type IssueSubstatus = (typeof ISSUE_SUBSTATUSES)[number]; -// Release (embedded in Issue) +// Issue -export const ReleaseSchema = z - .object({ - id: z.number().optional(), - version: z.string(), - shortVersion: z.string().optional(), - status: z.string().optional(), - dateCreated: z.string().optional(), - dateReleased: z.string().nullable().optional(), - ref: z.string().nullable().optional(), - url: z.string().nullable().optional(), - commitCount: z.number().optional(), - deployCount: z.number().optional(), - authors: z.array(z.unknown()).optional(), - projects: z - .array( - z - .object({ - id: z.union([z.string(), z.number()]), - slug: z.string(), - name: z.string(), - }) - .passthrough() - ) - .optional(), - }) - .passthrough(); +/** + * 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; +}; -export type Release = z.infer; +// Event -// Issue +/** + * 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) -export const SentryIssueSchema = z +/** + * 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 (not in @sentry/api SDK) + +// Region + +/** A Sentry region (e.g., US, EU) */ +export const RegionSchema = z.object({ + name: z.string(), + url: z.string().url(), +}); + +export type Region = z.infer; + +/** Response from /api/0/users/me/regions/ endpoint */ +export const UserRegionsResponseSchema = z.object({ + regions: z.array(RegionSchema), +}); + +export type UserRegionsResponse = z.infer; + +// User + +export const SentryUserSchema = 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(), + // Optional user info + email: z.string().optional(), + username: z.string().optional(), + name: z.string().optional(), }) .passthrough(); -export type SentryIssue = z.infer; +export type SentryUser = z.infer; // Trace Context @@ -525,109 +502,6 @@ export const UserGeoSchema = z 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; - /** Log severity levels (similar to issue levels but includes trace) */ export const LOG_SEVERITIES = [ "fatal", diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index ad3ac173..3dfc013b 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -118,17 +118,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, @@ -170,17 +175,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, @@ -211,17 +221,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, @@ -254,17 +269,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, @@ -319,7 +339,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" }]), @@ -349,17 +370,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, @@ -398,7 +424,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" }]), @@ -458,7 +485,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 () => { From 7dd5c0a0e8ca7009e28c6ced09ee49faffec22e4 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 01:51:55 +0530 Subject: [PATCH 03/13] chore: removed unused types and validations --- src/types/index.ts | 24 +-- src/types/sentry.ts | 381 +++++++++++++++----------------------------- 2 files changed, 132 insertions(+), 273 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index dd502134..c8fd4bff 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,13 +55,9 @@ export type { ExceptionEntry, ExceptionValue, IssueLevel, - IssuePriority, IssueStatus, - IssueSubstatus, - LogSeverity, LogsResponse, Mechanism, - OrganizationLinks, OsContext, ProjectKey, Region, @@ -72,40 +68,22 @@ export type { SentryOrganization, SentryProject, SentryUser, - Span, StackFrame, Stacktrace, TraceContext, TraceSpan, - UserGeo, UserRegionsResponse, } from "./sentry.js"; export { - BreadcrumbSchema, - BreadcrumbsEntrySchema, - BrowserContextSchema, DetailedLogsResponseSchema, DetailedSentryLogSchema, - DeviceContextSchema, - ExceptionEntrySchema, - ExceptionValueSchema, ISSUE_LEVELS, - ISSUE_PRIORITIES, ISSUE_STATUSES, - LOG_SEVERITIES, LogsResponseSchema, - MechanismSchema, - OsContextSchema, RegionSchema, - RequestEntrySchema, SentryLogSchema, SentryUserSchema, - SpanSchema, - StackFrameSchema, - StacktraceSchema, - TraceContextSchema, - UserGeoSchema, UserRegionsResponseSchema, } from "./sentry.js"; diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 0bce0876..549ab31c 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -8,8 +8,9 @@ * 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 (Region, User, logs, event entries, traces) that are not covered - * by the SDK use Zod schemas for runtime validation. + * 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 { @@ -25,15 +26,6 @@ import { z } from "zod"; // Organization -/** - * Organization links with region URL for multi-region support. - * Derived from the SDK's organization `links` field. - */ -export type OrganizationLinks = { - organizationUrl: string; - regionUrl: string; -}; - /** * A Sentry organization. * @@ -74,7 +66,7 @@ export type SentryProject = Partial & { status?: string; }; -// Issue Status & Level Constants +// Issue Constants export const ISSUE_STATUSES = ["resolved", "unresolved", "ignored"] as const; export type IssueStatus = (typeof ISSUE_STATUSES)[number]; @@ -88,20 +80,6 @@ export const ISSUE_LEVELS = [ ] as const; export type IssueLevel = (typeof ISSUE_LEVELS)[number]; -export const ISSUE_PRIORITIES = ["high", "medium", "low"] as const; -export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; - -export const ISSUE_SUBSTATUSES = [ - "ongoing", - "escalating", - "regressed", - "new", - "archived_until_escalating", - "archived_until_condition_met", - "archived_forever", -] as const; -export type IssueSubstatus = (typeof ISSUE_SUBSTATUSES)[number]; - // Issue /** @@ -206,7 +184,7 @@ export type ProjectKey = Partial & { }; }; -// Internal types (not in @sentry/api SDK) +// Internal types with Zod schemas (runtime-validated, not in @sentry/api) // Region @@ -229,9 +207,7 @@ export type UserRegionsResponse = z.infer; 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(), @@ -240,42 +216,47 @@ export const SentryUserSchema = z export type SentryUser = z.infer; -// Trace Context +// Plain TypeScript interfaces (type annotations only, no runtime validation) -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(); +// Event Contexts -export type TraceContext = 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; +}; -// Span (for trace tree display) +/** Browser context from event.contexts.browser */ +export type BrowserContext = { + name?: string; + version?: string; + type?: "browser"; + [key: string]: unknown; +}; -/** A single span in a trace */ -export const SpanSchema = z - .object({ - span_id: z.string(), - parent_span_id: z.string().nullable().optional(), - trace_id: z.string().optional(), - op: z.string().optional(), - description: z.string().nullable().optional(), - /** Start time as Unix timestamp (seconds with fractional ms) */ - start_timestamp: z.number(), - /** End time as Unix timestamp (seconds with fractional ms) */ - timestamp: z.number(), - status: z.string().optional(), - data: z.record(z.unknown()).optional(), - tags: z.record(z.string()).optional(), - }) - .passthrough(); +/** Operating system context from event.contexts.os */ +export type OsContext = { + name?: string; + version?: string; + type?: "os"; + [key: string]: unknown; +}; + +/** Device context from event.contexts.device */ +export type DeviceContext = { + family?: string; + model?: string; + brand?: string; + type?: "device"; + [key: string]: unknown; +}; -export type Span = z.infer; +// Trace Spans /** * Span from /trace/{traceId}/ endpoint with nested children. @@ -304,215 +285,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; +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. From 61e1074aa901776936287a984713e8d37f3cdc74 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 16:09:37 +0530 Subject: [PATCH 04/13] chore: minor changes --- bun.lock | 10 ++++------ package.json | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 9e46b8e9..76ba3323 100644 --- a/bun.lock +++ b/bun.lock @@ -4,14 +4,9 @@ "workspaces": { "": { "name": "sentry", - "dependencies": { - "@sentry/api": "^0.1.0", - "ignore": "^7.0.5", - "p-limit": "^7.2.0", - "pretty-ms": "^9.3.0", - }, "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", @@ -24,6 +19,9 @@ "chalk": "^5.6.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", + "ignore": "^7.0.5", + "p-limit": "^7.2.0", + "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", "semver": "^7.7.3", "tinyglobby": "^0.2.15", diff --git a/package.json b/package.json index ee29ec43..4010d558 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", @@ -39,6 +40,9 @@ "chalk": "^5.6.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", + "ignore": "^7.0.5", + "p-limit": "^7.2.0", + "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", "semver": "^7.7.3", "tinyglobby": "^0.2.15", @@ -59,11 +63,5 @@ "patchedDependencies": { "@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch", "@sentry/core@10.38.0": "patches/@sentry%2Fcore@10.38.0.patch" - }, - "dependencies": { - "@sentry/api": "^0.1.0", - "ignore": "^7.0.5", - "p-limit": "^7.2.0", - "pretty-ms": "^9.3.0" } } From d3a85ad9356177c913dca816663ca6b6d4ba0cc0 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 16:19:09 +0530 Subject: [PATCH 05/13] chore: minor change --- src/lib/api-client.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 45c7c25a..46133ca3 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -94,6 +94,11 @@ function throwApiError( /** * Unwrap an @sentry/api SDK result, throwing ApiError on failure. * + * 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 @@ -109,6 +114,10 @@ function unwrapResult( }; 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); } From 428111bb547343ce3f41a111ad810be8cdafe2fa Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 19:44:57 +0530 Subject: [PATCH 06/13] chore: minor changes --- src/lib/api-client.ts | 7 +------ src/lib/region.ts | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 46133ca3..6e404916 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -47,6 +47,7 @@ import { import type { AutofixResponse, AutofixState } from "../types/seer.js"; import { ApiError, AuthError } from "./errors.js"; +import { resolveOrgRegion } from "./region.js"; import { getApiBaseUrl, getControlSiloUrl, @@ -162,7 +163,6 @@ export function buildSearchParams( * Resolves the org's region URL and returns the config. */ async function getOrgSdkConfig(orgSlug: string) { - const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); return getSdkConfig(regionUrl); } @@ -460,7 +460,6 @@ export type ProjectWithOrg = SentryProject & { */ export function listRepositories(orgSlug: string): Promise { return withHttpSpan("GET", `/organizations/${orgSlug}/repos/`, async () => { - const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); return apiRequestToRegion( @@ -698,7 +697,6 @@ export function listIssues( "GET", `/projects/${orgSlug}/${projectSlug}/issues/`, async () => { - const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); // Use raw request: the SDK type doesn't support limit/sort params @@ -855,7 +853,6 @@ export function getDetailedTrace( "GET", `/organizations/${orgSlug}/trace/${traceId}/`, async () => { - const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); return apiRequestToRegion( @@ -917,7 +914,6 @@ export function listTransactions( const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); - const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); // Use raw request: the SDK's dataset type doesn't include "transactions" @@ -1056,7 +1052,6 @@ export function triggerSolutionPlanning( "POST", `/organizations/${orgSlug}/issues/${issueId}/autofix/`, async () => { - const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); return apiRequestToRegion( diff --git a/src/lib/region.ts b/src/lib/region.ts index 50583960..05c98e3c 100644 --- a/src/lib/region.ts +++ b/src/lib/region.ts @@ -5,7 +5,9 @@ * 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 { getSdkConfig } from "./sentry-client.js"; import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; /** @@ -13,9 +15,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,20 +30,21 @@ 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"); - type OrgWithLinks = { links?: { regionUrl?: string } }; + // 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}/` - ); + const result = await retrieveAnOrganization({ + ...config, + path: { organization_id_or_slug: orgSlug }, + }); + + if (result.error !== undefined) { + return baseUrl; + } - const regionUrl = org.links?.regionUrl ?? baseUrl; + const regionUrl = result.data?.links?.regionUrl ?? baseUrl; // Cache for future use await setOrgRegion(orgSlug, regionUrl); From 6c266f1751a9c19cc4cdf9c3a9bdfd2671008d70 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 11 Feb 2026 20:01:47 +0530 Subject: [PATCH 07/13] chore: minor change --- src/lib/region.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/region.ts b/src/lib/region.ts index 05c98e3c..8b546f14 100644 --- a/src/lib/region.ts +++ b/src/lib/region.ts @@ -7,6 +7,7 @@ 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"; @@ -41,6 +42,10 @@ export async function resolveOrgRegion(orgSlug: string): Promise { }); if (result.error !== undefined) { + // Propagate auth errors so callers can prompt login + if (result.error instanceof AuthError) { + throw result.error; + } return baseUrl; } @@ -50,8 +55,12 @@ export async function resolveOrgRegion(orgSlug: string): Promise { 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(); } From 4920c866287276a3c4034a7f3ee6c0cc5550a179 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Feb 2026 00:35:54 +0530 Subject: [PATCH 08/13] fix: used the new endpoint instead of the deprecated one --- src/lib/api-client.ts | 46 ++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6e404916..0e4773b4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -9,6 +9,7 @@ */ import { + listAnOrganization_sIssues, listAnOrganization_sProjects, listAProject_sClientKeys, queryExploreEventsInTableFormat, @@ -680,6 +681,7 @@ export function getProjectKeys( /** * 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( @@ -689,32 +691,32 @@ export function listIssues( query?: string; cursor?: string; limit?: number; - sort?: "date" | "new" | "priority" | "freq" | "user"; + sort?: "date" | "new" | "freq" | "user"; statsPeriod?: string; } = {} ): Promise { - return withHttpSpan( - "GET", - `/projects/${orgSlug}/${projectSlug}/issues/`, - async () => { - const regionUrl = await resolveOrgRegion(orgSlug); + return withHttpSpan("GET", `/organizations/${orgSlug}/issues/`, async () => { + const config = await getOrgSdkConfig(orgSlug); - // Use raw request: the SDK type doesn't support limit/sort params - return apiRequestToRegion( - regionUrl, - `/projects/${orgSlug}/${projectSlug}/issues/`, - { - params: { - query: options.query, - cursor: options.cursor, - limit: options.limit, - sort: options.sort, - statsPeriod: options.statsPeriod, - }, - } - ); - } - ); + // 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[]; + }); } /** From 0ba12a624d57f9f2e64ad8bb972a94096e6f6590 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Feb 2026 16:47:25 +0530 Subject: [PATCH 09/13] fix: shifted the withHttpSpan to createAuthenticatedFetch --- src/lib/api-client.ts | 692 +++++++++++++++++---------------------- src/lib/sentry-client.ts | 65 +++- 2 files changed, 350 insertions(+), 407 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0e4773b4..78e86f5b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -55,7 +55,6 @@ import { getDefaultSdkConfig, getSdkConfig, } from "./sentry-client.js"; -import { withHttpSpan } from "./telemetry.js"; import { isAllDigits } from "./utils.js"; // Helpers @@ -246,10 +245,7 @@ export function apiRequest( endpoint: string, options: ApiRequestOptions = {} ): Promise { - const { method = "GET" } = options; - return withHttpSpan(method, endpoint, () => - apiRequestToRegion(getApiBaseUrl(), endpoint, options) - ); + return apiRequestToRegion(getApiBaseUrl(), endpoint, options); } /** @@ -262,62 +258,60 @@ 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 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 config = getDefaultSdkConfig(); - const headers: Record = { ...customHeaders }; - if (!(isStringBody || hasContentType) && body !== undefined) { - headers["Content-Type"] = "application/json"; - } + 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}`; - let requestBody: string | undefined; - if (body !== undefined) { - requestBody = isStringBody ? body : JSON.stringify(body); - } + // 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 fetchFn = config.fetch; - const response = await fetchFn(url, { - method, - headers, - body: requestBody, - }); + 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, }); + + const text = await response.text(); + let responseBody: unknown; + try { + responseBody = JSON.parse(text); + } catch { + responseBody = text; + } + + return { + status: response.status, + headers: response.headers, + body: responseBody, + }; } // Organization functions @@ -411,18 +405,18 @@ export async function listOrganizations(): Promise { * Get a specific organization. * Uses region-aware routing for multi-region support. */ -export function getOrganization(orgSlug: string): Promise { - return withHttpSpan("GET", `/organizations/${orgSlug}/`, async () => { - const config = await getOrgSdkConfig(orgSlug); +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; + 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 @@ -431,22 +425,16 @@ export function getOrganization(orgSlug: string): Promise { * List projects in an organization. * Uses region-aware routing for multi-region support. */ -export function listProjects(orgSlug: string): Promise { - return withHttpSpan( - "GET", - `/organizations/${orgSlug}/projects/`, - async () => { - 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[]; - } - ); +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 */ @@ -459,15 +447,15 @@ export type ProjectWithOrg = SentryProject & { * List repositories in an organization. * Uses region-aware routing for multi-region support. */ -export function listRepositories(orgSlug: string): Promise { - return withHttpSpan("GET", `/organizations/${orgSlug}/repos/`, async () => { - const regionUrl = await resolveOrgRegion(orgSlug); +export async function listRepositories( + orgSlug: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); - return apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/repos/` - ); - }); + return apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/repos/` + ); } /** @@ -625,56 +613,44 @@ 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 withHttpSpan( - "GET", - `/projects/${orgSlug}/${projectSlug}/`, - async () => { - 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; - } - ); + 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 withHttpSpan( - "GET", - `/projects/${orgSlug}/${projectSlug}/keys/`, - async () => { - 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[]; - } - ); + 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 @@ -684,7 +660,7 @@ export function getProjectKeys( * 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: { @@ -695,40 +671,36 @@ export function listIssues( statsPeriod?: string; } = {} ): Promise { - return withHttpSpan("GET", `/organizations/${orgSlug}/issues/`, async () => { - 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 config = await getOrgSdkConfig(orgSlug); - const data = unwrapResult(result, "Failed to list issues"); - return data as unknown as SentryIssue[]; + // 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. */ export function getIssue(issueId: string): Promise { - return withHttpSpan("GET", `/issues/${issueId}/`, () => { - // 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}/`); - }); + // 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}/`); } /** @@ -736,40 +708,33 @@ export function getIssue(issueId: string): Promise { * Requires organization context to resolve the short ID. * Uses region-aware routing for multi-region support. */ -export function getIssueByShortId( +export async function getIssueByShortId( orgSlug: string, shortId: string ): Promise { const normalizedShortId = shortId.toUpperCase(); + const config = await getOrgSdkConfig(orgSlug); - return withHttpSpan( - "GET", - `/organizations/${orgSlug}/issues/${normalizedShortId}/`, - async () => { - 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; - } - ); + 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 @@ -781,59 +746,47 @@ 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 withHttpSpan( - "GET", - `/organizations/${orgSlug}/issues/${issueId}/events/latest/`, - async () => { - 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; - } - ); + 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. */ -export function getEvent( +export async function getEvent( orgSlug: string, projectSlug: string, eventId: string ): Promise { - return withHttpSpan( - "GET", - `/projects/${orgSlug}/${projectSlug}/events/${eventId}/`, - async () => { - 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; - } - ); + 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; } /** @@ -846,28 +799,22 @@ 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 withHttpSpan( - "GET", + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, `/organizations/${orgSlug}/trace/${traceId}/`, - async () => { - const regionUrl = await resolveOrgRegion(orgSlug); - - return apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/trace/${traceId}/`, - { - params: { - timestamp, - limit: 10_000, - project: -1, - }, - } - ); + { + params: { + timestamp, + limit: 10_000, + project: -1, + }, } ); } @@ -906,41 +853,37 @@ type ListTransactionsOptions = { * @param options - Query options (query, limit, sort, statsPeriod) * @returns Array of transaction items */ -export function listTransactions( +export async function listTransactions( orgSlug: string, projectSlug: string, options: ListTransactionsOptions = {} ): Promise { - return withHttpSpan("GET", `/organizations/${orgSlug}/events/`, async () => { - const isNumericProject = isAllDigits(projectSlug); - const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; - const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); - - 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: { - dataset: "transactions", - field: TRANSACTION_FIELDS, - project: isNumericProject ? projectSlug : undefined, - query: fullQuery || undefined, - per_page: options.limit || 10, - statsPeriod: options.statsPeriod ?? "7d", - sort: - options.sort === "duration" - ? "-transaction.duration" - : "-timestamp", - }, - schema: TransactionsResponseSchema, - } - ); + const isNumericProject = isAllDigits(projectSlug); + const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); - return response.data; - }); + 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: { + dataset: "transactions", + field: TRANSACTION_FIELDS, + project: isNumericProject ? projectSlug : undefined, + query: fullQuery || undefined, + per_page: options.limit || 10, + statsPeriod: options.statsPeriod ?? "7d", + sort: + options.sort === "duration" ? "-transaction.duration" : "-timestamp", + }, + schema: TransactionsResponseSchema, + } + ); + + return response.data; } // Issue update functions @@ -952,13 +895,11 @@ export function updateIssueStatus( issueId: string, status: "resolved" | "unresolved" | "ignored" ): Promise { - return withHttpSpan("PUT", `/issues/${issueId}/`, () => { - // 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 }, - }); + // 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 }, }); } @@ -973,34 +914,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 withHttpSpan( - "POST", - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - async () => { - 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 }; - } - ); + 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 }; } /** @@ -1011,29 +943,23 @@ export function triggerRootCauseAnalysis( * @param issueId - The numeric Sentry issue ID * @returns The autofix state, or null if no autofix has been run */ -export function getAutofixState( +export async function getAutofixState( orgSlug: string, issueId: string ): Promise { - return withHttpSpan( - "GET", - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - async () => { - const config = await getOrgSdkConfig(orgSlug); - - 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; - } - ); + const config = await getOrgSdkConfig(orgSlug); + + 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; } /** @@ -1045,28 +971,22 @@ export 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 withHttpSpan( - "POST", + const regionUrl = await resolveOrgRegion(orgSlug); + + return apiRequestToRegion( + regionUrl, `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - async () => { - const regionUrl = await resolveOrgRegion(orgSlug); - - return apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - body: { - run_id: runId, - step: "solution", - }, - } - ); + { + method: "POST", + body: { + run_id: runId, + step: "solution", + }, } ); } @@ -1078,11 +998,9 @@ export function triggerSolutionPlanning( * Uses the /users/me/ endpoint on the control silo. */ export function getCurrentUser(): Promise { - return withHttpSpan("GET", "/users/me/", () => - apiRequestToRegion(getControlSiloUrl(), "/users/me/", { - schema: SentryUserSchema, - }) - ); + return apiRequestToRegion(getControlSiloUrl(), "/users/me/", { + schema: SentryUserSchema, + }); } // Log functions @@ -1117,43 +1035,41 @@ type ListLogsOptions = { * @param options - Query options (query, limit, statsPeriod) * @returns Array of log entries */ -export function listLogs( +export async function listLogs( orgSlug: string, projectSlug: string, options: ListLogsOptions = {} ): Promise { - return withHttpSpan("GET", `/organizations/${orgSlug}/events/`, async () => { - const isNumericProject = isAllDigits(projectSlug); - - const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; - const timestampFilter = options.afterTimestamp - ? `timestamp_precise:>${options.afterTimestamp}` - : ""; - - const fullQuery = [projectFilter, options.query, timestampFilter] - .filter(Boolean) - .join(" "); - - 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", - }, - }); + const isNumericProject = isAllDigits(projectSlug); + + const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const timestampFilter = options.afterTimestamp + ? `timestamp_precise:>${options.afterTimestamp}` + : ""; - const data = unwrapResult(result, "Failed to list logs"); - const logsResponse = LogsResponseSchema.parse(data); - return logsResponse.data; + const fullQuery = [projectFilter, options.query, timestampFilter] + .filter(Boolean) + .join(" "); + + 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", + }, }); + + const data = unwrapResult(result, "Failed to list logs"); + const logsResponse = LogsResponseSchema.parse(data); + return logsResponse.data; } /** All fields to request for detailed log view */ @@ -1187,29 +1103,27 @@ const DETAILED_LOG_FIELDS = [ * @param logId - The sentry.item_id of the log entry * @returns The detailed log entry, or null if not found */ -export function getLog( +export async function getLog( orgSlug: string, projectSlug: string, logId: string ): Promise { - return withHttpSpan("GET", `/organizations/${orgSlug}/events/`, async () => { - 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 query = `project:${projectSlug} sentry.item_id:${logId}`; + const config = await getOrgSdkConfig(orgSlug); - const data = unwrapResult(result, "Failed to get log"); - const logsResponse = DetailedLogsResponseSchema.parse(data); - return logsResponse.data[0] ?? null; + 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 data = unwrapResult(result, "Failed to get log"); + const logsResponse = DetailedLogsResponseSchema.parse(data); + return logsResponse.data[0] ?? null; } diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index 9486be29..00e46045 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -10,6 +10,7 @@ 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. @@ -177,6 +178,23 @@ function handleFetchError( 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. * @@ -187,6 +205,7 @@ function handleFetchError( * - 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 */ @@ -194,29 +213,39 @@ function createAuthenticatedFetch(): ( input: Request | string | URL, init?: RequestInit ) => Promise { - return async function authenticatedFetch( + return function authenticatedFetch( input: Request | string | URL, init?: RequestInit ): Promise { - 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; + 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)); } - await Bun.sleep(backoffDelay(attempt)); - } - - // Unreachable: the last attempt always returns 'done' or 'throw' - throw new Error("Exhausted all retry attempts"); + // Unreachable: the last attempt always returns 'done' or 'throw' + throw new Error("Exhausted all retry attempts"); + }); }; } From 209d180cf2464ca124049c710f2f0f691d07ef6d Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Feb 2026 17:08:16 +0530 Subject: [PATCH 10/13] fix: added back the comments from byk --- src/lib/api-client.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 78e86f5b..9d6f5924 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -526,6 +526,14 @@ export function matchesWordBoundary(a: string, b: string): boolean { * Find projects matching a pattern with bidirectional word-boundary matching. * Used for directory name inference when DSN detection fails. * + * Uses `\b` regex word boundary, which matches: + * - Start/end of string + * - Between word char (`\w`) and non-word char (like "-") + * + * Matching is bidirectional: + * - Directory name in project slug: dir "cli" matches project "cli-website" + * - Project slug in directory name: project "docs" matches dir "sentry-docs" + * * @param pattern - Directory name to match against project slugs * @returns Array of matching projects with their org context */ @@ -557,7 +565,8 @@ export async function findProjectsByPattern( * Find a project by DSN public key. * * Uses the /api/0/projects/ endpoint with query=dsn: to search - * across all accessible projects in all regions. + * across all accessible projects in all regions. This works for both + * SaaS and self-hosted DSNs, even when the org ID is not embedded in the DSN. * * @param publicKey - The DSN public key (username portion of DSN URL) * @returns The matching project, or null if not found From a9f3a912b84dd7d27c6d996d452159dfb022bacc Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Feb 2026 17:22:37 +0530 Subject: [PATCH 11/13] fix: corrected the CI --- test/mocks/multiregion.ts | 27 ++++++++++++++++++++++++++- test/mocks/routes.ts | 13 ++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) 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..e775ee38 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/", From c319c6d73fdf80f127356f1b7abacdcd7873c892 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Feb 2026 17:25:36 +0530 Subject: [PATCH 12/13] chore: minor change --- test/mocks/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index e775ee38..0d0e8c0e 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -135,7 +135,7 @@ export const apiRoutes: MockRoute[] = [ { method: "GET", path: "/api/0/organizations/:orgSlug/issues/", - response: (req, params) => { + response: (_req, params) => { if (params.orgSlug === TEST_ORG) { return { body: issuesFixture }; } From cc5768ac5b6e1127a8047ba41a70668ad30667cd Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 13 Feb 2026 02:00:31 +0530 Subject: [PATCH 13/13] empty commit