chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,635 @@
|
||||
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<string, unknown>): 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<string, string> {
|
||||
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.
|
||||
* `<https://…/issues/?cursor=0:100:0>; 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<string, unknown> {
|
||||
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<SentryEvent | null> {
|
||||
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<string, unknown>,
|
||||
cursor?: string,
|
||||
syncContext?: Record<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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=<id>`, 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<string, unknown>,
|
||||
externalId: string
|
||||
): Promise<ExternalDocument | null> => {
|
||||
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<string, unknown>
|
||||
): 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<string, unknown>): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user