import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { DEFAULT_QUERY, sentryConnectorMeta } from '@/connectors/sentry/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { parseTagDate } from '@/connectors/utils' const logger = createLogger('SentryConnector') const DEFAULT_HOST = 'sentry.io' const ISSUES_PER_PAGE = 100 /** * Allowed `statsPeriod` values for the project issues list endpoint. Sentry's * project issues endpoint only honors `24h` (default) or `14d` for its timeline * stats; an empty value disables the stats window. Other periods (e.g. `90d`) * are accepted by the organization issues endpoint but not this one, so they are * rejected during validation to avoid a silently-ignored filter. */ const ALLOWED_STATS_PERIODS = new Set(['24h', '14d']) /** * Metadata block on a Sentry issue, carrying the human-readable error type/value. */ interface SentryIssueMetadata { type?: string value?: string function?: string title?: string } /** * A single issue (error group) returned by the issues list/detail endpoints. */ interface SentryIssue { id: string shortId?: string title?: string culprit?: string | null permalink?: string logger?: string | null level?: string status?: string platform?: string | null type?: string | null metadata?: SentryIssueMetadata /** Sentry returns the event count as a string (e.g. "12"), not a number. */ count?: string userCount?: number firstSeen?: string lastSeen?: string } /** * One entry inside a Sentry event. Entries carry the structured payload (exception, * breadcrumbs, request, message) keyed by `type`, with the shape under `data` varying * per entry type. */ interface SentryEventEntry { type?: string data?: unknown } /** * A key/value tag pair attached to a Sentry event. */ interface SentryEventTag { key?: string value?: string } /** * The latest event for an issue, used to enrich the synced document with the concrete * message, exception detail, and tags from the most recent occurrence. */ interface SentryEvent { id?: string eventID?: string message?: string title?: string culprit?: string | null platform?: string | null dateCreated?: string metadata?: SentryIssueMetadata entries?: SentryEventEntry[] tags?: SentryEventTag[] } /** * The shape of an exception entry's `data` payload: a list of exception values, each * with a type, message, and an optional rendered stack frame list. */ interface SentryExceptionData { values?: { type?: string value?: string module?: string stacktrace?: { frames?: { filename?: string function?: string lineNo?: number module?: string }[] } }[] } /** * Resolved connector source configuration after normalization. */ interface SentrySourceConfig { /** Bare host (no protocol, no trailing slash), e.g. `sentry.io` or a self-hosted host. */ host: string /** REST API base, e.g. `https://sentry.io/api/0`. */ apiBase: string organization: string project: string query: string statsPeriod: string environment: string maxIssues: number } /** * Normalizes the host config value: trims whitespace, strips any protocol prefix, * trailing slashes, and a pasted `/api` or `/api/0` suffix (the connector appends * `/api/0` itself), and falls back to sentry.io when empty. Genuine path prefixes * (e.g. `company.com/sentry` for subpath self-hosted installs) are preserved. */ function normalizeHost(rawHost: unknown): string { const host = typeof rawHost === 'string' ? rawHost.trim() : '' if (!host) return DEFAULT_HOST return host .replace(/^https?:\/\//i, '') .replace(/\/+$/, '') .replace(/\/api(\/0)?$/i, '') .replace(/\/+$/, '') .trim() } /** * Reads and normalizes the connector source configuration once per call. */ function readSourceConfig(sourceConfig: Record): SentrySourceConfig { const host = normalizeHost(sourceConfig.baseUrl) const organization = typeof sourceConfig.organization === 'string' ? sourceConfig.organization.trim() : '' const project = typeof sourceConfig.project === 'string' ? sourceConfig.project.trim() : '' const query = typeof sourceConfig.query === 'string' && sourceConfig.query.trim() ? sourceConfig.query.trim() : DEFAULT_QUERY const statsPeriod = typeof sourceConfig.statsPeriod === 'string' ? sourceConfig.statsPeriod.trim() : '' const environment = typeof sourceConfig.environment === 'string' ? sourceConfig.environment.trim() : '' const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 return { host, apiBase: `https://${host}/api/0`, organization, project, query, statsPeriod, environment, maxIssues, } } /** * Builds the standard JSON request headers carrying the Sentry auth token. */ function authHeaders(accessToken: string): Record { return { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', } } /** * Reads the `cursor` of the `rel="next"` link from a Sentry `Link` header. * * Sentry paginates via the `Link` header: each link is annotated with `rel`, * `results`, and `cursor` attributes, e.g. * `; rel="next"; results="true"; cursor="0:100:0"`. * A further page exists only when the `next` link reports `results="true"`; when it * reports `results="false"` (or the header is absent) the cursor points at an empty * page and pagination must stop. The cursor is read from the `cursor="…"` attribute, * which is the canonical token Sentry expects echoed back on the next request. */ function parseNextCursor(linkHeader: string | null): string | undefined { if (!linkHeader) return undefined for (const part of linkHeader.split(',')) { if (!/rel="next"/.test(part)) continue if (!/results="true"/.test(part)) return undefined const cursorMatch = part.match(/cursor="([^"]*)"/) if (cursorMatch) return cursorMatch[1] return undefined } return undefined } /** * Builds the metadata-based content hash for an issue. * * The hash combines the issue id, its status, and `lastSeen`. `lastSeen` advances every * time a new event lands on the group, which is exactly when the latest-event content can * change — so it captures content freshness without hashing the downloaded body. `status` * is included so resolve/ignore transitions also re-sync. `count` is deliberately omitted: * it changes on every single occurrence and would churn the document on each event even * when `lastSeen` already moved, providing no extra signal over `lastSeen`. * * The hash is derived purely from issue metadata present on both the list stub and the * getDocument detail fetch, so both paths produce an identical hash for the same issue * snapshot. If a fresh event lands between listing and hydration, `lastSeen` advances and * getDocument computes a newer hash; the sync engine stores that newer hash, which the next * list pass reproduces — so the document converges without churn. */ function buildContentHash(issue: SentryIssue): string { return `sentry:${issue.id}:${issue.status ?? ''}:${issue.lastSeen ?? ''}` } /** * Builds the document title, preferring the issue title and falling back to the * metadata type/value or short id. */ function buildTitle(issue: SentryIssue): string { const title = issue.title?.trim() if (title) return title const metaType = issue.metadata?.type?.trim() const metaValue = issue.metadata?.value?.trim() if (metaType && metaValue) return `${metaType}: ${metaValue}` return metaType || issue.shortId || `Issue ${issue.id}` } /** * Collects the source-specific metadata fed to mapTags. Shared between the list stub and * getDocument so tag values stay consistent regardless of which path produced the doc. */ function buildMetadata(issue: SentryIssue): Record { return { level: issue.level, status: issue.status, firstSeen: issue.firstSeen, lastSeen: issue.lastSeen, count: issue.count != null ? Number(issue.count) : undefined, } } /** * Creates a lightweight document stub from a list entry. No per-issue API calls — the * latest-event content is deferred to getDocument and only fetched for new/changed issues. */ function issueToStub(issue: SentryIssue): ExternalDocument { return { externalId: issue.id, title: buildTitle(issue), content: '', contentDeferred: true, mimeType: 'text/plain', sourceUrl: issue.permalink || undefined, contentHash: buildContentHash(issue), metadata: buildMetadata(issue), } } /** * Renders the exception entry of a latest event into readable lines: each exception's * type/value plus a compact, top-down stack frame list. */ function formatException(data: SentryExceptionData): string[] { const lines: string[] = [] for (const value of data.values ?? []) { const header = [value.type, value.value].filter(Boolean).join(': ') if (header) lines.push(header) const frames = value.stacktrace?.frames ?? [] for (const frame of frames.slice().reverse()) { const location = [frame.module || frame.filename, frame.function].filter(Boolean).join(' in ') const lineNo = frame.lineNo != null ? `:${frame.lineNo}` : '' if (location) lines.push(` at ${location}${lineNo}`) } } return lines } /** * Formats an issue and its latest event into a single plain-text document covering the * title, culprit, counts, the latest event's message/exception, and event tags. */ function formatIssueContent(issue: SentryIssue, event: SentryEvent | null): string { const parts: string[] = [] parts.push(`Issue: ${buildTitle(issue)}`) if (issue.shortId) parts.push(`Short ID: ${issue.shortId}`) if (issue.culprit) parts.push(`Culprit: ${issue.culprit}`) if (issue.level) parts.push(`Level: ${issue.level}`) if (issue.status) parts.push(`Status: ${issue.status}`) if (issue.platform) parts.push(`Platform: ${issue.platform}`) if (issue.count) parts.push(`Events: ${issue.count}`) if (issue.userCount != null) parts.push(`Users affected: ${issue.userCount}`) if (issue.firstSeen) parts.push(`First seen: ${issue.firstSeen}`) if (issue.lastSeen) parts.push(`Last seen: ${issue.lastSeen}`) if (event) { const message = event.message?.trim() || event.title?.trim() if (message) { parts.push('') parts.push('--- Latest Event ---') if (event.dateCreated) parts.push(`Occurred: ${event.dateCreated}`) parts.push(message) } const exceptionEntry = event.entries?.find((entry) => entry.type === 'exception') if (exceptionEntry?.data) { const exceptionLines = formatException(exceptionEntry.data as SentryExceptionData) if (exceptionLines.length > 0) { parts.push('') parts.push('--- Exception ---') parts.push(...exceptionLines) } } const tagLines = (event.tags ?? []) .map((tag) => (tag.key && tag.value ? `${tag.key}: ${tag.value}` : undefined)) .filter((line): line is string => Boolean(line)) if (tagLines.length > 0) { parts.push('') parts.push('--- Tags ---') parts.push(...tagLines) } } return parts.join('\n').trim() } /** * Fetches the latest event for an issue. Returns null when the issue has no events or the * request fails, so the document still syncs with its list-level summary. * * Uses the organization-scoped event endpoint * `/api/0/organizations/{org}/issues/{id}/events/latest/`, which is the documented path * and works for both sentry.io and self-hosted installs. */ async function fetchLatestEvent( apiBase: string, organization: string, accessToken: string, issueId: string ): Promise { const url = `${apiBase}/organizations/${encodeURIComponent(organization)}/issues/${encodeURIComponent(issueId)}/events/latest/` const response = await secureFetchWithRetry(url, { method: 'GET', headers: authHeaders(accessToken), }) if (!response.ok) { if (response.status !== 404) { logger.warn('Failed to fetch latest Sentry event', { issueId, status: response.status }) } return null } return (await response.json()) as SentryEvent } export const sentryConnector: ConnectorConfig = { ...sentryConnectorMeta, listDocuments: async ( accessToken: string, sourceConfig: Record, cursor?: string, syncContext?: Record ): Promise => { const { apiBase, organization, project, query, statsPeriod, environment, maxIssues } = readSourceConfig(sourceConfig) if (!organization || !project) { throw new Error('Organization and project slugs are required') } /* * Uses the project issues list endpoint * `/api/0/projects/{org}/{project}/issues/`. This endpoint is deprecated in favor of * `/api/0/organizations/{org}/issues/?project=`, but the organization endpoint * filters by numeric project ID rather than slug — a UX regression for a connector * keyed on the human-readable project slug. The project endpoint remains functional * and slug-addressable, so it is retained deliberately for the listing path. Issue * detail and latest-event fetches use the organization-scoped paths. */ const url = new URL( `${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/issues/` ) url.searchParams.set('query', query) url.searchParams.set('limit', String(ISSUES_PER_PAGE)) if (statsPeriod) url.searchParams.set('statsPeriod', statsPeriod) if (environment) url.searchParams.set('environment', environment) if (cursor) url.searchParams.set('cursor', cursor) logger.info('Listing Sentry issues', { organization, project, cursor: cursor ?? 'initial', maxIssues, }) const response = await secureFetchWithRetry(url.toString(), { method: 'GET', headers: authHeaders(accessToken), }) if (!response.ok) { const errorText = await response.text().catch(() => '') logger.error('Failed to list Sentry issues', { status: response.status, error: errorText.slice(0, 500), }) throw new Error(`Failed to list Sentry issues: ${response.status}`) } const issues = ((await response.json()) as SentryIssue[]).filter((issue) => Boolean(issue.id)) const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 let documents = issues.map(issueToStub) let slicedSome = false if (maxIssues > 0) { const remaining = Math.max(0, maxIssues - prevFetched) if (documents.length > remaining) { slicedSome = true documents = documents.slice(0, remaining) } } const totalFetched = prevFetched + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxIssues > 0 && totalFetched >= maxIssues const nextCursor = parseNextCursor(response.headers.get('Link')) if (hitLimit && (slicedSome || Boolean(nextCursor)) && syncContext) { syncContext.listingCapped = true } const hasMore = !hitLimit && Boolean(nextCursor) return { documents, nextCursor: hasMore ? nextCursor : undefined, hasMore, } }, getDocument: async ( accessToken: string, sourceConfig: Record, externalId: string ): Promise => { try { if (!externalId) return null const { apiBase, organization } = readSourceConfig(sourceConfig) if (!organization) return null const url = `${apiBase}/organizations/${encodeURIComponent(organization)}/issues/${encodeURIComponent(externalId)}/` const response = await secureFetchWithRetry(url, { method: 'GET', headers: authHeaders(accessToken), }) if (!response.ok) { if (response.status === 404 || response.status === 410) return null throw new Error(`Failed to fetch Sentry issue: ${response.status}`) } const issue = (await response.json()) as SentryIssue if (!issue?.id) return null const event = await fetchLatestEvent(apiBase, organization, accessToken, issue.id) const content = formatIssueContent(issue, event) if (!content.trim()) return null return { externalId: issue.id, title: buildTitle(issue), content, contentDeferred: false, mimeType: 'text/plain', sourceUrl: issue.permalink || undefined, contentHash: buildContentHash(issue), metadata: buildMetadata(issue), } } catch (error) { logger.warn('Failed to get Sentry issue', { externalId, error: toError(error).message, }) return null } }, validateConfig: async ( accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const { apiBase, organization, project, statsPeriod, maxIssues, host } = readSourceConfig(sourceConfig) if (!organization) { return { valid: false, error: 'Organization slug is required' } } if (!project) { return { valid: false, error: 'Project slug is required' } } if (statsPeriod && !ALLOWED_STATS_PERIODS.has(statsPeriod)) { return { valid: false, error: 'Stats period must be 24h or 14d' } } const rawMax = sourceConfig.maxIssues as string | undefined if (rawMax && (Number.isNaN(maxIssues) || maxIssues < 0)) { return { valid: false, error: 'Max issues must be a non-negative number' } } try { /* * Probe the project detail endpoint first. This exercises the `project:read` * scope and the project-scoped path style, and gives a precise "not found" * message when the org or project slug is wrong. */ const projectResponse = await secureFetchWithRetry( `${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/`, { method: 'GET', headers: authHeaders(accessToken), }, VALIDATE_RETRY_OPTIONS ) if (!projectResponse.ok) { if (projectResponse.status === 401 || projectResponse.status === 403) { return { valid: false, error: 'Invalid auth token or insufficient permissions' } } if (projectResponse.status === 404) { return { valid: false, error: `Organization or project not found on ${host}`, } } const errorText = await projectResponse.text().catch(() => '') return { valid: false, error: `Sentry access failed: ${projectResponse.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, } } /* * Probe the issues-list endpoint with a single-result page. The project * detail probe above only proves `project:read`, but every sync operation — * `listDocuments` and the org-scoped `getDocument`/latest-event hydration — * needs `event:read`. A token scoped to `project:read` only would pass the * first probe yet fail at hydration time, so this second probe forces a * misconfigured token to fail fast at save time. It is slug-addressable and * cheap (one issue, no stats window). */ const issuesProbeUrl = new URL( `${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/issues/` ) issuesProbeUrl.searchParams.set('query', DEFAULT_QUERY) issuesProbeUrl.searchParams.set('limit', '1') const issuesResponse = await secureFetchWithRetry( issuesProbeUrl.toString(), { method: 'GET', headers: authHeaders(accessToken), }, VALIDATE_RETRY_OPTIONS ) if (!issuesResponse.ok) { if (issuesResponse.status === 401 || issuesResponse.status === 403) { return { valid: false, error: 'Auth token cannot read issues. The token needs the "event:read" scope (in addition to "project:read").', } } const errorText = await issuesResponse.text().catch(() => '') return { valid: false, error: `Sentry issue access failed: ${issuesResponse.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, } } return { valid: true } } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') return { valid: false, error: message } } }, mapTags: (metadata: Record): Record => { const result: Record = {} if (typeof metadata.level === 'string' && metadata.level.trim()) { result.level = metadata.level } if (typeof metadata.status === 'string' && metadata.status.trim()) { result.status = metadata.status } if (metadata.count != null) { const num = Number(metadata.count) if (!Number.isNaN(num)) result.count = num } const firstSeen = parseTagDate(metadata.firstSeen) if (firstSeen) result.firstSeen = firstSeen const lastSeen = parseTagDate(metadata.lastSeen) if (lastSeen) result.lastSeen = lastSeen return result }, }