This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/lib/integrations/sentry.ts

520 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}