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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user