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

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();
}
}