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
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -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
519
lib/integrations/sentry.ts
Normal 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 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<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;
|
||||
}
|
||||
192
lib/scaffold/sentry-snippets.ts
Normal file
192
lib/scaffold/sentry-snippets.ts
Normal 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'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.',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user