From 9ddbe5b7d80dfbb49c8de6b0db7fda2488e6e52a Mon Sep 17 00:00:00 2001
From: Mark Henderson
Date: Fri, 1 May 2026 12:52:17 -0700
Subject: [PATCH] feat(sentry-as-product): auto-provision per-project + AI
feedback loop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements all 4 stages from SENTRY_AS_PRODUCT.md:
Stage 1 — Auto-provision per-project Sentry:
- New module lib/integrations/sentry.ts with idempotent
ensureSentryProject(): creates Sentry project under shared
vibnai org, fetches DSN, persists to fs_projects.data.sentry.
- Wired into POST /api/projects/create (provision early so DSN is
ready before first deploy) and into applyEnvsAndDeploy in MCP
(lazy retry + env var injection on every apps.create).
- applySentryEnvToCoolifyApp upserts NEXT_PUBLIC_SENTRY_DSN +
SENTRY_AUTH_TOKEN onto the Coolify app, so the very first build
inlines the DSN into the client bundle and uploads source maps.
Stage 2 — Bake into scaffolds:
- New module lib/scaffold/sentry-snippets.ts exposes canonical
Next.js + Vite+React snippets the AI copies verbatim (keeps
outputs deterministic across chats).
- AI system prompt updated: explicit instructions to wire Sentry
on every new app, env vars are guaranteed available, project
Sentry slug comes from projects_get.
- projects.get MCP response now includes `sentry: {slug, dsn,
provisionedAt}` so the AI can substitute the slug into
withSentryConfig({ project: }).
Stage 3 — Expose error feed to the AI:
- Three new MCP tools registered:
project_recent_errors — list unresolved issues
project_error_detail — stack trace + breadcrumbs + replay url
project_error_resolve — mark resolved after a verified fix
- Tenant-safe: each tool re-checks projectId belongs to caller's
workspace before talking to Sentry.
Stage 4 — Auto-surface at chat-turn start:
- chat/route.ts pulls listRecentSentryIssues for the active
project (last 6h, count ≥ 2 to skip noise) and appends a
[PROJECT HEALTH] block to the system prompt. AI decides
whether to surface a one-liner; if user's message is about a
broken thing, AI prefers Sentry stack trace over guessing.
End state: a Vibn user's deployed app crashes for a real user →
Sentry captures with source-mapped stack trace + Session Replay →
next AI chat turn the AI knows about it and can offer a fix
without the user pasting the error.
Co-authored-by: Cursor
---
app/api/chat/route.ts | 41 ++-
app/api/mcp/route.ts | 136 ++++++++
app/api/projects/create/route.ts | 17 +
lib/ai/vibn-tools.ts | 42 ++-
lib/integrations/sentry.ts | 519 +++++++++++++++++++++++++++++++
lib/scaffold/sentry-snippets.ts | 192 ++++++++++++
6 files changed, 944 insertions(+), 3 deletions(-)
create mode 100644 lib/integrations/sentry.ts
create mode 100644 lib/scaffold/sentry-snippets.ts
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.',
+ ],
+};