feat(sentry-as-product): auto-provision per-project + AI feedback loop

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: <slug> }).

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-01 12:52:17 -07:00
parent 871f465079
commit 9ddbe5b7d8
6 changed files with 944 additions and 3 deletions

View File

@@ -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: "<slug>", ... })\`. 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';

View File

@@ -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<string, any>
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: <slug> })
// 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<string | NextResponse> {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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);

View File

@@ -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
// ──────────────────────────────────────────────