deploy
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 1m26s

This commit is contained in:
2026-01-30 13:57:36 +01:00
parent 1468a48281
commit 1f57bae339
10 changed files with 488 additions and 71 deletions

View File

@@ -1,22 +1,13 @@
'use client';
import React, { useEffect } from 'react';
import { createPlausibleAnalytics } from '../utils/analytics';
import { useEffect } from 'react';
import { getDefaultAnalytics } from '../utils/analytics';
import { getDefaultErrorTracking } from '../utils/error-tracking';
interface AnalyticsProps {
domain?: string;
scriptUrl?: string;
}
export const Analytics: React.FC<AnalyticsProps> = ({
domain = 'mintel.me',
scriptUrl = 'https://plausible.yourdomain.com/js/script.js'
}) => {
export const Analytics: React.FC = () => {
useEffect(() => {
const analytics = createPlausibleAnalytics({
domain: document.documentElement.lang || domain,
scriptUrl
});
const analytics = getDefaultAnalytics();
const errorTracking = getDefaultErrorTracking();
// Track page load performance
const trackPageLoad = () => {
@@ -58,14 +49,41 @@ export const Analytics: React.FC<AnalyticsProps> = ({
}
};
// Global error handler for error tracking
const handleGlobalError = (event: ErrorEvent) => {
errorTracking.captureException(event.error || event.message);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
errorTracking.captureException(event.reason);
};
window.addEventListener('error', handleGlobalError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
trackPageLoad();
trackOutboundLinks();
const cleanupSearch = trackSearch();
return () => {
if (cleanupSearch) cleanupSearch();
window.removeEventListener('error', handleGlobalError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, [domain, scriptUrl]);
}, []);
return null;
const analytics = getDefaultAnalytics();
const adapter = analytics.getAdapter();
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null;
if (!scriptTag) return null;
// We use dangerouslySetInnerHTML to inject the script tag from the adapter
// This is safe here because the script URLs and IDs come from our own config/env
return (
<div
dangerouslySetInnerHTML={{ __html: scriptTag }}
style={{ display: 'none' }}
/>
);
};

View File

@@ -5,6 +5,7 @@
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
import { PlausibleAdapter } from './plausible-adapter';
import { UmamiAdapter, type UmamiConfig } from './umami-adapter';
export class AnalyticsService {
private adapter: AnalyticsAdapter;
@@ -65,20 +66,33 @@ export class AnalyticsService {
}
}
// Factory function
// Factory functions
export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService {
return new AnalyticsService(new PlausibleAdapter(config));
}
export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService {
return new AnalyticsService(new UmamiAdapter(config));
}
// Default singleton
let defaultAnalytics: AnalyticsService | null = null;
export function getDefaultAnalytics(): AnalyticsService {
if (!defaultAnalytics) {
defaultAnalytics = createPlausibleAnalytics({
domain: 'mintel.me',
scriptUrl: 'https://plausible.yourdomain.com/js/script.js'
});
const provider = process.env.NEXT_PUBLIC_ANALYTICS_PROVIDER || 'plausible';
if (provider === 'umami') {
defaultAnalytics = createUmamiAnalytics({
websiteId: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || '',
hostUrl: process.env.NEXT_PUBLIC_UMAMI_HOST_URL,
});
} else {
defaultAnalytics = createPlausibleAnalytics({
domain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'mintel.me',
scriptUrl: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL || 'https://plausible.yourdomain.com/js/script.js'
});
}
}
return defaultAnalytics;
}

View File

@@ -0,0 +1,54 @@
/**
* Umami Analytics Adapter
* Decoupled implementation
*/
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
export interface UmamiConfig extends AnalyticsConfig {
websiteId: string;
hostUrl?: string;
}
export class UmamiAdapter implements AnalyticsAdapter {
private websiteId: string;
private hostUrl: string;
constructor(config: UmamiConfig) {
this.websiteId = config.websiteId;
this.hostUrl = config.hostUrl || 'https://cloud.umami.is';
}
async track(event: AnalyticsEvent): Promise<void> {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.umami) {
w.umami.track(event.name, event.props);
}
}
async page(path: string, props?: Record<string, any>): Promise<void> {
// Umami tracks pageviews automatically by default,
// but we can manually trigger it if needed.
if (typeof window === 'undefined') return;
const w = window as any;
if (w.umami) {
w.umami.track(props?.name || 'pageview', { url: path, ...props });
}
}
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
// Umami doesn't have a direct 'identify' like Segment,
// but we can track it as an event or session property if supported by the instance.
await this.track({
name: 'identify',
props: { userId, ...traits }
});
}
getScriptTag(): string {
return `<script async src="${this.hostUrl}/script.js" data-website-id="${this.websiteId}"></script>`;
}
}

View File

@@ -0,0 +1,76 @@
/**
* GlitchTip Error Tracking Adapter
* GlitchTip is Sentry-compatible.
*/
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
export class GlitchTipAdapter implements ErrorTrackingAdapter {
private dsn: string;
constructor(config: ErrorTrackingConfig) {
this.dsn = config.dsn;
this.init(config);
}
private init(config: ErrorTrackingConfig) {
if (typeof window === 'undefined') return;
// In a real scenario, we would import @sentry/nextjs or @sentry/browser
// For this implementation, we assume Sentry is available globally or
// we provide the structure that would call the SDK.
const w = window as any;
if (w.Sentry) {
w.Sentry.init({
dsn: this.dsn,
environment: config.environment || 'production',
release: config.release,
debug: config.debug || false,
});
}
}
captureException(error: any, context?: ErrorContext): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.captureException(error, context);
} else {
console.error('[GlitchTip] Exception captured (Sentry not loaded):', error, context);
}
}
captureMessage(message: string, context?: ErrorContext): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.captureMessage(message, context);
} else {
console.log('[GlitchTip] Message captured (Sentry not loaded):', message, context);
}
}
setUser(user: ErrorContext['user']): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.setUser(user);
}
}
setTag(key: string, value: string): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.setTag(key, value);
}
}
setExtra(key: string, value: any): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.setExtra(key, value);
}
}
}

