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
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:
55
apps/web/app/errors/api/relay/route.ts
Normal file
55
apps/web/app/errors/api/relay/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/web/instrumentation.ts
Normal file
13
apps/web/instrumentation.ts
Normal 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;
|
||||||
@@ -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() {
|
||||||
|
|||||||
16
apps/web/sentry.client.config.ts
Normal file
16
apps/web/sentry.client.config.ts
Normal 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,
|
||||||
|
});
|
||||||
10
apps/web/sentry.edge.config.ts
Normal file
10
apps/web/sentry.edge.config.ts
Normal 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,
|
||||||
|
});
|
||||||
11
apps/web/sentry.server.config.ts
Normal file
11
apps/web/sentry.server.config.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user