From 4db820214b77d5649e62bc8a08f923d62640473b Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 16 Feb 2026 23:09:50 +0100 Subject: [PATCH] feat(telemetry): implement glitchtip/sentry integration with smart proxy - Added Sentry/GlitchTip relay route handler - Configured Sentry for client (with tunnel), server, and edge runtimes - Refactored GlitchTip adapter to use official @sentry/nextjs SDK - Fixed ComparisonRow type issues and Analytics Suspense bailout - Integrated Sentry instrumentation into the boot sequence --- apps/web/app/errors/api/relay/route.ts | 55 +++++++++++++ apps/web/instrumentation.ts | 13 ++++ apps/web/next.config.mjs | 16 +--- apps/web/sentry.client.config.ts | 16 ++++ apps/web/sentry.edge.config.ts | 10 +++ apps/web/sentry.server.config.ts | 11 +++ apps/web/src/components/Analytics.tsx | 26 ++++--- .../src/components/Landing/ComparisonRow.tsx | 2 +- apps/web/src/lib/env.ts | 3 + .../utils/error-tracking/glitchtip-adapter.ts | 78 ++++++------------- 10 files changed, 152 insertions(+), 78 deletions(-) create mode 100644 apps/web/app/errors/api/relay/route.ts create mode 100644 apps/web/instrumentation.ts create mode 100644 apps/web/sentry.client.config.ts create mode 100644 apps/web/sentry.edge.config.ts create mode 100644 apps/web/sentry.server.config.ts diff --git a/apps/web/app/errors/api/relay/route.ts b/apps/web/app/errors/api/relay/route.ts new file mode 100644 index 0000000..9adf8eb --- /dev/null +++ b/apps/web/app/errors/api/relay/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +/** + * Smart Proxy / Relay for Sentry/GlitchTip events. + * + * Mirroring the klz-2026 pattern: + * Receives Sentry envelopes from the client, injects the correct DSN, + * and forwards them to GlitchTip. + */ +export async function POST(request: NextRequest) { + try { + const envelope = await request.text(); + + const realDsn = env.SENTRY_DSN; + + if (!realDsn) { + console.warn( + "[Sentry Relay] Received payload but no SENTRY_DSN configured", + ); + return NextResponse.json({ status: "ignored" }, { status: 200 }); + } + + const dsnUrl = new URL(realDsn); + const projectId = dsnUrl.pathname.replace("/", ""); + const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`; + + const response = await fetch(relayUrl, { + method: "POST", + body: envelope, + headers: { + "Content-Type": "application/x-sentry-envelope", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("[Sentry Relay] GlitchTip API responded with error", { + status: response.status, + error: errorText.slice(0, 100), + }); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: "ok" }); + } catch (error) { + console.error("[Sentry Relay] Failed to relay Sentry request", { + error: (error as Error).message, + }); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts new file mode 100644 index 0000000..7cbe93c --- /dev/null +++ b/apps/web/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 72f47ea..6e70a14 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -9,21 +9,9 @@ const nextConfig = { loaderFile: './src/utils/imgproxy-loader.ts', }, async rewrites() { - const umamiUrl = - process.env.UMAMI_API_ENDPOINT || - process.env.UMAMI_SCRIPT_URL || - process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || - "https://analytics.infra.mintel.me"; - const glitchtipUrl = process.env.SENTRY_DSN - ? new URL(process.env.SENTRY_DSN).origin - : "https://errors.infra.mintel.me"; - return [ - // Umami proxy rewrite removed in favor of app/stats/api/send/route.ts - { - source: "/errors/:path*", - destination: `${glitchtipUrl}/:path*`, - }, + // Umami proxy rewrite handled in app/stats/api/send/route.ts + // Sentry relay handled in app/errors/api/relay/route.ts ]; }, async redirects() { diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts new file mode 100644 index 0000000..0888d25 --- /dev/null +++ b/apps/web/sentry.client.config.ts @@ -0,0 +1,16 @@ +import * as Sentry from "@sentry/nextjs"; + +// Use a placeholder DSN on the client as the real one is injected by our relay +const CLIENT_DSN = "https://public@errors.infra.mintel.me/1"; + +Sentry.init({ + dsn: CLIENT_DSN, + // Relay events through our own server to hide the real DSN and bypass ad-blockers + tunnel: "/errors/api/relay", + + enabled: true, + + tracesSampleRate: 0, + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 0.1, +}); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts new file mode 100644 index 0000000..64b5c1b --- /dev/null +++ b/apps/web/sentry.edge.config.ts @@ -0,0 +1,10 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.SENTRY_DSN; + +Sentry.init({ + dsn, + enabled: Boolean(dsn), + tracesSampleRate: 1, + debug: false, +}); diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts new file mode 100644 index 0000000..73411cb --- /dev/null +++ b/apps/web/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.SENTRY_DSN; + +Sentry.init({ + dsn, + enabled: Boolean(dsn), + + tracesSampleRate: 1, + debug: false, +}); diff --git a/apps/web/src/components/Analytics.tsx b/apps/web/src/components/Analytics.tsx index a49e814..1e568a2 100644 --- a/apps/web/src/components/Analytics.tsx +++ b/apps/web/src/components/Analytics.tsx @@ -1,12 +1,12 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, Suspense } from "react"; import { usePathname, useSearchParams } from "next/navigation"; import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker"; import { getDefaultAnalytics } from "../utils/analytics"; import { getDefaultErrorTracking } from "../utils/error-tracking"; -export const Analytics: React.FC = () => { +const AnalyticsInner: React.FC = () => { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -122,17 +122,23 @@ export const Analytics: React.FC = () => { const adapter = analytics.getAdapter(); const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null; - if (!scriptTag) return null; - - // We use dangerouslySetInnerHTML to inject the script tag from the adapter - // This is safe here because the script URLs and IDs come from our own config/env return ( <> -
+ {scriptTag && ( +
+ )} ); }; + +export const Analytics: React.FC = () => { + return ( + + + + ); +}; diff --git a/apps/web/src/components/Landing/ComparisonRow.tsx b/apps/web/src/components/Landing/ComparisonRow.tsx index 80dcfed..b92c7b7 100644 --- a/apps/web/src/components/Landing/ComparisonRow.tsx +++ b/apps/web/src/components/Landing/ComparisonRow.tsx @@ -8,7 +8,7 @@ import { cn } from "../../utils/cn"; interface ComparisonRowProps { description?: string; negativeLabel: string; - negativeText: React.ReactNode; + negativeText: string; positiveLabel: string; positiveText: React.ReactNode; reverse?: boolean; diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts index a3e21dd..306b43c 100644 --- a/apps/web/src/lib/env.ts +++ b/apps/web/src/lib/env.ts @@ -20,6 +20,9 @@ const envExtension = { .url() .optional() .default("https://analytics.infra.mintel.me"), + + // Error Tracking + SENTRY_DSN: z.string().url().optional(), }; /** diff --git a/apps/web/src/utils/error-tracking/glitchtip-adapter.ts b/apps/web/src/utils/error-tracking/glitchtip-adapter.ts index 7b5ee09..e3f6fdd 100644 --- a/apps/web/src/utils/error-tracking/glitchtip-adapter.ts +++ b/apps/web/src/utils/error-tracking/glitchtip-adapter.ts @@ -1,76 +1,48 @@ /** * GlitchTip Error Tracking Adapter * GlitchTip is Sentry-compatible. + * This version uses the official @sentry/nextjs SDK. */ -import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces'; +import * as Sentry from "@sentry/nextjs"; +import type { + ErrorTrackingAdapter, + ErrorContext, + ErrorTrackingConfig, +} from "./interfaces"; export class GlitchTipAdapter implements ErrorTrackingAdapter { - private dsn: string; - - constructor(config: ErrorTrackingConfig) { - this.dsn = config.dsn; - this.init(config); - } - - private init(config: ErrorTrackingConfig) { - if (typeof window === 'undefined') return; - - // In a real scenario, we would import @sentry/nextjs or @sentry/browser - // For this implementation, we assume Sentry is available globally or - // we provide the structure that would call the SDK. - const w = window as any; - if (w.Sentry) { - w.Sentry.init({ - dsn: this.dsn, - environment: config.environment || 'production', - release: config.release, - debug: config.debug || false, - }); - } + constructor(_config: ErrorTrackingConfig) { + // Sentry is initialized via sentry.*.config.ts files } captureException(error: any, context?: ErrorContext): void { - if (typeof window === 'undefined') return; - const w = window as any; - if (w.Sentry) { - w.Sentry.captureException(error, context); - } else { - console.error('[GlitchTip] Exception captured (Sentry not loaded):', error, context); - } + Sentry.captureException(error, { + extra: context?.extra, + tags: context?.tags, + user: context?.user as any, + level: context?.level as any, + }); } captureMessage(message: string, context?: ErrorContext): void { - if (typeof window === 'undefined') return; - const w = window as any; - if (w.Sentry) { - w.Sentry.captureMessage(message, context); - } else { - console.log('[GlitchTip] Message captured (Sentry not loaded):', message, context); - } + Sentry.captureMessage(message, { + extra: context?.extra, + tags: context?.tags, + user: context?.user as any, + level: context?.level as any, + }); } - setUser(user: ErrorContext['user']): void { - if (typeof window === 'undefined') return; - const w = window as any; - if (w.Sentry) { - w.Sentry.setUser(user); - } + setUser(user: ErrorContext["user"]): void { + Sentry.setUser(user as any); } setTag(key: string, value: string): void { - if (typeof window === 'undefined') return; - const w = window as any; - if (w.Sentry) { - w.Sentry.setTag(key, value); - } + Sentry.setTag(key, value); } setExtra(key: string, value: any): void { - if (typeof window === 'undefined') return; - const w = window as any; - if (w.Sentry) { - w.Sentry.setExtra(key, value); - } + Sentry.setExtra(key, value); } }