feat(telemetry): implement glitchtip/sentry integration with smart proxy
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Failing after 2m43s
Build & Deploy / 🏗️ Build (push) Failing after 5m34s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

- 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
This commit is contained in:
2026-02-16 23:09:50 +01:00
parent 2038b8fe47
commit 4db820214b
10 changed files with 152 additions and 78 deletions

View File

@@ -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 },
);
}
}

View File

@@ -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;

View File

@@ -9,21 +9,9 @@ const nextConfig = {
loaderFile: './src/utils/imgproxy-loader.ts', loaderFile: './src/utils/imgproxy-loader.ts',
}, },
async rewrites() { 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 [ return [
// Umami proxy rewrite removed in favor of app/stats/api/send/route.ts // Umami proxy rewrite handled in app/stats/api/send/route.ts
{ // Sentry relay handled in app/errors/api/relay/route.ts
source: "/errors/:path*",
destination: `${glitchtipUrl}/:path*`,
},
]; ];
}, },
async redirects() { async redirects() {

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import React, { useEffect } from "react"; import React, { useEffect, Suspense } from "react";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker"; import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
import { getDefaultAnalytics } from "../utils/analytics"; import { getDefaultAnalytics } from "../utils/analytics";
import { getDefaultErrorTracking } from "../utils/error-tracking"; import { getDefaultErrorTracking } from "../utils/error-tracking";
export const Analytics: React.FC = () => { const AnalyticsInner: React.FC = () => {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -122,17 +122,23 @@ export const Analytics: React.FC = () => {
const adapter = analytics.getAdapter(); const adapter = analytics.getAdapter();
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null; 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 ( return (
<> <>
<ScrollDepthTracker /> <ScrollDepthTracker />
<div {scriptTag && (
dangerouslySetInnerHTML={{ __html: scriptTag }} <div
style={{ display: "none" }} dangerouslySetInnerHTML={{ __html: scriptTag }}
/> style={{ display: "none" }}
/>
)}
</> </>
); );
}; };
export const Analytics: React.FC = () => {
return (
<Suspense fallback={null}>
<AnalyticsInner />
</Suspense>
);
};

View File

@@ -8,7 +8,7 @@ import { cn } from "../../utils/cn";
interface ComparisonRowProps { interface ComparisonRowProps {
description?: string; description?: string;
negativeLabel: string; negativeLabel: string;
negativeText: React.ReactNode; negativeText: string;
positiveLabel: string; positiveLabel: string;
positiveText: React.ReactNode; positiveText: React.ReactNode;
reverse?: boolean; reverse?: boolean;

View File

@@ -20,6 +20,9 @@ const envExtension = {
.url() .url()
.optional() .optional()
.default("https://analytics.infra.mintel.me"), .default("https://analytics.infra.mintel.me"),
// Error Tracking
SENTRY_DSN: z.string().url().optional(),
}; };
/** /**

View File

@@ -1,76 +1,48 @@
/** /**
* GlitchTip Error Tracking Adapter * GlitchTip Error Tracking Adapter
* GlitchTip is Sentry-compatible. * 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 { export class GlitchTipAdapter implements ErrorTrackingAdapter {
private dsn: string; constructor(_config: ErrorTrackingConfig) {
// Sentry is initialized via sentry.*.config.ts files
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,
});
}
} }
captureException(error: any, context?: ErrorContext): void { captureException(error: any, context?: ErrorContext): void {
if (typeof window === 'undefined') return; Sentry.captureException(error, {
const w = window as any; extra: context?.extra,
if (w.Sentry) { tags: context?.tags,
w.Sentry.captureException(error, context); user: context?.user as any,
} else { level: context?.level as any,
console.error('[GlitchTip] Exception captured (Sentry not loaded):', error, context); });
}
} }
captureMessage(message: string, context?: ErrorContext): void { captureMessage(message: string, context?: ErrorContext): void {
if (typeof window === 'undefined') return; Sentry.captureMessage(message, {
const w = window as any; extra: context?.extra,
if (w.Sentry) { tags: context?.tags,
w.Sentry.captureMessage(message, context); user: context?.user as any,
} else { level: context?.level as any,
console.log('[GlitchTip] Message captured (Sentry not loaded):', message, context); });
}
} }
setUser(user: ErrorContext['user']): void { setUser(user: ErrorContext["user"]): void {
if (typeof window === 'undefined') return; Sentry.setUser(user as any);
const w = window as any;
if (w.Sentry) {
w.Sentry.setUser(user);
}
} }
setTag(key: string, value: string): void { setTag(key: string, value: string): void {
if (typeof window === 'undefined') return; Sentry.setTag(key, value);
const w = window as any;
if (w.Sentry) {
w.Sentry.setTag(key, value);
}
} }
setExtra(key: string, value: any): void { setExtra(key: string, value: any): void {
if (typeof window === 'undefined') return; Sentry.setExtra(key, value);
const w = window as any;
if (w.Sentry) {
w.Sentry.setExtra(key, value);
}
} }
} }