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

View File

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

519
lib/integrations/sentry.ts Normal file
View File

@@ -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<SentryProjectInfo | null> {
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 az, 09, 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<void> {
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<SentryProjectInfo | null> {
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<SentryIssueSummary[]> {
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<Record<string, any>>;
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<SentryEventDetail | null> {
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<string, any>;
// 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<Record<string, any>> | 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<Record<string, any>> | 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<Record<string, any>> | 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<boolean> {
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<string, any>): 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<SentryApiProject | null> {
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<SentryApiProject | null> {
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<string | null> {
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<SentryProjectInfo | null> {
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;
}

View File

@@ -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 (
<html>
<body>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: "100vh", padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>Something went wrong</h1>
<p style={{ color: "#666" }}>We&apos;ve been notified. Try refreshing.</p>
{error.digest ? <code style={{ fontSize: "0.75rem", color: "#999", marginTop: "1rem" }}>ref: {error.digest}</code> : null}
</div>
</body>
</html>
);
}
`,
},
],
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: "<sentry-project-slug>", 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: "<sentry-project-slug>", 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 <App /> in <Sentry.ErrorBoundary fallback={<p>Something broke</p>}><App /></Sentry.ErrorBoundary> so React render errors propagate to Sentry.',
],
};