umami, glitchtip, redis
Some checks failed
Build & Deploy / deploy (push) Failing after 3m45s

This commit is contained in:
2026-01-18 15:37:51 +01:00
parent 619b699f14
commit b05a21350c
29 changed files with 3568 additions and 316 deletions

5
.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
.next/
node_modules/
reference/
public/
dist/

12
.eslintrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn"
}
}

View File

@@ -38,7 +38,20 @@ SITE_URL=https://klz-cables.com
RESEND_API_KEY=your_resend_key
TURNSTILE_SITE_KEY=your_turnstile_key
TURNSTILE_SECRET_KEY=your_turnstile_secret
VERCEL_ANALYTICS_ID=your_analytics_id
# Umami
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
# GlitchTip (Sentry compatible)
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
# Redis (optional cache)
# Platform provides a shared redis container reachable as `redis`.
# Pick a dedicated DB index per app, e.g. redis://redis:6379/2
REDIS_URL=redis://redis:6379/2
REDIS_KEY_PREFIX=klz:
```
## 📊 Project Overview
@@ -346,4 +359,4 @@ Proprietary - KLZ Cables
**Status**: ✅ **READY FOR DEPLOYMENT**
**Version**: 1.0.0
**Last Updated**: December 27, 2025
**Last Updated**: December 27, 2025

View File

@@ -3,6 +3,8 @@ import {getMessages, getTranslations} from 'next-intl/server';
import '../../styles/globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import UmamiScript from '@/components/analytics/UmamiScript';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import { Metadata, Viewport } from 'next';
export async function generateMetadata({params: {locale}}: {params: {locale: string}}): Promise<Metadata> {
@@ -79,6 +81,12 @@ export default async function LocaleLayout({
</main>
<Footer />
</NextIntlClientProvider>
{/* Loads Umami only when NEXT_PUBLIC_UMAMI_WEBSITE_ID is set */}
<UmamiScript />
{/* Sends pageviews for client-side navigations */}
<AnalyticsProvider />
</body>
</html>
);

View File

@@ -0,0 +1,20 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services';
// Minimal client-side hook that sends Umami pageviews on route changes.
export default function AnalyticsProvider() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
services.analytics.trackPageview(url);
}, [pathname, searchParams]);
return null;
}

View File

@@ -0,0 +1,19 @@
import Script from 'next/script';
export default function UmamiScript() {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
if (!websiteId) return null;
const src =
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ??
'https://analytics.infra.mintel.me/script.js';
return (
<Script
src={src}
data-website-id={websiteId}
strategy="afterInteractive"
/>
);
}

View File

@@ -4,30 +4,44 @@ services:
restart: always
networks:
- infra
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "-L", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Shared platform services are reachable on the `infra` network.
# Pick an app-specific Redis DB index.
environment:
# Umami
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
# GlitchTip (Sentry protocol)
- SENTRY_DSN=${SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN:-${SENTRY_DSN}}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/2}
- REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-klz:}
labels:
- "traefik.enable=true"
# HTTP → HTTPS redirect
- "traefik.http.routers.klz-cables-web.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`)"
# HTTP → HTTPS redirect (ohne ACME-Challenge)
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.klz-cables-web.entrypoints=web"
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
# HTTPS router
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`)"
- "traefik.http.routers.klz-cables.entrypoints=websecure"
- "traefik.http.routers.klz-cables.tls.certresolver=le"
- "traefik.http.routers.klz-cables.tls=true"
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
# compression
- "traefik.http.routers.klz-cables.middlewares=compress"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
# Forwarded Headers (für Apps, die HTTPS erwarten)
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares anhängen
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
networks:
infra:
external: true

View File

@@ -1,36 +0,0 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
{
ignores: [
'.next/**',
'node_modules/**',
'reference/**',
'public/**',
'dist/**',
],
},
...compat.extends('next/core-web-vitals'),
...compat.extends('next/typescript'),
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-require-imports': 'off',
'prefer-const': 'warn',
'react/no-unescaped-entities': 'off',
'@next/next/no-img-element': 'warn',
}
}
];
export default eslintConfig;

17
instrumentation.ts Normal file
View File

@@ -0,0 +1,17 @@
import * as Sentry from '@sentry/nextjs';
// Next.js will call this on boot for the active runtime.
// We dynamically import the correct Sentry config file.
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');
}
}
// Capture errors from Server Components, middleware and route handlers.
export const onRequestError = Sentry.captureRequestError;

View File

@@ -0,0 +1,10 @@
export type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
export interface AnalyticsService {
track(eventName: string, props?: AnalyticsEventProperties): void;
trackPageview(url?: string): void;
}

View File

@@ -0,0 +1,11 @@
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
export class NoopAnalyticsService implements AnalyticsService {
track(_eventName: string, _props?: AnalyticsEventProperties) {
// intentionally noop
}
trackPageview(_url?: string) {
// intentionally noop
}
}

View File

@@ -0,0 +1,32 @@
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
type UmamiGlobal = {
track?: (eventOrUrl: string, props?: AnalyticsEventProperties) => void;
};
export type UmamiAnalyticsServiceOptions = {
enabled: boolean;
};
export class UmamiAnalyticsService implements AnalyticsService {
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
track(eventName: string, props?: AnalyticsEventProperties) {
if (!this.options.enabled) return;
if (typeof window === 'undefined') return;
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
umami?.track?.(eventName, props);
}
trackPageview(url?: string) {
if (!this.options.enabled) return;
if (typeof window === 'undefined') return;
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
// Umami treats `track(url)` as a pageview override.
if (url) umami?.track?.(url);
else umami?.track?.(window.location.pathname + window.location.search);
}
}

View File

@@ -0,0 +1,12 @@
import type { AnalyticsService } from './analytics/analytics-service';
import type { CacheService } from './cache/cache-service';
import type { ErrorReportingService } from './errors/error-reporting-service';
// Simple constructor-based DI container.
export class AppServices {
constructor(
public readonly analytics: AnalyticsService,
public readonly errors: ErrorReportingService,
public readonly cache: CacheService
) {}
}

10
lib/services/cache/cache-service.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
export type CacheSetOptions = {
ttlSeconds?: number;
};
export interface CacheService {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
del(key: string): Promise<void>;
}

View File

@@ -0,0 +1,30 @@
import type { CacheService, CacheSetOptions } from './cache-service';
type Entry = {
value: unknown;
expiresAt?: number;
};
export class MemoryCacheService implements CacheService {
private readonly store = new Map<string, Entry>();
async get<T>(key: string) {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.value as T;
}
async set<T>(key: string, value: T, options?: CacheSetOptions) {
const ttl = options?.ttlSeconds;
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
}
async del(key: string) {
this.store.delete(key);
}
}

View File

@@ -0,0 +1,49 @@
import { createClient, type RedisClientType } from 'redis';
import type { CacheService, CacheSetOptions } from './cache-service';
export type RedisCacheServiceOptions = {
url: string;
keyPrefix?: string;
};
// Thin wrapper around shared Redis (platform provides host `redis`).
// Values are JSON-serialized.
export class RedisCacheService implements CacheService {
private readonly client: RedisClientType;
private readonly keyPrefix: string;
constructor(options: RedisCacheServiceOptions) {
this.client = createClient({ url: options.url });
this.keyPrefix = options.keyPrefix ?? '';
// Fire-and-forget connect.
this.client.connect().catch(() => undefined);
}
private k(key: string) {
return `${this.keyPrefix}${key}`;
}
async get<T>(key: string): Promise<T | undefined> {
const raw = await this.client.get(this.k(key));
if (raw == null) return undefined;
return JSON.parse(raw) as T;
}
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
const ttl = options?.ttlSeconds;
const raw = JSON.stringify(value);
if (ttl && ttl > 0) {
await this.client.set(this.k(key), raw, { EX: ttl });
return;
}
await this.client.set(this.k(key), raw);
}
async del(key: string): Promise<void> {
await this.client.del(this.k(key));
}
}

View File

@@ -0,0 +1,44 @@
import { AppServices } from './app-services';
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
import { MemoryCacheService } from './cache/memory-cache-service';
import { RedisCacheService } from './cache/redis-cache-service';
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
let singleton: AppServices | undefined;
export function getAppServices(): AppServices {
// In Next.js, module singletons are per-process (server) and per-tab (client).
// This is good enough for a small service layer.
if (singleton) return singleton;
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
const sentryClientEnabled = Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN);
const sentryServerEnabled = Boolean(process.env.SENTRY_DSN);
const analytics = umamiEnabled
? new UmamiAnalyticsService({ enabled: true })
: new NoopAnalyticsService();
// Enable GlitchTip/Sentry only when a DSN is present for the active runtime.
const errors =
typeof window === 'undefined'
? sentryServerEnabled
? new GlitchtipErrorReportingService({ enabled: true })
: new NoopErrorReportingService()
: sentryClientEnabled
? new GlitchtipErrorReportingService({ enabled: true })
: new NoopErrorReportingService();
const redisUrl = process.env.REDIS_URL;
const cache = redisUrl
? new RedisCacheService({
url: redisUrl,
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'klz:',
})
: new MemoryCacheService();
singleton = new AppServices(analytics, errors, cache);
return singleton;
}

View File

@@ -0,0 +1,16 @@
export type ErrorReportingUser = {
id?: string;
email?: string;
username?: string;
};
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
export interface ErrorReportingService {
captureException(error: unknown, context?: Record<string, unknown>): string | undefined;
captureMessage(message: string, level?: ErrorReportingLevel): string | undefined;
setUser(user: ErrorReportingUser | null): void;
setTag(key: string, value: string): void;
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
}

View File

@@ -0,0 +1,53 @@
import * as Sentry from '@sentry/nextjs';
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from './error-reporting-service';
type SentryLike = typeof Sentry;
export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean;
};
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor(
private readonly options: GlitchtipErrorReportingServiceOptions,
private readonly sentry: SentryLike = Sentry
) {}
captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined;
return this.sentry.captureException(error, context as any) as any;
}
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
if (!this.options.enabled) return undefined;
return this.sentry.captureMessage(message, level as any) as any;
}
setUser(user: ErrorReportingUser | null) {
if (!this.options.enabled) return;
this.sentry.setUser(user as any);
}
setTag(key: string, value: string) {
if (!this.options.enabled) return;
this.sentry.setTag(key, value);
}
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
if (!this.options.enabled) return fn();
return this.sentry.withScope((scope) => {
if (context) {
for (const [key, value] of Object.entries(context)) {
scope.setExtra(key, value);
}
}
return fn();
});
}
}

View File

@@ -0,0 +1,18 @@
import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser } from './error-reporting-service';
export class NoopErrorReportingService implements ErrorReportingService {
captureException(_error: unknown, _context?: Record<string, unknown>) {
return undefined;
}
captureMessage(_message: string, _level?: ErrorReportingLevel) {
return undefined;
}
setUser(_user: ErrorReportingUser | null) {}
setTag(_key: string, _value: string) {}
withScope<T>(fn: () => T, _context?: Record<string, unknown>) {
return fn();
}
}

View File

@@ -1,7 +1,8 @@
import createNextIntlPlugin from 'next-intl/plugin';
import { withSentryConfig } from '@sentry/nextjs';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
@@ -19,5 +20,21 @@ const nextConfig = {
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
};
export default withNextIntl(nextConfig);
const nextIntlConfig = withNextIntl(nextConfig);
// GlitchTip is Sentry-compatible; we use the Sentry Next.js SDK.
// Source map upload is optional; we keep this config minimal.
export default withSentryConfig(
nextIntlConfig,
{
silent: !process.env.CI,
// Keep bundle size down; remove SDK debug logging.
treeshake: { removeDebugLogging: true },
},
// Sentry Webpack plugin options (not needed unless you upload sourcemaps)
{
// no sourcemap upload by default
authToken: undefined,
}
);

3291
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0",
"@swc/helpers": "^0.5.18",
"@types/cheerio": "^0.22.35",
"axios": "^1.13.2",
@@ -18,6 +19,7 @@
"pdf-lib": "^1.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"redis": "^4.7.1",
"resend": "^3.5.0",
"sharp": "^0.34.5",
"svg-to-pdfkit": "^0.1.8",
@@ -33,8 +35,8 @@
"@types/sharp": "^0.31.1",
"@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.35",
"postcss": "^8.5.6",
"sass": "^1.97.1",
"tailwindcss": "^4.1.18",
@@ -50,7 +52,7 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test": "vitest run --passWithNoTests",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts"
},

10
sentry.client.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0,
});

10
sentry.edge.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0,
});

10
sentry.server.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0,
});

File diff suppressed because one or more lines are too long

22
types/redis.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
// Fallback ambient types for `redis`.
//
// The official `redis` package ships its own types. In some editor setups
// (especially with newer TS + `moduleResolution: bundler`) the TS server may
// temporarily fail to resolve them. This keeps the project compiling.
declare module 'redis' {
export type RedisClientType = {
connect(): Promise<void>;
get(key: string): Promise<string | null>;
set(
key: string,
value: string,
options?: {
EX?: number;
}
): Promise<unknown>;
del(key: string): Promise<number>;
};
export function createClient(options: { url: string }): RedisClientType;
}

43
types/sentry-nextjs.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
// Fallback ambient types for `@sentry/nextjs`.
//
// In case the editor/TS server fails to pick up the package's bundled types
// (common in some TS + moduleResolution combinations), this keeps the repo
// type-safe enough to build.
//
// If your tooling resolves the official types correctly, this file is still
// compatible (it just provides broad typings).
declare module '@sentry/nextjs' {
export type SeverityLevel =
| 'fatal'
| 'error'
| 'warning'
| 'info'
| 'debug'
| 'log';
export type User = {
id?: string;
email?: string;
username?: string;
};
export function init(options: any): void;
export function captureException(exception: any, captureContext?: any): any;
export function captureMessage(message: string, level?: SeverityLevel): any;
export function setUser(user: User | null): void;
export function setTag(key: string, value: string): void;
export function withScope<T>(callback: (scope: any) => T): T;
export const captureRequestError: (...args: any[]) => any;
export const captureRouterTransitionStart: (...args: any[]) => any;
export function replayIntegration(...args: any[]): any;
export function feedbackIntegration(...args: any[]): any;
export function withSentryConfig(
nextConfig: any,
sentryWebpackPluginOptions?: any,
sentryBuildOptions?: any
): any;
}