Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a55649c5f2 | |||
| 0d7c588536 | |||
| b6debcbb59 | |||
| 5847bc5795 | |||
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 |
@@ -28,6 +28,7 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -68,6 +69,7 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -79,12 +81,28 @@ jobs:
|
|||||||
pnpm release:tag
|
pnpm release:tag
|
||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
name: 🐳 Build & Push Images
|
name: 🐳 Build ${{ matrix.name }}
|
||||||
needs: qa
|
needs: qa
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- image: nextjs
|
||||||
|
file: packages/infra/docker/Dockerfile.nextjs
|
||||||
|
name: Build-Base
|
||||||
|
- image: runtime
|
||||||
|
file: packages/infra/docker/Dockerfile.runtime
|
||||||
|
name: Production Runtime
|
||||||
|
- image: gatekeeper
|
||||||
|
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||||
|
name: Gatekeeper (Product)
|
||||||
|
- image: directus
|
||||||
|
file: packages/infra/docker/Dockerfile.directus
|
||||||
|
name: Directus (Base)
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -99,58 +117,19 @@ jobs:
|
|||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: packages/infra/docker/Dockerfile.nextjs
|
file: ${{ matrix.file }}
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
secrets: |
|
secrets: |
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: |
|
tags: |
|
||||||
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||||
registry.infra.mintel.me/mintel/nextjs:latest
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Production Runtime
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: packages/infra/docker/Dockerfile.runtime
|
|
||||||
platforms: linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
secrets: |
|
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
|
||||||
tags: |
|
|
||||||
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
|
|
||||||
registry.infra.mintel.me/mintel/runtime:latest
|
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
|
||||||
platforms: linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
secrets: |
|
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
|
||||||
tags: |
|
|
||||||
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
|
|
||||||
registry.infra.mintel.me/mintel/gatekeeper:latest
|
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Directus (Base)
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: packages/infra/docker/Dockerfile.directus
|
|
||||||
platforms: linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
secrets: |
|
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
|
||||||
tags: |
|
|
||||||
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
|
|
||||||
registry.infra.mintel.me/mintel/directus:latest
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
.next/
|
.next/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@mintel/husky-config": "workspace:*",
|
"@mintel/husky-config": "workspace:*",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^20.17.16",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"mintel": "./dist/index.js"
|
"mintel": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm --target es2020",
|
"build": "tsup",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
target: 'es2020',
|
||||||
|
clean: true,
|
||||||
|
banner: {
|
||||||
|
js: '#!/usr/bin/env node',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
import { dirname } from "path";
|
import nextPlugin from "@next/eslint-plugin-next";
|
||||||
import { fileURLToPath } from "url";
|
import reactPlugin from "eslint-plugin-react";
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import hooksPlugin from "eslint-plugin-react-hooks";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
/**
|
||||||
const __dirname = dirname(__filename);
|
* Mintel Next.js ESLint Configuration (Flat Config)
|
||||||
|
*
|
||||||
const compat = new FlatCompat({
|
* This configuration replaces the legacy 'eslint-config-next' which
|
||||||
baseDirectory: __dirname,
|
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
||||||
});
|
*/
|
||||||
|
export const nextConfig = tseslint.config(
|
||||||
export const nextConfig = [
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
"**/dist/**",
|
|
||||||
"**/build/**",
|
|
||||||
"**/out/**",
|
|
||||||
"**/coverage/**",
|
|
||||||
"**/.next/**",
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/.gitea/**",
|
|
||||||
"**/.changeset/**",
|
|
||||||
"**/.vercel/**",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
|
||||||
{
|
{
|
||||||
|
plugins: {
|
||||||
|
"react": reactPlugin,
|
||||||
|
"react-hooks": hooksPlugin,
|
||||||
|
"@next/next": nextPlugin,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
// Add common browser/node globals if needed,
|
||||||
|
// though usually handled by base configs
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
...reactPlugin.configs.recommended.rules,
|
||||||
"@typescript-eslint/no-unused-vars": [
|
...hooksPlugin.configs.recommended.rules,
|
||||||
"warn",
|
...nextPlugin.configs.recommended.rules,
|
||||||
{ argsIgnorePattern: "^_" },
|
...nextPlugin.configs["core-web-vitals"].rules,
|
||||||
],
|
"react/react-in-jsx-scope": "off",
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
"prefer-const": "warn",
|
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "off",
|
||||||
"@next/next/no-img-element": "warn",
|
"@next/next/no-img-element": "warn",
|
||||||
},
|
},
|
||||||
},
|
settings: {
|
||||||
];
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/eslintrc": "^3.0.0",
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@next/eslint-plugin-next": "15.1.6",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.1.6",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"typescript-eslint": "^8.54.0"
|
"typescript-eslint": "^8.54.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
|
|||||||
import { NextConfig } from "next";
|
import { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Gatekeeper specific overrides
|
basePath: '/gatekeeper',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mintelNextConfig(nextConfig);
|
export default mintelNextConfig(nextConfig);
|
||||||
|
|||||||
@@ -9,16 +9,74 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const session = cookieStore.get(authCookieName);
|
const session = cookieStore.get(authCookieName);
|
||||||
|
|
||||||
if (session?.value === password) {
|
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||||
return new NextResponse("OK", { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traefik ForwardAuth headers
|
|
||||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||||
const host =
|
const host =
|
||||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(originalUrl, `${proto}://${host}`);
|
||||||
|
if (url.searchParams.get("gk_bypass") === password) {
|
||||||
|
// Remove the bypass parameter from the redirect URL
|
||||||
|
url.searchParams.delete("gk_bypass");
|
||||||
|
const cleanUrl = url.pathname + url.search;
|
||||||
|
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
|
||||||
|
|
||||||
|
const response = NextResponse.redirect(absoluteCleanUrl);
|
||||||
|
|
||||||
|
// Set the session cookie so the bypass is persistent
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||||
|
const sessionValue = JSON.stringify({
|
||||||
|
identity: "Bypass",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(authCookieName, sessionValue, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !isDev,
|
||||||
|
path: "/",
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
sameSite: "lax",
|
||||||
|
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// URL parsing failed, proceed with normal logic
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAuthenticated = false;
|
||||||
|
let identity = "Guest";
|
||||||
|
|
||||||
|
if (session?.value) {
|
||||||
|
if (session.value === password) {
|
||||||
|
isAuthenticated = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(session.value);
|
||||||
|
if (payload.identity) {
|
||||||
|
isAuthenticated = true;
|
||||||
|
identity = payload.identity;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback or old format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return new NextResponse("OK", {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"X-Auth-User": identity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traefik ForwardAuth headers
|
||||||
const gatekeeperUrl =
|
const gatekeeperUrl =
|
||||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||||
|
|||||||
26
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
26
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const authCookieName =
|
||||||
|
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||||
|
const session = cookieStore.get(authCookieName);
|
||||||
|
|
||||||
|
if (!session?.value) {
|
||||||
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity = "Guest";
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(session.value);
|
||||||
|
identity = payload.identity || "Guest";
|
||||||
|
} catch (e) {
|
||||||
|
// Old format probably just the password
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
identity: identity,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,18 +17,73 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
async function login(formData: FormData) {
|
async function login(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
const password = formData.get("password");
|
const email = formData.get("email") as string;
|
||||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
const password = formData.get("password") as string;
|
||||||
|
|
||||||
|
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||||
|
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
|
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||||
const authCookieName =
|
const authCookieName =
|
||||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||||
const targetRedirect = formData.get("redirect") as string;
|
const targetRedirect = formData.get("redirect") as string;
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||||
|
|
||||||
if (password === expectedPassword) {
|
let userIdentity = "";
|
||||||
|
|
||||||
|
// 1. Check Global Admin (from ENV)
|
||||||
|
if (
|
||||||
|
adminEmail &&
|
||||||
|
adminPassword &&
|
||||||
|
email === adminEmail &&
|
||||||
|
password === adminPassword
|
||||||
|
) {
|
||||||
|
userIdentity = "Admin";
|
||||||
|
}
|
||||||
|
// 2. Check Generic Code (Guest)
|
||||||
|
else if (!email && password === expectedCode) {
|
||||||
|
userIdentity = "Guest";
|
||||||
|
}
|
||||||
|
// 3. Check Directus if email is provided
|
||||||
|
if (email && password && process.env.DIRECTUS_URL) {
|
||||||
|
try {
|
||||||
|
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginRes.ok) {
|
||||||
|
const { data } = await loginRes.json();
|
||||||
|
const accessToken = data.access_token;
|
||||||
|
|
||||||
|
// Fetch user info to get a nice display name
|
||||||
|
const userRes = await fetch(`${process.env.DIRECTUS_URL}/users/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userRes.ok) {
|
||||||
|
const { data: user } = await userRes.json();
|
||||||
|
userIdentity = user.first_name || user.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Directus Auth Error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIdentity) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(authCookieName, expectedPassword, {
|
// Store identity in the cookie (simplified for now, ideally signed)
|
||||||
|
const sessionValue = JSON.stringify({
|
||||||
|
identity: userIdentity,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
cookieStore.set(authCookieName, sessionValue, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: !isDev,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
@@ -85,24 +140,35 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form action={login} className="space-y-6">
|
<form action={login} className="space-y-4">
|
||||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||||
|
|
||||||
<div className="relative group">
|
<div className="space-y-2">
|
||||||
<input
|
<div className="relative group">
|
||||||
type="password"
|
<input
|
||||||
name="password"
|
type="email"
|
||||||
required
|
name="email"
|
||||||
autoFocus
|
placeholder="EMAIL (OPTIONAL)"
|
||||||
autoComplete="current-password"
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||||
placeholder="GATEKEEPER CODE"
|
/>
|
||||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
</div>
|
||||||
/>
|
|
||||||
|
<div className="relative group">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="ACCESS CODE"
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100"
|
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
Unlock Access
|
Unlock Access
|
||||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ RUN apk add --no-cache libc6-compat curl
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable pnpm
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Copy source (honoring .dockerignore)
|
# Copy manifest files specifically for better layer caching
|
||||||
COPY . .
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||||
|
COPY packages/gatekeeper/package.json ./packages/gatekeeper/package.json
|
||||||
|
COPY packages/next-utils/package.json ./packages/next-utils/package.json
|
||||||
|
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||||
|
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||||
|
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||||
|
|
||||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
|
pnpm config set store-dir /pnpm/store && \
|
||||||
pnpm i --frozen-lockfile
|
pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy the rest of the source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# Build Gatekeeper and its dependencies
|
# Build Gatekeeper and its dependencies
|
||||||
RUN pnpm --filter @mintel/gatekeeper... build
|
RUN pnpm --filter @mintel/gatekeeper... build
|
||||||
RUN mkdir -p packages/gatekeeper/public
|
RUN mkdir -p packages/gatekeeper/public
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
614
pnpm-lock.yaml
generated
614
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user