feat: integrate observability
This commit is contained in:
49
packages/next-observability/package.json
Normal file
49
packages/next-observability/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.0.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client.d.ts",
|
||||
"import": "./dist/client.js",
|
||||
"require": "./dist/client.cjs"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts src/client.ts --format cjs,esm --dts --splitting",
|
||||
"dev": "tsup src/index.ts src/client.ts --format cjs,esm --watch --dts --splitting",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"next": "15.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useAnalytics } from "./context";
|
||||
|
||||
/**
|
||||
* Automatically tracks pageviews on client-side route changes in Next.js.
|
||||
*/
|
||||
export function AnalyticsAutoTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
||||
analytics.trackPageview(url);
|
||||
}, [pathname, searchParams, analytics]);
|
||||
|
||||
return null;
|
||||
}
|
||||
50
packages/next-observability/src/analytics/context.tsx
Normal file
50
packages/next-observability/src/analytics/context.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, ReactNode, useMemo } from "react";
|
||||
import type { AnalyticsService } from "@mintel/observability";
|
||||
import {
|
||||
NoopAnalyticsService,
|
||||
UmamiAnalyticsService,
|
||||
} from "@mintel/observability";
|
||||
|
||||
const AnalyticsContext = createContext<AnalyticsService>(
|
||||
new NoopAnalyticsService(),
|
||||
);
|
||||
|
||||
export interface AnalyticsContextProviderProps {
|
||||
service?: AnalyticsService;
|
||||
config?: {
|
||||
enabled: boolean;
|
||||
websiteId?: string;
|
||||
apiEndpoint: string;
|
||||
};
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalyticsContextProvider({
|
||||
service,
|
||||
config,
|
||||
children,
|
||||
}: AnalyticsContextProviderProps) {
|
||||
const activeService = useMemo(() => {
|
||||
if (service) return service;
|
||||
if (config) return new UmamiAnalyticsService(config);
|
||||
return new NoopAnalyticsService();
|
||||
}, [service, config]);
|
||||
|
||||
return (
|
||||
<AnalyticsContext.Provider value={activeService}>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const context = useContext(AnalyticsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAnalytics must be used within an AnalyticsContextProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
4
packages/next-observability/src/client.ts
Normal file
4
packages/next-observability/src/client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export * from "./analytics/context";
|
||||
export * from "./analytics/auto-tracker";
|
||||
26
packages/next-observability/src/errors/sentry.ts
Normal file
26
packages/next-observability/src/errors/sentry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export interface SentryConfig {
|
||||
dsn?: string;
|
||||
enabled?: boolean;
|
||||
tracesSampleRate?: number;
|
||||
tunnel?: string;
|
||||
replaysOnErrorSampleRate?: number;
|
||||
replaysSessionSampleRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized Sentry initialization for Mintel projects.
|
||||
*/
|
||||
export function initSentry(config: SentryConfig) {
|
||||
Sentry.init({
|
||||
dsn: config.dsn,
|
||||
enabled: config.enabled ?? Boolean(config.dsn || config.tunnel),
|
||||
tracesSampleRate: config.tracesSampleRate ?? 0,
|
||||
tunnel: config.tunnel,
|
||||
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate ?? 1.0,
|
||||
replaysSessionSampleRate: config.replaysSessionSampleRate ?? 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
90
packages/next-observability/src/handlers/index.ts
Normal file
90
packages/next-observability/src/handlers/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Logic for Umami Smart Proxy Route Handler.
|
||||
*/
|
||||
export function createUmamiProxyHandler(config: {
|
||||
websiteId?: string;
|
||||
apiEndpoint: string;
|
||||
}) {
|
||||
return async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
if (!config.websiteId) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: config.websiteId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${config.apiEndpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": request.headers.get("user-agent") || "Mintel-Proxy",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic for Sentry/GlitchTip Relay Route Handler.
|
||||
*/
|
||||
export function createSentryRelayHandler(config: { dsn?: string }) {
|
||||
return async function POST(request: NextRequest) {
|
||||
try {
|
||||
const envelope = await request.text();
|
||||
const lines = envelope.split("\n");
|
||||
if (lines.length < 1) {
|
||||
return NextResponse.json({ error: "Empty envelope" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!config.dsn) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
const dsnUrl = new URL(config.dsn);
|
||||
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();
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
packages/next-observability/src/index.ts
Normal file
2
packages/next-observability/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers/index";
|
||||
export * from "./errors/sentry";
|
||||
10
packages/next-observability/tsconfig.json
Normal file
10
packages/next-observability/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user