diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 332096cc..3665dfa5 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -20,6 +20,7 @@ import { query } from '@/lib/db-postgres'; import { callGeminiChat } from '@/lib/ai/gemini-chat'; import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools'; import { detectKnownError, formatRecoveryMessage } from '@/lib/ai/error-recovery'; +import { listRecentSentryIssues } from '@/lib/integrations/sentry'; import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat'; // Bumped from 6 to 12 because Path B chains (devcontainer.ensure → @@ -178,7 +179,9 @@ Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` an - **Next dev:** \`next dev -p 3000 -H 0.0.0.0\` (WSS HMR works automatically through the proxy without extra config). - **Express / plain Node:** bind \`0.0.0.0\` (we set \`HOST=0.0.0.0\` env, but verify your framework respects it). -**Build-me-X recipe:** \`devcontainer_ensure\` → \`shell_exec npx create-next-app@latest . --yes\` (or pick an OSS scaffold via \`github_search\`) → \`fs_edit\` / \`fs_write\` to customize → \`dev_server_start { command: 'npm run dev', port: 3000 }\` and share the preview URL → when the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). +**Build-me-X recipe:** \`devcontainer_ensure\` → \`shell_exec npx create-next-app@latest . --yes\` (or pick an OSS scaffold via \`github_search\`) → \`fs_edit\` / \`fs_write\` to customize → **wire Sentry (see below)** → \`dev_server_start { command: 'npm run dev', port: 3000 }\` and share the preview URL → when the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). + +**Sentry is auto-provisioned per Vibn project.** When you scaffold a Next.js or Vite app, wire Sentry from day one so the user gets de-minified error capture + Session Replay on first deploy. The DSN (\`NEXT_PUBLIC_SENTRY_DSN\`) and shared org auth token (\`SENTRY_AUTH_TOKEN\`) are injected into the Coolify app's env automatically by \`apps_create\` — you don't set them. Get the project's Sentry slug from \`projects_get { projectId }\` (field: \`sentry.slug\`); pass it to \`withSentryConfig({ org: "vibnai", project: "", ... })\`. The reference recipe (instrumentation.ts, instrumentation-client.ts, app/global-error.tsx, next.config.ts wrapper, Dockerfile ARG declarations) is in \`vibn-frontend/lib/scaffold/sentry-snippets.ts\` — read it once via \`fs_*\` if you're unsure, then copy the snippets into the user's project verbatim. Skip Sentry for non-app projects (CLIs, library-only repos). **Rules:** - Stay under \`/workspace\`. \`fs_*\` enforce this; use \`shell_exec\` deliberately for system paths. @@ -304,7 +307,41 @@ export async function POST(request: Request) { } } - const systemPrompt = buildSystemPrompt(projects, workspace, activeProject); + let systemPrompt = buildSystemPrompt(projects, workspace, activeProject); + + // Sentry-as-product Stage 4: auto-surface unresolved errors at + // chat-turn start. We pull the last 6 hours' unresolved issues + // for the active project; if anything has fired ≥2 times, we + // append a [PROJECT HEALTH] block to the system prompt so the + // AI is aware before the user even speaks. The AI decides + // whether to mention them — usually yes if the user's first + // message touches the affected area, otherwise a one-line FYI. + // Single-occurrence errors are filtered out to avoid noise from + // bots / one-off network blips. + if (activeProject?.id) { + try { + const issues = await listRecentSentryIssues(activeProject.id, { + sinceHours: 6, + limit: 5, + }); + const noteworthy = issues.filter((i) => i.count >= 2); + if (noteworthy.length > 0) { + const lines = noteworthy.map((i) => { + const culprit = i.culprit ? ` — ${i.culprit}` : ''; + return `- ${i.title} (×${i.count}, last seen ${i.lastSeen})${culprit}`; + }); + const healthBlock = + `\n\n[PROJECT HEALTH — last 6 hours]\n` + + `${noteworthy.length} unresolved Sentry issue${noteworthy.length === 1 ? '' : 's'}, count ≥ 2 (one-offs filtered):\n` + + lines.join('\n') + + `\n\nIf the user's message is about something that's broken, prefer the matching issue's stack trace over guessing — call \`project_error_detail { projectId, issueId }\` to fetch it. ` + + `If the user's message is unrelated to these errors, you MAY proactively surface a one-liner ("FYI: X has been failing for users — want me to look?") but do not derail their actual question.`; + systemPrompt += healthBlock; + } + } catch (err) { + console.warn('[chat] auto-surface Sentry errors failed (non-fatal)', err); + } + } // Base URL for internal MCP calls const host = request.headers.get('host') || 'vibnai.com'; diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index c4542252..255c3a88 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -53,6 +53,13 @@ import { PREVIEW_PORT_COUNT, } from '@/lib/dev-container'; import { isPathBDisabled } from '@/lib/feature-flags'; +import { + ensureSentryProject, + applySentryEnvToCoolifyApp, + listRecentSentryIssues, + getSentryIssueDetail, + resolveSentryIssue, +} from '@/lib/integrations/sentry'; import { composeUp, composePs, @@ -144,6 +151,9 @@ export async function GET() { 'gitea.credentials', 'projects.list', 'projects.get', + 'project.recent_errors', + 'project.error_detail', + 'project.error_resolve', 'apps.list', 'apps.get', 'apps.create', @@ -246,6 +256,15 @@ export async function POST(request: Request) { case 'projects.get': return await toolProjectsGet(principal, params); + case 'project.recent_errors': + return await toolProjectRecentErrors(principal, params); + + case 'project.error_detail': + return await toolProjectErrorDetail(principal, params); + + case 'project.error_resolve': + return await toolProjectErrorResolve(principal, params); + case 'apps.list': return await toolAppsList(principal, params); @@ -626,12 +645,100 @@ async function toolProjectsGet(principal: Principal, params: Record coolifyDomain: d.coolifyDomain || null, repositoryUrl: d.repositoryUrl || null, possibleDeployments, + // Sentry-as-product: surface the project's Sentry slug + DSN + // so the AI can wire withSentryConfig({ org, project: }) + // when scaffolding apps. DSN is also injected as a Coolify env + // var by apps.create automatically — see SENTRY_AS_PRODUCT.md. + sentry: d.sentry + ? { slug: d.sentry.slug, dsn: d.sentry.dsn, provisionedAt: d.sentry.provisionedAt } + : null, createdAt: r.created_at, updatedAt: r.updated_at, }, }); } +/** + * Tenant-safe lookup: confirms the project belongs to the caller's + * workspace before exposing Sentry data. Returns null + error + * response if not, the project id (string) if yes. Used by all + * three Sentry tools below. + */ +async function projectInWorkspace( + principal: Principal, + projectId: string, +): Promise { + if (!projectId) { + return NextResponse.json({ error: 'Param "projectId" is required' }, { status: 400 }); + } + const rows = await query<{ id: string }>( + `SELECT id FROM fs_projects + WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) + LIMIT 1`, + [projectId, principal.workspace.id, principal.workspace.slug], + ); + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 }); + } + return projectId; +} + +async function toolProjectRecentErrors(principal: Principal, params: Record) { + const projectId = await projectInWorkspace(principal, String(params.projectId ?? '').trim()); + if (projectId instanceof NextResponse) return projectId; + + const sinceHours = clampNumber(params.sinceHours, { min: 1, max: 168, fallback: 24 }); + const limit = clampNumber(params.limit, { min: 1, max: 50, fallback: 10 }); + + const issues = await listRecentSentryIssues(projectId, { sinceHours, limit }); + return NextResponse.json({ result: { issues, count: issues.length, sinceHours } }); +} + +async function toolProjectErrorDetail(principal: Principal, params: Record) { + const projectId = await projectInWorkspace(principal, String(params.projectId ?? '').trim()); + if (projectId instanceof NextResponse) return projectId; + + const issueId = String(params.issueId ?? '').trim(); + if (!issueId) { + return NextResponse.json({ error: 'Param "issueId" is required' }, { status: 400 }); + } + const detail = await getSentryIssueDetail(projectId, issueId); + if (!detail) { + return NextResponse.json( + { error: 'Issue not found or Sentry not provisioned for this project' }, + { status: 404 }, + ); + } + return NextResponse.json({ result: detail }); +} + +async function toolProjectErrorResolve(principal: Principal, params: Record) { + const projectId = await projectInWorkspace(principal, String(params.projectId ?? '').trim()); + if (projectId instanceof NextResponse) return projectId; + + const issueId = String(params.issueId ?? '').trim(); + if (!issueId) { + return NextResponse.json({ error: 'Param "issueId" is required' }, { status: 400 }); + } + const ok = await resolveSentryIssue(projectId, issueId); + if (!ok) { + return NextResponse.json( + { error: 'Resolve failed (Sentry not provisioned, issue not found, or token rejected)' }, + { status: 502 }, + ); + } + return NextResponse.json({ result: { resolved: true, issueId } }); +} + +function clampNumber( + raw: unknown, + opts: { min: number; max: number; fallback: number }, +): number { + const n = Number(raw); + if (!Number.isFinite(n)) return opts.fallback; + return Math.max(opts.min, Math.min(opts.max, Math.floor(n))); +} + function requireCoolifyProject(principal: Principal): string | NextResponse { const projectUuid = principal.workspace.coolify_project_uuid; if (!projectUuid) { @@ -2051,6 +2158,35 @@ async function applyEnvsAndDeploy( } } } + + // Sentry-as-product: when this app belongs to a Vibn project, + // ensure a Sentry project exists for it and inject the DSN + + // shared org auth token as Coolify env vars. Done here (after + // user envs, before deploy) so the very first build of the app + // already inlines the public DSN into the client bundle and + // uploads source maps. See SENTRY_AS_PRODUCT.md. + if (params.projectId) { + try { + const projectId = String(params.projectId); + const projectRow = await queryOne<{ slug: string; data: any; workspace: string }>( + `SELECT slug, data, workspace FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId], + ); + if (projectRow) { + await ensureSentryProject({ + projectId, + workspaceSlug: projectRow.workspace, + projectSlug: projectRow.slug, + projectName: + projectRow.data?.productName || projectRow.data?.name || projectRow.slug, + }); + await applySentryEnvToCoolifyApp(appUuid, projectId); + } + } catch (e) { + console.warn('[mcp apps.create] sentry provisioning failed (non-fatal)', e); + } + } + if (params.instantDeploy === false) return null; try { const dep = await deployApplication(appUuid); diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 44ec9611..ee857981 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; import { ensureProjectCoolifyProject } from '@/lib/projects'; +import { ensureSentryProject } from '@/lib/integrations/sentry'; import { loadGithubIntegration } from '@/lib/integrations/github'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; @@ -208,6 +209,22 @@ export async function POST(request: Request) { { projectSlug: slug, projectName }, ); + // Sentry-as-product: provision a Sentry project under the + // shared `vibnai` org so any Coolify app deployed for this + // Vibn project has a DSN waiting in env vars on first build. + // Soft-fails — project create still succeeds without Sentry, + // and apps.create will lazily retry the provisioning later. + try { + await ensureSentryProject({ + projectId, + workspaceSlug: workspace, + projectSlug: slug, + projectName, + }); + } catch (sentryErr) { + console.warn('[API] Sentry provisioning failed (non-fatal):', sentryErr); + } + // ────────────────────────────────────────────── // 3. Save project record // ────────────────────────────────────────────── diff --git a/lib/ai/vibn-tools.ts b/lib/ai/vibn-tools.ts index f6ba2b09..d50d7a0f 100644 --- a/lib/ai/vibn-tools.ts +++ b/lib/ai/vibn-tools.ts @@ -36,7 +36,7 @@ export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'projects_get', - description: 'Get details for a single Vibn project by ID (name, status, vision, linked Coolify UUID).', + description: 'Get details for a single Vibn project by ID (name, status, vision, linked Coolify UUID, Sentry slug + DSN).', parameters: { type: 'OBJECT', properties: { @@ -46,6 +46,46 @@ export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [ }, }, + // ── Sentry (Stage 3 of Sentry-as-product) ─────────────────────────────── + + { + name: 'project_recent_errors', + description: 'List recent unresolved Sentry issues for a Vibn project. Each item has id, title, level, count, lastSeen, culprit, permalink. Use this when the user asks "is anything broken?" or before declaring something done. Returns [] if Sentry is not yet provisioned (project too new) — that is fine.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + sinceHours: { type: 'NUMBER', description: 'Look-back window in hours. Default 24, max 168 (1 week).' }, + limit: { type: 'NUMBER', description: 'Max issues to return (1-50). Default 10.' }, + }, + required: ['projectId'], + }, + }, + { + name: 'project_error_detail', + description: 'Fetch the most recent event for a Sentry issue: stack frames (top 12, source-mapped to real filenames), breadcrumbs (last 20 user actions before the error), user/request context, and a Session Replay link if one was captured. Call this AFTER project_recent_errors gives you an issue id.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + issueId: { type: 'STRING', description: 'Sentry issue id from project_recent_errors.' }, + }, + required: ['projectId', 'issueId'], + }, + }, + { + name: 'project_error_resolve', + description: 'Mark a Sentry issue resolved. Call this AFTER you have shipped a fix and either run a verifying test, watched the error stop firing, or had the user confirm. Do NOT mark resolved speculatively — Sentry auto-reopens issues on regression but it is noisy.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + issueId: { type: 'STRING', description: 'Sentry issue id to resolve.' }, + }, + required: ['projectId', 'issueId'], + }, + }, + // ── Applications ───────────────────────────────────────────────────────── { diff --git a/lib/integrations/sentry.ts b/lib/integrations/sentry.ts new file mode 100644 index 00000000..19e05bd4 --- /dev/null +++ b/lib/integrations/sentry.ts @@ -0,0 +1,519 @@ +/** + * 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; +} diff --git a/lib/scaffold/sentry-snippets.ts b/lib/scaffold/sentry-snippets.ts new file mode 100644 index 00000000..4892c91c --- /dev/null +++ b/lib/scaffold/sentry-snippets.ts @@ -0,0 +1,192 @@ +/** + * Canonical Sentry wiring snippets the AI should drop into any + * new app it scaffolds. Keeping these in one place (and naming + * the file paths explicitly) means the AI's outputs are + * deterministic across chats — every Next.js app it scaffolds + * gets the same instrumentation files, the same global error + * boundary, and the same `withSentryConfig` wrapper. + * + * The runtime env vars `NEXT_PUBLIC_SENTRY_DSN` and + * `SENTRY_AUTH_TOKEN` are guaranteed to exist on every Coolify + * app created via apps.create with a `projectId` — see + * `lib/integrations/sentry.ts` and `applyEnvsAndDeploy` in + * `app/api/mcp/route.ts`. + * + * The AI references these via `getSentrySnippets("nextjs")` etc. + * Authoring rule: when changing any snippet, also bump the + * matching file in vibn-frontend itself so they stay in sync — + * vibn-frontend is the reference implementation. + */ + +export type SentryFramework = 'nextjs' | 'vite-react'; + +export interface SentrySnippet { + /** Repo-relative path the file should land at. */ + path: string; + /** File contents, copied verbatim by the AI. */ + contents: string; + /** Short description for AI to mention to the user, if relevant. */ + purpose: string; +} + +export interface SentryWiringPackage { + framework: SentryFramework; + /** npm dependencies the AI must add to package.json. */ + dependencies: string[]; + /** Files to write into the project. */ + files: SentrySnippet[]; + /** Free-form modifications the AI must apply to existing files. */ + modifications: string[]; +} + +/** + * Returns the full set of files + dependency edits + free-form + * modifications the AI must apply when scaffolding a new app of + * this framework. Currently covers Next.js (App Router) and + * Vite + React. + */ +export function getSentrySnippets(framework: SentryFramework): SentryWiringPackage { + switch (framework) { + case 'nextjs': + return NEXTJS_PACKAGE; + case 'vite-react': + return VITE_REACT_PACKAGE; + default: { + const exhaustive: never = framework; + throw new Error(`Unsupported Sentry framework: ${exhaustive}`); + } + } +} + +// ────────────────────────────────────────────────────────────────── +// Next.js (App Router, Next 14+) +// ────────────────────────────────────────────────────────────────── + +const NEXTJS_PACKAGE: SentryWiringPackage = { + framework: 'nextjs', + dependencies: ['@sentry/nextjs'], + files: [ + { + path: 'instrumentation.ts', + purpose: 'Server + edge runtime Sentry init', + contents: `import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + tracesSampleRate: 1.0, + enabled: Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN), + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, + }); + } + if (process.env.NEXT_RUNTIME === 'edge') { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + tracesSampleRate: 1.0, + enabled: Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN), + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, + }); + } +} + +export const onRequestError = Sentry.captureRequestError; +`, + }, + { + path: 'instrumentation-client.ts', + purpose: 'Browser Sentry init with Session Replay', + contents: `import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + enabled: Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN), + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV, + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + Sentry.replayIntegration({ + maskAllText: true, + blockAllMedia: true, + }), + ], +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; +`, + }, + { + path: 'app/global-error.tsx', + purpose: 'Catches root-layout crashes that escape every other error boundary', + contents: `"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + +
+

