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 (
<>