View File

@@ -0,0 +1,61 @@
/**
* Error Tracking Service - Main entry point with DI
*/
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
import { GlitchTipAdapter } from './glitchtip-adapter';
export class ErrorTrackingService {
private adapter: ErrorTrackingAdapter;
constructor(adapter: ErrorTrackingAdapter) {
this.adapter = adapter;
}
captureException(error: any, context?: ErrorContext): void {
this.adapter.captureException(error, context);
}
captureMessage(message: string, context?: ErrorContext): void {
this.adapter.captureMessage(message, context);
}
setUser(user: ErrorContext['user']): void {
this.adapter.setUser(user);
}
setTag(key: string, value: string): void {
this.adapter.setTag(key, value);
}
setExtra(key: string, value: any): void {
this.adapter.setExtra(key, value);
}
}
// Factory function
export function createGlitchTipErrorTracking(config: ErrorTrackingConfig): ErrorTrackingService {
return new ErrorTrackingService(new GlitchTipAdapter(config));
}
// Default singleton
let defaultErrorTracking: ErrorTrackingService | null = null;
export function getDefaultErrorTracking(): ErrorTrackingService {
if (!defaultErrorTracking) {
defaultErrorTracking = createGlitchTipErrorTracking({
dsn: process.env.NEXT_PUBLIC_GLITCHTIP_DSN || '',
environment: process.env.NODE_ENV,
});
}
return defaultErrorTracking;
}
// Convenience functions
export function captureException(error: any, context?: ErrorContext): void {
getDefaultErrorTracking().captureException(error, context);
}
export function captureMessage(message: string, context?: ErrorContext): void {
getDefaultErrorTracking().captureMessage(message, context);
}

View File

@@ -0,0 +1,29 @@
/**
* Error Tracking interfaces - decoupled contracts
*/
export interface ErrorContext {
extra?: Record<string, any>;
tags?: Record<string, string>;
user?: {
id?: string;
email?: string;
username?: string;
};
level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug';
}
export interface ErrorTrackingAdapter {
captureException(error: any, context?: ErrorContext): void;
captureMessage(message: string, context?: ErrorContext): void;
setUser(user: ErrorContext['user']): void;
setTag(key: string, value: string): void;
setExtra(key: string, value: any): void;
}
export interface ErrorTrackingConfig {
dsn: string;
environment?: string;
release?: string;
debug?: boolean;
}