Something went wrong

+

We've been notified. Try refreshing.

+ {error.digest ? ref: {error.digest} : null} +
+ + + ); +} +`, + }, + ], + modifications: [ + 'In next.config.ts (or next.config.js), import withSentryConfig from "@sentry/nextjs" and wrap the exported config with withSentryConfig(nextConfig, { org: "vibnai", project: "", silent: !process.env.CI, widenClientFileUpload: true, tunnelRoute: "/monitoring", telemetry: false, errorHandler: (err) => console.warn("Sentry source map upload skipped:", err.message) }).', + 'In Dockerfile (if the project uses Docker), add ARG NEXT_PUBLIC_SENTRY_DSN and ARG SENTRY_AUTH_TOKEN in the builder stage, then ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN and ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN — without these the build args from Coolify never reach `next build`.', + 'The Sentry project slug for this Vibn project is fs_projects.data.sentry.slug — fetch it from the projects/get MCP tool and substitute into the next.config.ts above.', + ], +}; + +// ────────────────────────────────────────────────────────────────── +// Vite + React +// ────────────────────────────────────────────────────────────────── + +const VITE_REACT_PACKAGE: SentryWiringPackage = { + framework: 'vite-react', + dependencies: ['@sentry/react', '@sentry/vite-plugin'], + files: [ + { + path: 'src/sentry.ts', + purpose: 'Sentry init, imported once at the top of main.tsx', + contents: `import * as Sentry from "@sentry/react"; + +Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + enabled: Boolean(import.meta.env.VITE_SENTRY_DSN), + environment: import.meta.env.MODE, + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }), + ], +}); +`, + }, + ], + modifications: [ + 'In src/main.tsx, add `import "./sentry"` as the FIRST import line (before React, before App).', + 'In vite.config.ts, add the Sentry vite plugin: import { sentryVitePlugin } from "@sentry/vite-plugin"; then in plugins: [sentryVitePlugin({ org: "vibnai", project: "", authToken: process.env.SENTRY_AUTH_TOKEN, telemetry: false, errorHandler: (err) => console.warn(err.message) })]. Set build.sourcemap = true so source maps generate.', + 'Vite uses VITE_SENTRY_DSN (not NEXT_PUBLIC_SENTRY_DSN). Add `VITE_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN` as a Coolify env var alias on the app, OR have the AI rename the env when injecting via apps_create.', + 'Wrap the root in Something broke

}>
so React render errors propagate to Sentry.', + ], +};