Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61e78ea672 |
@@ -22,6 +22,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
"@mintel/observability": "workspace:*",
|
||||||
|
"@mintel/next-observability": "workspace:*",
|
||||||
|
"@sentry/nextjs": "^8.55.0",
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
9
apps/sample-website/sentry.client.config.ts
Normal file
9
apps/sample-website/sentry.client.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { initSentry } from "@mintel/next-observability";
|
||||||
|
|
||||||
|
initSentry({
|
||||||
|
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
|
||||||
|
// Or just use the real DSN if you don't care about ad-blockers for errors.
|
||||||
|
// The Mintel standard is to use the relay.
|
||||||
|
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
|
||||||
|
tunnel: "/errors/api/relay",
|
||||||
|
});
|
||||||
8
apps/sample-website/sentry.edge.config.ts
Normal file
8
apps/sample-website/sentry.edge.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { initSentry } from "@mintel/next-observability";
|
||||||
|
import { validateMintelEnv } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
const env = validateMintelEnv();
|
||||||
|
|
||||||
|
initSentry({
|
||||||
|
dsn: env.SENTRY_DSN,
|
||||||
|
});
|
||||||
8
apps/sample-website/sentry.server.config.ts
Normal file
8
apps/sample-website/sentry.server.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { initSentry } from "@mintel/next-observability";
|
||||||
|
import { validateMintelEnv } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
const env = validateMintelEnv();
|
||||||
|
|
||||||
|
initSentry({
|
||||||
|
dsn: env.SENTRY_DSN,
|
||||||
|
});
|
||||||
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createSentryRelayHandler } from "@mintel/next-observability";
|
||||||
|
import { validateMintelEnv } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
export const POST = createSentryRelayHandler({
|
||||||
|
dsn: validateMintelEnv().SENTRY_DSN,
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import {
|
||||||
|
AnalyticsContextProvider,
|
||||||
|
AnalyticsAutoTracker,
|
||||||
|
} from "@mintel/next-observability/client";
|
||||||
|
import { getAnalyticsConfig } from "@/lib/observability";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sample Website",
|
title: "Sample Website",
|
||||||
@@ -11,9 +17,18 @@ export default function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const analyticsConfig = getAnalyticsConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<AnalyticsContextProvider config={analyticsConfig}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AnalyticsAutoTracker />
|
||||||
|
</Suspense>
|
||||||
|
{children}
|
||||||
|
</AnalyticsContextProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createUmamiProxyHandler } from "@mintel/next-observability";
|
||||||
|
import { validateMintelEnv } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
export const POST = createUmamiProxyHandler({
|
||||||
|
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
|
||||||
|
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
|
||||||
|
});
|
||||||
13
apps/sample-website/src/instrumentation.ts
Normal file
13
apps/sample-website/src/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;
|
||||||
54
apps/sample-website/src/lib/observability.ts
Normal file
54
apps/sample-website/src/lib/observability.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
UmamiAnalyticsService,
|
||||||
|
GotifyNotificationService,
|
||||||
|
NoopNotificationService,
|
||||||
|
} from "@mintel/observability";
|
||||||
|
import { validateMintelEnv } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
let analyticsService: any = null;
|
||||||
|
let notificationService: any = null;
|
||||||
|
|
||||||
|
export function getAnalyticsConfig() {
|
||||||
|
const isClient = typeof window !== "undefined";
|
||||||
|
|
||||||
|
if (isClient) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
apiEndpoint: "/stats",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = validateMintelEnv();
|
||||||
|
return {
|
||||||
|
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||||
|
websiteId: env.UMAMI_WEBSITE_ID,
|
||||||
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnalyticsService() {
|
||||||
|
if (analyticsService) return analyticsService;
|
||||||
|
|
||||||
|
const config = getAnalyticsConfig();
|
||||||
|
analyticsService = new UmamiAnalyticsService(config);
|
||||||
|
|
||||||
|
return analyticsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationService() {
|
||||||
|
if (notificationService) return notificationService;
|
||||||
|
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
const env = validateMintelEnv();
|
||||||
|
notificationService = new GotifyNotificationService({
|
||||||
|
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||||
|
url: env.GOTIFY_URL || "",
|
||||||
|
token: env.GOTIFY_TOKEN || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Notifications are typically server-side only to protect tokens
|
||||||
|
notificationService = new NoopNotificationService();
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationService;
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ program
|
|||||||
react: "^19.0.0",
|
react: "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
"@mintel/next-observability": "workspace:*",
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
},
|
},
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
@@ -263,11 +264,15 @@ export default createMintelI18nRequestConfig(
|
|||||||
// Create instrumentation.ts
|
// Create instrumentation.ts
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(fullPath, "src/instrumentation.ts"),
|
path.join(fullPath, "src/instrumentation.ts"),
|
||||||
`import * as Sentry from '@sentry/nextjs';
|
`import { Sentry } from '@mintel/next-observability';
|
||||||
|
|
||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
// Server-side initialization
|
await import('./sentry.server.config');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||||
|
await import('./sentry.edge.config');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +402,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
|
|||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
|
# Notifications (Gotify)
|
||||||
|
GOTIFY_URL=
|
||||||
|
GOTIFY_TOKEN=
|
||||||
`;
|
`;
|
||||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||||
|
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export const mintelEnvSchema = {
|
export const mintelEnvSchema = {
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
NODE_ENV: z
|
||||||
|
.enum(["development", "production", "test"])
|
||||||
|
.default("development"),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
// Analytics (Proxy Pattern)
|
||||||
|
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
|
UMAMI_API_ENDPOINT: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.default("https://analytics.infra.mintel.me"),
|
||||||
|
|
||||||
|
// Error Tracking
|
||||||
SENTRY_DSN: z.string().optional(),
|
SENTRY_DSN: z.string().optional(),
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
||||||
|
// Notifications
|
||||||
|
GOTIFY_URL: z.string().url().optional(),
|
||||||
|
GOTIFY_TOKEN: z.string().optional(),
|
||||||
|
|
||||||
|
LOG_LEVEL: z
|
||||||
|
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||||
|
.default("info"),
|
||||||
MAIL_HOST: z.string().optional(),
|
MAIL_HOST: z.string().optional(),
|
||||||
MAIL_PORT: z.coerce.number().default(587),
|
MAIL_PORT: z.coerce.number().default(587),
|
||||||
MAIL_USERNAME: z.string().optional(),
|
MAIL_USERNAME: z.string().optional(),
|
||||||
MAIL_PASSWORD: z.string().optional(),
|
MAIL_PASSWORD: z.string().optional(),
|
||||||
MAIL_FROM: z.string().optional(),
|
MAIL_FROM: z.string().optional(),
|
||||||
MAIL_RECIPIENTS: z.preprocess(
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||||
z.array(z.string()).default([])
|
z.array(z.string()).default([]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) {
|
|||||||
...schemaExtension,
|
...schemaExtension,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isBuildTime =
|
||||||
|
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||||
|
process.env.SKIP_ENV_VALIDATION === "true";
|
||||||
|
|
||||||
const result = fullSchema.safeParse(process.env);
|
const result = fullSchema.safeParse(process.env);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
if (isBuildTime) {
|
||||||
throw new Error('Invalid environment variables');
|
console.warn(
|
||||||
|
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
|
||||||
|
);
|
||||||
|
// Return partial data to allow build to continue
|
||||||
|
return process.env as unknown as z.infer<typeof fullSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
"❌ Invalid environment variables:",
|
||||||
|
result.error.flatten().fieldErrors,
|
||||||
|
);
|
||||||
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
123
packages/observability/README.md
Normal file
123
packages/observability/README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# @mintel/observability
|
||||||
|
|
||||||
|
Standardized observability package for the Mintel ecosystem, providing Umami analytics and Sentry/GlitchTip error tracking with a focus on privacy and ad-blocker resilience.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Umami Smart Proxy**: Track analytics without external scripts and hide your Website ID.
|
||||||
|
- **Sentry Relay**: Bypass ad-blockers for error tracking by relaying envelopes through your own server.
|
||||||
|
- **Unified API**: consistent interface for tracking across multiple projects.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @mintel/observability @sentry/nextjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Unified Environment (via @mintel/next-utils)
|
||||||
|
|
||||||
|
Define the following environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Analytics
|
||||||
|
UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
|
# Error Tracking
|
||||||
|
SENTRY_DSN=your-sentry-dsn
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: No `NEXT_PUBLIC_` prefix is required for these anymore, as they are handled by server-side proxies.
|
||||||
|
|
||||||
|
### 2. Analytics Setup
|
||||||
|
|
||||||
|
In your root layout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
AnalyticsContextProvider,
|
||||||
|
AnalyticsAutoTracker,
|
||||||
|
UmamiAnalyticsService,
|
||||||
|
} from "@mintel/observability";
|
||||||
|
|
||||||
|
const analytics = new UmamiAnalyticsService({
|
||||||
|
enabled: true,
|
||||||
|
websiteId: process.env.UMAMI_WEBSITE_ID, // Server-side
|
||||||
|
apiEndpoint:
|
||||||
|
typeof window === "undefined" ? process.env.UMAMI_API_ENDPOINT : "/stats",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<AnalyticsContextProvider service={analytics}>
|
||||||
|
<AnalyticsAutoTracker />
|
||||||
|
{children}
|
||||||
|
</AnalyticsContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Route Handlers
|
||||||
|
|
||||||
|
Create a proxy for Umami:
|
||||||
|
`app/stats/api/send/route.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createUmamiProxyHandler } from "@mintel/observability";
|
||||||
|
export const POST = await createUmamiProxyHandler({
|
||||||
|
websiteId: process.env.UMAMI_WEBSITE_ID,
|
||||||
|
apiEndpoint: process.env.UMAMI_API_ENDPOINT,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a relay for Sentry:
|
||||||
|
`app/errors/api/relay/route.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createSentryRelayHandler } from "@mintel/observability";
|
||||||
|
export const POST = await createSentryRelayHandler({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Notification Setup (Server-side)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GotifyNotificationService } from "@mintel/observability";
|
||||||
|
|
||||||
|
const notifications = new GotifyNotificationService({
|
||||||
|
enabled: true,
|
||||||
|
url: process.env.GOTIFY_URL,
|
||||||
|
token: process.env.GOTIFY_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifications.notify({
|
||||||
|
title: "Lead Capture",
|
||||||
|
message: "New contact form submission",
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Sentry Configuration
|
||||||
|
|
||||||
|
Use `initSentry` in your `sentry.server.config.ts` and `sentry.client.config.ts`.
|
||||||
|
|
||||||
|
On the client, use the tunnel:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
initSentry({
|
||||||
|
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder
|
||||||
|
tunnel: "/errors/api/relay",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This package implements the **Smart Proxy** pattern:
|
||||||
|
|
||||||
|
- The client NEVER knows the real `UMAMI_WEBSITE_ID`.
|
||||||
|
- Tracking events are sent to your own domain (`/stats/api/send`).
|
||||||
|
- Your server injects the secret ID and forwards to Umami.
|
||||||
|
- This bypasses ad-blockers and keeps your configuration secure.
|
||||||
34
packages/observability/package.json
Normal file
34
packages/observability/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||||
|
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/observability/src/analytics/noop.test.ts
Normal file
14
packages/observability/src/analytics/noop.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { NoopAnalyticsService } from "./noop";
|
||||||
|
|
||||||
|
describe("NoopAnalyticsService", () => {
|
||||||
|
it("should not throw on track", () => {
|
||||||
|
const service = new NoopAnalyticsService();
|
||||||
|
expect(() => service.track("test")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw on trackPageview", () => {
|
||||||
|
const service = new NoopAnalyticsService();
|
||||||
|
expect(() => service.trackPageview()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
packages/observability/src/analytics/noop.ts
Normal file
15
packages/observability/src/analytics/noop.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-operation analytics service.
|
||||||
|
* Used when analytics are disabled or for local development.
|
||||||
|
*/
|
||||||
|
export class NoopAnalyticsService implements AnalyticsService {
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties): void {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
trackPageview(url?: string): void {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/observability/src/analytics/service.ts
Normal file
31
packages/observability/src/analytics/service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Type definition for analytics event properties.
|
||||||
|
*/
|
||||||
|
export type AnalyticsEventProperties = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for analytics service implementations.
|
||||||
|
*
|
||||||
|
* This interface defines the contract for all analytics services,
|
||||||
|
* allowing for different implementations (Umami, Google Analytics, etc.)
|
||||||
|
* while maintaining a consistent API.
|
||||||
|
*/
|
||||||
|
export interface AnalyticsService {
|
||||||
|
/**
|
||||||
|
* Track a custom event with optional properties.
|
||||||
|
*
|
||||||
|
* @param eventName - The name of the event to track
|
||||||
|
* @param props - Optional event properties (metadata)
|
||||||
|
*/
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a pageview.
|
||||||
|
*
|
||||||
|
* @param url - The URL to track (defaults to current location)
|
||||||
|
*/
|
||||||
|
trackPageview(url?: string): void;
|
||||||
|
}
|
||||||
74
packages/observability/src/analytics/umami.test.ts
Normal file
74
packages/observability/src/analytics/umami.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { UmamiAnalyticsService } from "./umami";
|
||||||
|
|
||||||
|
describe("UmamiAnalyticsService", () => {
|
||||||
|
const mockConfig = {
|
||||||
|
websiteId: "test-website-id",
|
||||||
|
apiEndpoint: "https://analytics.test",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not send payload if disabled", async () => {
|
||||||
|
const service = new UmamiAnalyticsService({
|
||||||
|
...mockConfig,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
service.track("test-event");
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send payload with correct data for track", async () => {
|
||||||
|
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
service.track("test-event", { foo: "bar" });
|
||||||
|
|
||||||
|
// Wait for async sendPayload
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://analytics.test/api/send",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: expect.stringContaining('"type":"event"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(callBody.payload.name).toBe("test-event");
|
||||||
|
expect(callBody.payload.data.foo).toBe("bar");
|
||||||
|
expect(callBody.payload.website).toBe("test-website-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log warning if send fails", async () => {
|
||||||
|
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
|
||||||
|
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: () => Promise.resolve("Internal error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
service.track("test-event");
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
"Umami API responded with error",
|
||||||
|
expect.objectContaining({ status: 500 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/observability/src/analytics/umami.ts
Normal file
115
packages/observability/src/analytics/umami.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||||
|
|
||||||
|
export interface UmamiConfig {
|
||||||
|
websiteId?: string;
|
||||||
|
apiEndpoint: string; // The endpoint to send to (proxied or direct)
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
debug(msg: string, data?: any): void;
|
||||||
|
warn(msg: string, data?: any): void;
|
||||||
|
error(msg: string, data?: any): void;
|
||||||
|
trace(msg: string, data?: any): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||||
|
*/
|
||||||
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
|
private logger?: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: UmamiConfig,
|
||||||
|
logger?: Logger,
|
||||||
|
) {
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendPayload(type: "event", data: Record<string, any>) {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
const isClient = typeof window !== "undefined";
|
||||||
|
const websiteId = this.config.websiteId;
|
||||||
|
|
||||||
|
if (!isClient && !websiteId) {
|
||||||
|
this.logger?.warn(
|
||||||
|
"Umami tracking called on server but no Website ID configured",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
website: websiteId,
|
||||||
|
hostname: isClient ? window.location.hostname : "server",
|
||||||
|
screen: isClient
|
||||||
|
? `${window.screen.width}x${window.screen.height}`
|
||||||
|
: undefined,
|
||||||
|
language: isClient ? navigator.language : undefined,
|
||||||
|
referrer: isClient ? document.referrer : undefined,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger?.trace("Sending analytics payload", { type, url: data.url });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.apiEndpoint}/api/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": isClient ? navigator.userAgent : "Mintel-Server",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
keepalive: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger?.warn("Umami API responded with error", {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if ((fetchError as Error).name === "AbortError") {
|
||||||
|
this.logger?.error("Umami request timed out");
|
||||||
|
} else {
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.error("Failed to send analytics", {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||||
|
this.sendPayload("event", {
|
||||||
|
name: eventName,
|
||||||
|
data: props,
|
||||||
|
url:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trackPageview(url?: string) {
|
||||||
|
this.sendPayload("event", {
|
||||||
|
url:
|
||||||
|
url ||
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/observability/src/index.ts
Normal file
9
packages/observability/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Analytics
|
||||||
|
export * from "./analytics/service";
|
||||||
|
export * from "./analytics/umami";
|
||||||
|
export * from "./analytics/noop";
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
export * from "./notifications/service";
|
||||||
|
export * from "./notifications/gotify";
|
||||||
|
export * from "./notifications/noop";
|
||||||
82
packages/observability/src/notifications/gotify.test.ts
Normal file
82
packages/observability/src/notifications/gotify.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { GotifyNotificationService } from "./gotify";
|
||||||
|
|
||||||
|
describe("GotifyNotificationService", () => {
|
||||||
|
const mockConfig = {
|
||||||
|
url: "https://gotify.test",
|
||||||
|
token: "test-token",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLogger = {
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not notify if disabled", async () => {
|
||||||
|
const service = new GotifyNotificationService({
|
||||||
|
...mockConfig,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
await service.notify({ title: "test", message: "test" });
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send correct payload to Gotify", async () => {
|
||||||
|
const service = new GotifyNotificationService(mockConfig, mockLogger);
|
||||||
|
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await service.notify({
|
||||||
|
title: "Alert",
|
||||||
|
message: "Critical issue",
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://gotify.test/message?token=test-token",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Alert",
|
||||||
|
message: "Critical issue",
|
||||||
|
priority: 8,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing trailing slash in URL", async () => {
|
||||||
|
const service = new GotifyNotificationService({
|
||||||
|
...mockConfig,
|
||||||
|
url: "https://gotify.test",
|
||||||
|
});
|
||||||
|
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await service.notify({ title: "test", message: "test" });
|
||||||
|
|
||||||
|
expect((global.fetch as any).mock.calls[0][0]).toBe(
|
||||||
|
"https://gotify.test/message?token=test-token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log error if notify fails", async () => {
|
||||||
|
const service = new GotifyNotificationService(mockConfig, mockLogger);
|
||||||
|
(global.fetch as any).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: () => Promise.resolve("Unauthorized"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.notify({ title: "test", message: "test" });
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
"Gotify notification failed",
|
||||||
|
expect.objectContaining({ status: 401 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
packages/observability/src/notifications/gotify.ts
Normal file
56
packages/observability/src/notifications/gotify.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NotificationOptions, NotificationService } from "./service";
|
||||||
|
|
||||||
|
export interface GotifyConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gotify Notification Service implementation.
|
||||||
|
*/
|
||||||
|
export class GotifyNotificationService implements NotificationService {
|
||||||
|
constructor(
|
||||||
|
private config: GotifyConfig,
|
||||||
|
private logger?: { error(msg: string, data?: any): void },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async notify(options: NotificationOptions): Promise<void> {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, message, priority = 4 } = options;
|
||||||
|
|
||||||
|
// Ensure we have a trailing slash for base URL, then append 'message'
|
||||||
|
const baseUrl = this.config.url.endsWith("/")
|
||||||
|
? this.config.url
|
||||||
|
: `${this.config.url}/`;
|
||||||
|
const url = new URL("message", baseUrl);
|
||||||
|
url.searchParams.set("token", this.config.token);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger?.error("Gotify notification failed", {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.error("Gotify notification error", {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/observability/src/notifications/noop.test.ts
Normal file
11
packages/observability/src/notifications/noop.test.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { NoopNotificationService } from "./noop";
|
||||||
|
|
||||||
|
describe("NoopNotificationService", () => {
|
||||||
|
it("should not throw on notify", async () => {
|
||||||
|
const service = new NoopNotificationService();
|
||||||
|
await expect(
|
||||||
|
service.notify({ title: "test", message: "test" }),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
packages/observability/src/notifications/noop.ts
Normal file
10
packages/observability/src/notifications/noop.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NotificationService } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-operation notification service.
|
||||||
|
*/
|
||||||
|
export class NoopNotificationService implements NotificationService {
|
||||||
|
async notify(): Promise<void> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/observability/src/notifications/service.ts
Normal file
16
packages/observability/src/notifications/service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface NotificationOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for notification service implementations.
|
||||||
|
* Allows for different implementations (Gotify, Slack, Email, etc.)
|
||||||
|
*/
|
||||||
|
export interface NotificationService {
|
||||||
|
/**
|
||||||
|
* Send a notification.
|
||||||
|
*/
|
||||||
|
notify(options: NotificationOptions): Promise<void>;
|
||||||
|
}
|
||||||
11
packages/observability/tsconfig.json
Normal file
11
packages/observability/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
600
pnpm-lock.yaml
generated
600
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user