This commit is contained in:
146
.gitea/workflows/deploy.yml
Normal file
146
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
name: Build & Deploy Mintel Blog
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📋 Log Workflow Start
|
||||||
|
run: |
|
||||||
|
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
|
||||||
|
echo " • Commit: ${{ github.sha }}"
|
||||||
|
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🔐 Login to private registry
|
||||||
|
run: |
|
||||||
|
echo "🔐 Authenticating with registry.infra.mintel.me..."
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: 🏗️ Build Docker image
|
||||||
|
run: |
|
||||||
|
echo "🏗️ Building Docker image (linux/arm64)..."
|
||||||
|
docker buildx build \
|
||||||
|
--pull \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--build-arg NEXT_PUBLIC_ANALYTICS_PROVIDER="${{ secrets.NEXT_PUBLIC_ANALYTICS_PROVIDER }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_UMAMI_HOST_URL="${{ secrets.NEXT_PUBLIC_UMAMI_HOST_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_PLAUSIBLE_DOMAIN="${{ secrets.NEXT_PUBLIC_PLAUSIBLE_DOMAIN }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_GLITCHTIP_DSN="${{ secrets.NEXT_PUBLIC_GLITCHTIP_DSN }}" \
|
||||||
|
-t registry.infra.mintel.me/mintel/mintel.me:latest \
|
||||||
|
--push -f docker/Dockerfile .
|
||||||
|
|
||||||
|
- name: 🚀 Deploy to production server
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deploying to alpha.mintel.me..."
|
||||||
|
|
||||||
|
# Setup SSH
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Create .env file content
|
||||||
|
cat > /tmp/mintel.me.env << EOF
|
||||||
|
# ============================================================================
|
||||||
|
# Mintel Blog - Production Environment Configuration
|
||||||
|
# ============================================================================
|
||||||
|
# Auto-generated by CI/CD workflow
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
DOMAIN=mintel.me
|
||||||
|
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
NEXT_PUBLIC_ANALYTICS_PROVIDER=${{ secrets.NEXT_PUBLIC_ANALYTICS_PROVIDER }}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
|
NEXT_PUBLIC_UMAMI_HOST_URL=${{ secrets.NEXT_PUBLIC_UMAMI_HOST_URL }}
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${{ secrets.NEXT_PUBLIC_PLAUSIBLE_DOMAIN }}
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL }}
|
||||||
|
|
||||||
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
|
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.NEXT_PUBLIC_GLITCHTIP_DSN }}
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Upload .env and deploy
|
||||||
|
scp -o StrictHostKeyChecking=accept-new /tmp/mintel.me.env root@alpha.mintel.me:/home/deploy/sites/mintel.me/.env
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
|
||||||
|
set -e
|
||||||
|
cd /home/deploy/sites/mintel.me
|
||||||
|
|
||||||
|
chmod 600 .env
|
||||||
|
chown deploy:deploy .env
|
||||||
|
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
docker pull registry.infra.mintel.me/mintel/mintel.me:latest
|
||||||
|
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo "🚀 Starting containers..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo "⏳ Giving the app a few seconds to warm up..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo "🔍 Checking container status..."
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
if ! docker-compose ps | grep -q "Up"; then
|
||||||
|
echo "❌ Container failed to start"
|
||||||
|
docker-compose logs --tail=100
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f /tmp/mintel.me.env
|
||||||
|
|
||||||
|
- name: 📊 Workflow Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "📊 Status: ${{ job.status }}"
|
||||||
|
echo "🎯 Target: alpha.mintel.me"
|
||||||
|
|
||||||
|
- name: 🔔 Gotify Notification (Success)
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "Sending success notification to Gotify..."
|
||||||
|
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
||||||
|
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
|
||||||
|
|
||||||
|
Commit: ${{ github.sha }}
|
||||||
|
Actor: ${{ github.actor }}
|
||||||
|
Run ID: ${{ github.run_id }}" \
|
||||||
|
-F "priority=5"
|
||||||
|
|
||||||
|
- name: 🔔 Gotify Notification (Failure)
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "Sending failure notification to Gotify..."
|
||||||
|
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
||||||
|
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
|
||||||
|
|
||||||
|
Commit: ${{ github.sha }}
|
||||||
|
Actor: ${{ github.actor }}
|
||||||
|
Run ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
Please check the logs for details." \
|
||||||
|
-F "priority=8"
|
||||||
@@ -1,35 +1,41 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Main website - Nginx serving static Astro build
|
# Main website - Next.js standalone
|
||||||
website:
|
website:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_ANALYTICS_PROVIDER=${NEXT_PUBLIC_ANALYTICS_PROVIDER}
|
||||||
|
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
|
- NEXT_PUBLIC_UMAMI_HOST_URL=${NEXT_PUBLIC_UMAMI_HOST_URL}
|
||||||
|
- NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||||
|
- NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL}
|
||||||
|
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
|
||||||
container_name: mintel-website
|
container_name: mintel-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NGINX_HOST=${DOMAIN:-localhost}
|
- NODE_ENV=production
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- NEXT_PUBLIC_ANALYTICS_PROVIDER=${NEXT_PUBLIC_ANALYTICS_PROVIDER}
|
||||||
|
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
|
- NEXT_PUBLIC_UMAMI_HOST_URL=${NEXT_PUBLIC_UMAMI_HOST_URL}
|
||||||
|
- NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||||
|
- NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL}
|
||||||
|
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# Redis cache for performance
|
# Redis cache for performance
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: mintel-redis
|
container_name: mintel-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
@@ -61,23 +67,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
# Plausible Analytics (if you want to run it alongside)
|
|
||||||
# Uncomment if you need to spin up a new Plausible instance
|
|
||||||
# plausible:
|
|
||||||
# image: plausible/analytics:v2.0
|
|
||||||
# container_name: mintel-plausible
|
|
||||||
# restart: unless-stopped
|
|
||||||
# ports:
|
|
||||||
# - "8081:8000"
|
|
||||||
# environment:
|
|
||||||
# - BASE_URL=https://analytics.${DOMAIN}
|
|
||||||
# - SECRET_KEY_BASE=${PLAUSIBLE_SECRET}
|
|
||||||
# depends_on:
|
|
||||||
# - postgres
|
|
||||||
# - clickhouse
|
|
||||||
# networks:
|
|
||||||
# - app-network
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
@@ -85,4 +74,4 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1,34 +1,63 @@
|
|||||||
# Multi-stage build for optimized Astro static site
|
# Multi-stage build for Next.js
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# 1. Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
# Install dependencies
|
# 2. Rebuild the source code only when needed
|
||||||
RUN npm ci --only=production
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
# Copy source code
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the Astro site
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Build arguments for environment variables needed at build time
|
||||||
|
ARG NEXT_PUBLIC_ANALYTICS_PROVIDER
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_HOST_URL
|
||||||
|
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||||
|
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL
|
||||||
|
ARG NEXT_PUBLIC_GLITCHTIP_DSN
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage - Nginx for serving static files
|
# 3. Production image, copy all the files and run next
|
||||||
FROM nginx:alpine
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Remove default nginx static assets
|
ENV NODE_ENV production
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
# Copy built assets from builder stage
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy custom nginx config
|
COPY --from=builder /app/public ./public
|
||||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Expose port
|
# Set the correct permission for prune cache
|
||||||
EXPOSE 80
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
# Start nginx
|
# Automatically leverage output traces to reduce image size
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { createPlausibleAnalytics } from '../utils/analytics';
|
import { getDefaultAnalytics } from '../utils/analytics';
|
||||||
|
import { getDefaultErrorTracking } from '../utils/error-tracking';
|
||||||
|
|
||||||
interface AnalyticsProps {
|
export const Analytics: React.FC = () => {
|
||||||
domain?: string;
|
|
||||||
scriptUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Analytics: React.FC<AnalyticsProps> = ({
|
|
||||||
domain = 'mintel.me',
|
|
||||||
scriptUrl = 'https://plausible.yourdomain.com/js/script.js'
|
|
||||||
}) => {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const analytics = createPlausibleAnalytics({
|
const analytics = getDefaultAnalytics();
|
||||||
domain: document.documentElement.lang || domain,
|
const errorTracking = getDefaultErrorTracking();
|
||||||
scriptUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track page load performance
|
// Track page load performance
|
||||||
const trackPageLoad = () => {
|
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();
|
trackPageLoad();
|
||||||
trackOutboundLinks();
|
trackOutboundLinks();
|
||||||
const cleanupSearch = trackSearch();
|
const cleanupSearch = trackSearch();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (cleanupSearch) cleanupSearch();
|
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' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
||||||
import { PlausibleAdapter } from './plausible-adapter';
|
import { PlausibleAdapter } from './plausible-adapter';
|
||||||
|
import { UmamiAdapter, type UmamiConfig } from './umami-adapter';
|
||||||
|
|
||||||
export class AnalyticsService {
|
export class AnalyticsService {
|
||||||
private adapter: AnalyticsAdapter;
|
private adapter: AnalyticsAdapter;
|
||||||
@@ -65,20 +66,33 @@ export class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factory function
|
// Factory functions
|
||||||
export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService {
|
export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService {
|
||||||
return new AnalyticsService(new PlausibleAdapter(config));
|
return new AnalyticsService(new PlausibleAdapter(config));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService {
|
||||||
|
return new AnalyticsService(new UmamiAdapter(config));
|
||||||
|
}
|
||||||
|
|
||||||
// Default singleton
|
// Default singleton
|
||||||
let defaultAnalytics: AnalyticsService | null = null;
|
let defaultAnalytics: AnalyticsService | null = null;
|
||||||
|
|
||||||
export function getDefaultAnalytics(): AnalyticsService {
|
export function getDefaultAnalytics(): AnalyticsService {
|
||||||
if (!defaultAnalytics) {
|
if (!defaultAnalytics) {
|
||||||
defaultAnalytics = createPlausibleAnalytics({
|
const provider = process.env.NEXT_PUBLIC_ANALYTICS_PROVIDER || 'plausible';
|
||||||
domain: 'mintel.me',
|
|
||||||
scriptUrl: 'https://plausible.yourdomain.com/js/script.js'
|
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;
|
return defaultAnalytics;
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/utils/analytics/umami-adapter.ts
Normal file
54
src/utils/analytics/umami-adapter.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/utils/error-tracking/glitchtip-adapter.ts
Normal file
76
src/utils/error-tracking/glitchtip-adapter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/utils/error-tracking/index.ts
Normal file
61
src/utils/error-tracking/index.ts
Normal 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);
|
||||||
|
}
|
||||||
29
src/utils/error-tracking/interfaces.ts
Normal file
29
src/utils/error-tracking/interfaces.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user