This commit is contained in:
10
lib/services/analytics/analytics-service.ts
Normal file
10
lib/services/analytics/analytics-service.ts
Normal 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;
|
||||
}
|
||||
|
||||
11
lib/services/analytics/noop-analytics-service.ts
Normal file
11
lib/services/analytics/noop-analytics-service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
32
lib/services/analytics/umami-analytics-service.ts
Normal file
32
lib/services/analytics/umami-analytics-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
lib/services/app-services.ts
Normal file
12
lib/services/app-services.ts
Normal 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
10
lib/services/cache/cache-service.ts
vendored
Normal 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>;
|
||||
}
|
||||
|
||||
30
lib/services/cache/memory-cache-service.ts
vendored
Normal file
30
lib/services/cache/memory-cache-service.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
49
lib/services/cache/redis-cache-service.ts
vendored
Normal file
49
lib/services/cache/redis-cache-service.ts
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
44
lib/services/create-services.ts
Normal file
44
lib/services/create-services.ts
Normal 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;
|
||||
}
|
||||
16
lib/services/errors/error-reporting-service.ts
Normal file
16
lib/services/errors/error-reporting-service.ts
Normal 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;
|
||||
}
|
||||
|
||||
53
lib/services/errors/glitchtip-error-reporting-service.ts
Normal file
53
lib/services/errors/glitchtip-error-reporting-service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
18
lib/services/errors/noop-error-reporting-service.ts
Normal file
18
lib/services/errors/noop-error-reporting-service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user