/** * Sentry-as-product integration. * * Provisions a Sentry project per Vibn project under the shared * `vibnai` Sentry org, then makes its DSN + auth token available * to any Coolify app deployed for that project. Real-user errors * from the user's deployed app land in Sentry; AI tools (Stage 3) * read the issue feed back into chat (Stage 4). * * Design choices: * - **Idempotent.** Looks up existing Sentry projects by slug * before creating, so reruns are safe. * - **Shared org token.** The same `SENTRY_AUTH_TOKEN` we use to * upload source maps for vibn-frontend is reused across every * user project — it has org-write scope. This means we DON'T * need per-project auth tokens, just per-project DSNs. * - **Slug convention.** `vibn-{workspace}-{projectSlug}`. Avoids * collisions across workspaces and stays under Sentry's * 50-char project slug limit. Clamped if longer. * - **Soft failure.** If Sentry provisioning fails (rate limit, * network, token revoked) we log and continue. The Vibn * project still works without Sentry; we'll lazily retry on * the next deploy. * * See SENTRY_AS_PRODUCT.md for the full proposal context. */ import { query } from '@/lib/db-postgres'; import { upsertApplicationEnv } from '@/lib/coolify'; const SENTRY_API_BASE = 'https://de.sentry.io/api/0'; const SENTRY_ORG_SLUG = 'vibnai'; const SENTRY_TEAM_SLUG = 'vibnai'; // Default team name matches org slug for personal/single-team setups. export interface SentryProjectInfo { /** Sentry project slug, e.g. `vibn-mark-account-checkout-app`. */ slug: string; /** Public DSN for runtime error capture. NEXT_PUBLIC_SENTRY_DSN. */ dsn: string; /** When this row was created/updated. */ provisionedAt: string; } export interface ProvisionInput { projectId: string; /** Vibn workspace slug, e.g. "mark-account". */ workspaceSlug: string; /** Vibn project slug, e.g. "checkout-app". */ projectSlug: string; /** Display name shown in the Sentry UI. */ projectName: string; } /** * Returns the Sentry project for a given Vibn project, creating * it if missing. Persists the result to fs_projects.data.sentry * so subsequent calls hit the cache instead of Sentry's API. * * Returns null if Sentry provisioning fails — caller should log * and continue. */ export async function ensureSentryProject( input: ProvisionInput, ): Promise { const authToken = process.env.SENTRY_AUTH_TOKEN; if (!authToken) { console.warn('[sentry] SENTRY_AUTH_TOKEN missing — skipping provisioning'); return null; } // 1. Fast path: already provisioned for this Vibn project. const cached = await loadSentryFromProject(input.projectId); if (cached) return cached; // 2. Build the deterministic slug. Sentry caps at 50 chars and // only allows lowercase a–z, 0–9, dashes. const slug = buildSentrySlug(input.workspaceSlug, input.projectSlug); try { // 3. Try to look up existing Sentry project (handles the case // where fs_projects was wiped but Sentry still has the project). const existing = await fetchSentryProject(slug, authToken); if (existing) { const info = await materialize(input.projectId, existing, authToken); if (info) return info; } // 4. Create fresh. const created = await createSentryProject({ slug, name: input.projectName.slice(0, 50), authToken, }); if (!created) return null; return await materialize(input.projectId, created, authToken); } catch (err) { console.error('[sentry] ensureSentryProject failed', err); return null; } } /** * Sets the standard Sentry env vars on a Coolify application * so that its next build inlines the DSN and uploads source maps. * Idempotent — safe to call on every apps.create. Soft-fails on * any per-key error so a single failed upsert doesn't block deploy. * * Env vars set: * - NEXT_PUBLIC_SENTRY_DSN — public DSN for runtime capture * - SENTRY_AUTH_TOKEN — shared org token for source map upload * * Both are marked Coolify-default (is_buildtime=true is_runtime=true); * the public DSN must be present at build time so Next.js inlines * it into the client bundle. */ export async function applySentryEnvToCoolifyApp( coolifyAppUuid: string, projectId: string, ): Promise { const sentry = await loadSentryFromProject(projectId); if (!sentry) return; // Project not yet provisioned in Sentry — Stage 1 will retry. const authToken = process.env.SENTRY_AUTH_TOKEN; if (!authToken) return; const envs: Array<[string, string]> = [ ['NEXT_PUBLIC_SENTRY_DSN', sentry.dsn], ['SENTRY_AUTH_TOKEN', authToken], ]; for (const [key, value] of envs) { try { await upsertApplicationEnv(coolifyAppUuid, { key, value }); } catch (err) { console.warn(`[sentry] upsert ${key} on ${coolifyAppUuid} failed`, err); } } } /** * Returns the Sentry project info for a Vibn project if already * provisioned, else null. Read-only — does NOT call Sentry's API. */ export async function loadSentryFromProject( projectId: string, ): Promise { const rows = await query<{ data: any }>( `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId], ); const sentry = rows[0]?.data?.sentry; if (sentry?.slug && sentry?.dsn && sentry?.provisionedAt) { return sentry as SentryProjectInfo; } return null; } // ────────────────────────────────────────────────────────────────── // Issue feed — Stage 3 MCP tools read these // ────────────────────────────────────────────────────────────────── export interface SentryIssueSummary { id: string; title: string; /** "error" | "fatal" | "warning" | "info" | "debug" */ level: string; /** Total event count (lifetime). */ count: number; /** ISO of most recent occurrence. */ lastSeen: string; /** ISO of first occurrence. */ firstSeen: string; /** "unresolved" | "resolved" | "ignored" — we filter to unresolved. */ status: string; /** Best-effort culprit string from Sentry, e.g. "GET /api/checkout". */ culprit: string | null; /** Direct URL to the issue in Sentry's UI. */ permalink: string | null; } export interface SentryEventDetail { /** Sentry event ID (the canonical "fingerprint" of this occurrence). */ eventId: string; /** Most recent event's render of the stack trace (top frames first). */ stackFrames: Array<{ function: string | null; filename: string | null; lineno: number | null; colno: number | null; /** Source code surrounding the line if Sentry has source maps. */ contextLine: string | null; }>; /** User-context tag (email/id/username/ip), if Sentry captured one. */ user: { email?: string; id?: string; username?: string; ipAddress?: string } | null; /** Request method + URL if this was an HTTP-facing error. */ request: { method?: string; url?: string } | null; /** Truncated breadcrumbs (last 20) — clicks, fetches, navigations. */ breadcrumbs: Array<{ type: string | null; category: string | null; message: string | null; timestamp: string | null; }>; /** Direct URL to a Session Replay if the user had one recorded. */ replayUrl: string | null; } /** * List recent unresolved issues for a Vibn project's Sentry project. * Returns [] if Sentry isn't provisioned yet — caller should treat * that as "no errors", which is functionally correct (no Sentry = * no error capture path). */ export async function listRecentSentryIssues( projectId: string, options?: { limit?: number; sinceHours?: number }, ): Promise { const sentry = await loadSentryFromProject(projectId); if (!sentry) return []; const authToken = process.env.SENTRY_AUTH_TOKEN; if (!authToken) return []; const limit = clampLimit(options?.limit ?? 10); const sinceHours = options?.sinceHours ?? 24; const statsPeriod = `${sinceHours}h`; const url = new URL( `${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${sentry.slug}/issues/`, ); url.searchParams.set('limit', String(limit)); url.searchParams.set('query', 'is:unresolved'); url.searchParams.set('statsPeriod', statsPeriod); url.searchParams.set('sort', 'date'); const res = await fetch(url, { headers: { Authorization: `Bearer ${authToken}` }, }); if (!res.ok) { console.warn( `[sentry] listRecentSentryIssues ${res.status}: ${await res.text()}`, ); return []; } const raw = (await res.json()) as Array>; return raw.map((i) => ({ id: String(i.id), title: String(i.title ?? '(untitled)'), level: String(i.level ?? 'error'), count: Number(i.count ?? 0), lastSeen: String(i.lastSeen ?? ''), firstSeen: String(i.firstSeen ?? ''), status: String(i.status ?? 'unresolved'), culprit: i.culprit ? String(i.culprit) : null, permalink: i.permalink ? String(i.permalink) : null, })); } /** * Fetch the latest event for an issue, with stack trace, * breadcrumbs, user, request, and Session Replay link if any. * Returns null if the issue isn't found or Sentry isn't ready. */ export async function getSentryIssueDetail( projectId: string, issueId: string, ): Promise { const sentry = await loadSentryFromProject(projectId); if (!sentry) return null; const authToken = process.env.SENTRY_AUTH_TOKEN; if (!authToken) return null; const res = await fetch( `${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/events/latest/`, { headers: { Authorization: `Bearer ${authToken}` }, }, ); if (!res.ok) { console.warn( `[sentry] getSentryIssueDetail ${res.status}: ${await res.text()}`, ); return null; } const e = (await res.json()) as Record; // Sentry stores frames in a few different places depending on // platform. The exception entry has the most reliable shape. const exceptionEntry = (e.entries || []).find( (entry: any) => entry?.type === 'exception', ); const firstException = exceptionEntry?.data?.values?.[0]; const frames = (firstException?.stacktrace?.frames as Array> | undefined) ?? []; // Sentry returns frames in oldest-first order; reverse so top of // stack (the line that actually threw) is first — which is what // every developer scans first. const stackFrames = [...frames].reverse().slice(0, 12).map((f) => ({ function: f.function ?? null, filename: f.filename ?? null, lineno: typeof f.lineno === 'number' ? f.lineno : null, colno: typeof f.colno === 'number' ? f.colno : null, contextLine: f.contextLine ?? null, })); const breadcrumbsEntry = (e.entries || []).find( (entry: any) => entry?.type === 'breadcrumbs', ); const breadcrumbs = ((breadcrumbsEntry?.data?.values as Array> | undefined) ?? []) .slice(-20) .map((b) => ({ type: b.type ?? null, category: b.category ?? null, message: b.message ?? null, timestamp: b.timestamp ?? null, })); const requestEntry = (e.entries || []).find( (entry: any) => entry?.type === 'request', ); const request = requestEntry?.data ? { method: requestEntry.data.method ?? undefined, url: requestEntry.data.url ?? undefined, } : null; // Session Replay: Sentry attaches a `replayId` tag on events that // have one. Build the canonical UI URL. const replayId = (e.tags as Array> | undefined)?.find( (t) => t.key === 'replayId', )?.value; const replayUrl = replayId ? `https://${SENTRY_ORG_SLUG}.sentry.io/replays/${replayId}/` : null; return { eventId: String(e.eventID ?? e.id ?? ''), stackFrames, user: e.user ? sanitizeUser(e.user) : null, request, breadcrumbs, replayUrl, }; } /** * Mark an issue resolved. Used by the AI after it ships a fix and * verifies the bug no longer fires (e.g. via tests, log watch, or * explicit user confirmation). */ export async function resolveSentryIssue( projectId: string, issueId: string, ): Promise { const sentry = await loadSentryFromProject(projectId); if (!sentry) return false; const authToken = process.env.SENTRY_AUTH_TOKEN; if (!authToken) return false; const res = await fetch( `${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/`, { method: 'PUT', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'resolved' }), }, ); return res.ok; } function clampLimit(n: number): number { if (!Number.isFinite(n)) return 10; return Math.max(1, Math.min(50, Math.floor(n))); } function sanitizeUser(u: Record): SentryEventDetail['user'] { return { email: u.email ?? undefined, id: u.id ?? undefined, username: u.username ?? undefined, ipAddress: u.ip_address ?? u.ipAddress ?? undefined, }; } // ────────────────────────────────────────────────────────────────── // Internal helpers // ────────────────────────────────────────────────────────────────── function buildSentrySlug(workspaceSlug: string, projectSlug: string): string { const raw = `vibn-${workspaceSlug}-${projectSlug}` .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); // Sentry: 50 char max. Truncate from the end (preserves the // `vibn-{workspace}` prefix which keeps slugs distinguishable // across workspaces). return raw.slice(0, 50); } interface SentryApiProject { slug: string; name: string; id: string; } async function fetchSentryProject( slug: string, authToken: string, ): Promise { const res = await fetch( `${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/`, { headers: { Authorization: `Bearer ${authToken}` }, }, ); if (res.status === 404) return null; if (!res.ok) { throw new Error( `Sentry GET project failed: ${res.status} ${await res.text()}`, ); } return (await res.json()) as SentryApiProject; } async function createSentryProject(input: { slug: string; name: string; authToken: string; }): Promise { const res = await fetch( `${SENTRY_API_BASE}/teams/${SENTRY_ORG_SLUG}/${SENTRY_TEAM_SLUG}/projects/`, { method: 'POST', headers: { Authorization: `Bearer ${input.authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: input.name, slug: input.slug, platform: 'javascript-nextjs', // Most common case; doesn't gate which SDK actually sends events. }), }, ); if (!res.ok) { const text = await res.text(); // 409 = slug already exists in another team; we tried the GET // path first, so this is genuinely a race or a slug conflict. if (res.status === 409) { console.warn(`[sentry] slug ${input.slug} taken — retry GET`); return await fetchSentryProject(input.slug, input.authToken); } throw new Error(`Sentry POST project failed: ${res.status} ${text}`); } return (await res.json()) as SentryApiProject; } interface SentryClientKey { dsn: { public: string }; isActive: boolean; } async function fetchProjectDsn( slug: string, authToken: string, ): Promise { const res = await fetch( `${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/keys/`, { headers: { Authorization: `Bearer ${authToken}` }, }, ); if (!res.ok) { throw new Error( `Sentry GET keys failed: ${res.status} ${await res.text()}`, ); } const keys = (await res.json()) as SentryClientKey[]; // Sentry auto-creates a "Default" key on project creation; pick // the first active one. Multiple keys exist in pro setups but // we always want the live one. const active = keys.find((k) => k.isActive) ?? keys[0]; return active?.dsn?.public ?? null; } async function materialize( projectId: string, apiProj: SentryApiProject, authToken: string, ): Promise { const dsn = await fetchProjectDsn(apiProj.slug, authToken); if (!dsn) { console.error(`[sentry] no DSN returned for ${apiProj.slug}`); return null; } const info: SentryProjectInfo = { slug: apiProj.slug, dsn, provisionedAt: new Date().toISOString(), }; await query( `UPDATE fs_projects SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{sentry}', $2::jsonb, true), updated_at = NOW() WHERE id = $1`, [projectId, JSON.stringify(info)], ); return info; }