refactor: move umami and sentry to server side
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m39s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 2m54s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m39s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 2m54s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
This commit is contained in:
@@ -20,7 +20,7 @@ TARGET=development
|
|||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable analytics
|
# Optional: Leave empty to disable analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
|
|||||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||||
MAIL_RECIPIENTS=info@klz-cables.com
|
MAIL_RECIPIENTS=info@klz-cables.com
|
||||||
|
|
||||||
# Strapi
|
|
||||||
STRAPI_DATABASE_NAME=strapi
|
|
||||||
STRAPI_DATABASE_USERNAME=strapi
|
|
||||||
STRAPI_DATABASE_PASSWORD=
|
|
||||||
APP_KEYS=
|
|
||||||
API_TOKEN_SALT=
|
|
||||||
ADMIN_JWT_SECRET=
|
|
||||||
TRANSFER_TOKEN_SALT=
|
|
||||||
JWT_SECRET=
|
|
||||||
|
|
||||||
# Varnish Cache Size (optional)
|
# Varnish Cache Size (optional)
|
||||||
VARNISH_CACHE_SIZE=256m
|
VARNISH_CACHE_SIZE=256m
|
||||||
|
|||||||
@@ -212,16 +212,12 @@ jobs:
|
|||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
|
||||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
|
||||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--pull \
|
--pull \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
|
||||||
--build-arg UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
|
|
||||||
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
||||||
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
||||||
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
||||||
@@ -245,9 +241,9 @@ jobs:
|
|||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
||||||
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
||||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
||||||
@@ -291,7 +287,7 @@ jobs:
|
|||||||
# Generated by CI - $TARGET - $(date -u)
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
LOG_LEVEL=$LOG_LEVEL
|
LOG_LEVEL=$LOG_LEVEL
|
||||||
|
|||||||
@@ -25,17 +25,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# Build-time environment variables for Next.js
|
# Build-time environment variables for Next.js
|
||||||
# These are baked into the client bundle during build
|
# These are baked into the client bundle during build
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ARG UMAMI_API_ENDPOINT
|
|
||||||
ARG UMAMI_SCRIPT_URL
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ENV UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${UMAMI_SCRIPT_URL:-$NEXT_PUBLIC_UMAMI_SCRIPT_URL}}
|
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
|
||||||
|
|||||||
69
app/errors/api/relay/route.ts
Normal file
69
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||||
|
*
|
||||||
|
* This Route Handler receives Sentry envelopes from the client,
|
||||||
|
* injects the correct DSN if needed, and forwards them to the
|
||||||
|
* internal GlitchTip/Sentry instance.
|
||||||
|
*
|
||||||
|
* This hides the real DSN from the client and bypasses ad-blockers
|
||||||
|
* that target Sentry's default ingest endpoints.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envelope = await request.text();
|
||||||
|
|
||||||
|
// Sentry envelopes can contain multiple parts separated by newlines
|
||||||
|
const lines = envelope.split('\n');
|
||||||
|
if (lines.length < 1) {
|
||||||
|
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = JSON.parse(lines[0]);
|
||||||
|
const realDsn = config.errors.glitchtip.dsn;
|
||||||
|
|
||||||
|
if (!realDsn) {
|
||||||
|
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dsnUrl = new URL(realDsn);
|
||||||
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
|
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||||
|
|
||||||
|
logger.debug('Relaying Sentry envelope', {
|
||||||
|
projectId,
|
||||||
|
host: dsnUrl.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(relayUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: envelope,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-sentry-envelope',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to relay Sentry request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/stats/api/send/route.ts
Normal file
73
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Proxy for Umami Analytics.
|
||||||
|
*
|
||||||
|
* This Route Handler receives tracking events from the browser,
|
||||||
|
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||||
|
* internal Umami API endpoint.
|
||||||
|
*
|
||||||
|
* This ensures:
|
||||||
|
* 1. The Website ID is NOT leaked to the client bundle.
|
||||||
|
* 2. The Umami API endpoint is hidden behind our domain.
|
||||||
|
* 3. We have full control over the tracking data.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
// Inject the secret websiteId from server config
|
||||||
|
const websiteId = config.analytics.umami.websiteId;
|
||||||
|
if (!websiteId) {
|
||||||
|
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||||
|
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the enhanced payload with the secret ID
|
||||||
|
const enhancedPayload = {
|
||||||
|
...payload,
|
||||||
|
website: websiteId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||||
|
|
||||||
|
// Log the event (internal only)
|
||||||
|
logger.debug('Forwarding analytics event', {
|
||||||
|
type,
|
||||||
|
url: payload.url,
|
||||||
|
website: websiteId.slice(0, 8) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||||
|
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
return new NextResponse(errorText, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to proxy analytics request', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,19 +8,12 @@ import { getAppServices } from '@/lib/services/create-services';
|
|||||||
* AnalyticsProvider Component
|
* AnalyticsProvider Component
|
||||||
*
|
*
|
||||||
* Automatically tracks pageviews on client-side route changes.
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
* This component should be placed inside your layout to handle navigation events.
|
* This component handles navigation events for the Umami analytics service.
|
||||||
*
|
*
|
||||||
* @param {Object} props - Component props
|
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||||
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
|
* so it's no longer needed as a prop here.
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // In your layout.tsx
|
|
||||||
* const { websiteId } = config.analytics.umami;
|
|
||||||
* <AnalyticsProvider websiteId={websiteId} />
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) {
|
export default function AnalyticsProvider() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
@@ -31,14 +24,12 @@ export default function AnalyticsProvider({ websiteId }: { websiteId?: string })
|
|||||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
// Track pageview with the full URL
|
// Track pageview with the full URL
|
||||||
|
// The service will relay this to our internal proxy which injects the Website ID
|
||||||
services.analytics.trackPageview(url);
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
// Services like logger are already sub-initialized in getAppServices()
|
||||||
console.log('[Umami] Tracked pageview:', url);
|
// so we don't need to log here manually.
|
||||||
}
|
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
if (!websiteId) return null;
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ function createConfig() {
|
|||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
websiteId: env.UMAMI_WEBSITE_ID,
|
||||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
10
lib/env.ts
10
lib/env.ts
@@ -15,7 +15,7 @@ export const envSchema = z
|
|||||||
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||||
|
|
||||||
// Analytics
|
// Analytics
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||||
UMAMI_API_ENDPOINT: z.preprocess(
|
UMAMI_API_ENDPOINT: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default('https://analytics.infra.mintel.me'),
|
z.string().url().default('https://analytics.infra.mintel.me'),
|
||||||
@@ -82,12 +82,8 @@ export function getRawEnv() {
|
|||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID:
|
UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
|
||||||
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID,
|
UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
|
||||||
UMAMI_API_ENDPOINT:
|
|
||||||
process.env.UMAMI_API_ENDPOINT ||
|
|
||||||
process.env.UMAMI_SCRIPT_URL ||
|
|
||||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||||
MAIL_HOST: process.env.MAIL_HOST,
|
MAIL_HOST: process.env.MAIL_HOST,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for UmamiAnalyticsService.
|
* Configuration options for UmamiAnalyticsService.
|
||||||
@@ -18,56 +19,94 @@ export type UmamiAnalyticsServiceOptions = {
|
|||||||
*
|
*
|
||||||
* In the browser, it gathers standard metadata (screen, language, referrer)
|
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||||
* and sends it to the proxied '/stats/api/send' endpoint.
|
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||||
|
* On the server, it sends directly to the internal Umami API.
|
||||||
*/
|
*/
|
||||||
export class UmamiAnalyticsService implements AnalyticsService {
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
private websiteId?: string;
|
private websiteId?: string;
|
||||||
private endpoint: string;
|
private endpoint: string;
|
||||||
|
private logger: LoggerService;
|
||||||
|
|
||||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
constructor(
|
||||||
|
private readonly options: UmamiAnalyticsServiceOptions,
|
||||||
|
logger: LoggerService,
|
||||||
|
) {
|
||||||
this.websiteId = config.analytics.umami.websiteId;
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
|
this.logger = logger.child({ component: 'analytics-umami' });
|
||||||
|
|
||||||
// On server, use the full internal URL; on client, use the proxied path
|
// On server, use the full internal URL; on client, use the proxied path
|
||||||
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
|
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
|
||||||
|
|
||||||
|
this.logger.debug('Umami service initialized', {
|
||||||
|
enabled: this.options.enabled,
|
||||||
|
websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)',
|
||||||
|
endpoint: this.endpoint,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal method to send the payload to Umami API.
|
* Internal method to send the payload to Umami API.
|
||||||
*/
|
*/
|
||||||
private async sendPayload(type: 'event', data: Record<string, any>) {
|
private async sendPayload(type: 'event', data: Record<string, any>) {
|
||||||
if (!this.options.enabled || !this.websiteId) return;
|
if (!this.options.enabled) return;
|
||||||
|
|
||||||
|
// On the client, we don't need the websiteId (it's injected by the server-side proxy handler).
|
||||||
|
// On the server, we need it because we're calling the Umami API directly.
|
||||||
|
const isClient = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
if (!isClient && !this.websiteId) {
|
||||||
|
this.logger.warn('Umami tracking called on server but no Website ID configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
website: this.websiteId,
|
website: this.websiteId,
|
||||||
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
|
hostname: isClient ? window.location.hostname : 'server',
|
||||||
screen:
|
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
|
||||||
typeof window !== 'undefined'
|
language: isClient ? navigator.language : undefined,
|
||||||
? `${window.screen.width}x${window.screen.height}`
|
referrer: isClient ? document.referrer : undefined,
|
||||||
: undefined,
|
|
||||||
language: typeof window !== 'undefined' ? navigator.language : undefined,
|
|
||||||
referrer: typeof window !== 'undefined' ? document.referrer : undefined,
|
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
this.logger.trace('Sending analytics payload', { type, url: data.url });
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, payload }),
|
|
||||||
// Use keepalive for page navigation events to ensure they complete
|
|
||||||
keepalive: true,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
if (!response.ok && process.env.NODE_ENV === 'development') {
|
// Add a timeout to prevent hanging requests
|
||||||
const errorText = await response.text();
|
const controller = new AbortController();
|
||||||
console.warn(`[Umami] API responded with ${response.status}: ${errorText}`);
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': isClient ? navigator.userAgent : 'KLZ-Server',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
keepalive: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger.warn('Umami API responded with error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if ((fetchError as Error).name === 'AbortError') {
|
||||||
|
this.logger.error('Umami request timed out');
|
||||||
|
} else {
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
this.logger.error('Failed to send analytics', {
|
||||||
console.error('[Umami] Failed to send analytics:', error);
|
error: (error as Error).message,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const analytics = config.analytics.umami.enabled
|
const analytics = config.analytics.umami.enabled
|
||||||
? new UmamiAnalyticsService({ enabled: true })
|
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||||
: new NoopAnalyticsService();
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
if (config.analytics.umami.enabled) {
|
if (config.analytics.umami.enabled) {
|
||||||
@@ -55,7 +55,7 @@ export function getServerAppServices(): AppServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
|
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppServices } from './app-services';
|
import { AppServices } from './app-services';
|
||||||
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
||||||
|
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
||||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||||
@@ -100,12 +101,8 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create analytics service (Umami or no-op)
|
// Create analytics service (Umami or no-op)
|
||||||
// Use dynamic import to avoid importing server-only code in client components
|
|
||||||
const analytics = umamiEnabled
|
const analytics = umamiEnabled
|
||||||
? (() => {
|
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||||
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
|
|
||||||
return new UmamiAnalyticsService({ enabled: true });
|
|
||||||
})()
|
|
||||||
: new NoopAnalyticsService();
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
if (umamiEnabled) {
|
if (umamiEnabled) {
|
||||||
@@ -114,9 +111,13 @@ export function getAppServices(): AppServices {
|
|||||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create notification service
|
||||||
|
const notifications = new NoopNotificationService();
|
||||||
|
logger.info('Notification service initialized (noop)');
|
||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true })
|
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
@@ -139,7 +140,6 @@ export function getAppServices(): AppServices {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create and cache the singleton
|
// Create and cache the singleton
|
||||||
const notifications = new NoopNotificationService();
|
|
||||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
logger.info('All application services initialized successfully');
|
logger.info('All application services initialized successfully');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ErrorReportingUser,
|
ErrorReportingUser,
|
||||||
} from './error-reporting-service';
|
} from './error-reporting-service';
|
||||||
import type { NotificationService } from '../notifications/notification-service';
|
import type { NotificationService } from '../notifications/notification-service';
|
||||||
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
|
|
||||||
type SentryLike = typeof Sentry;
|
type SentryLike = typeof Sentry;
|
||||||
|
|
||||||
@@ -14,11 +15,16 @@ export type GlitchtipErrorReportingServiceOptions = {
|
|||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||||
|
private logger: LoggerService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||||
|
logger: LoggerService,
|
||||||
private readonly notifications?: NotificationService,
|
private readonly notifications?: NotificationService,
|
||||||
private readonly sentry: SentryLike = Sentry,
|
private readonly sentry: SentryLike = Sentry,
|
||||||
) {}
|
) {
|
||||||
|
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
||||||
|
}
|
||||||
|
|
||||||
async captureException(error: unknown, context?: Record<string, unknown>) {
|
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||||
if (!this.options.enabled) return undefined;
|
if (!this.options.enabled) return undefined;
|
||||||
|
|||||||
@@ -322,22 +322,9 @@ const nextConfig = {
|
|||||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const umamiUrl = (process.env.UMAMI_API_ENDPOINT || process.env.UMAMI_SCRIPT_URL || process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me');
|
|
||||||
const glitchtipUrl = process.env.SENTRY_DSN
|
|
||||||
? new URL(process.env.SENTRY_DSN).origin
|
|
||||||
: 'https://errors.infra.mintel.me';
|
|
||||||
|
|
||||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
source: '/stats/:path*',
|
|
||||||
destination: `${umamiUrl}/:path*`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/errors/:path*',
|
|
||||||
destination: `${glitchtipUrl}/:path*`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: '/cms/:path*',
|
source: '/cms/:path*',
|
||||||
destination: `${directusUrl}/:path*`,
|
destination: `${directusUrl}/:path*`,
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
const dsn = process.env.SENTRY_DSN;
|
// We use a placeholder DSN on the client because the real DSN is injected
|
||||||
|
// by our server-side relay at /errors/api/relay.
|
||||||
|
// This keeps our environment clean of NEXT_PUBLIC_ variables.
|
||||||
|
const CLIENT_DSN = 'https://public@errors.infra.mintel.me/1';
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn,
|
dsn: CLIENT_DSN,
|
||||||
enabled: Boolean(dsn),
|
// Relay events through our own server to hide the real DSN and bypass ad-blockers
|
||||||
|
tunnel: '/errors/api/relay',
|
||||||
|
|
||||||
|
// Enable even if no DSN is provided, because we have the tunnel
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: 0,
|
||||||
// AdjustingreplaysOnErrorSampleRate to 1.0 to capture 100% of errors with replays if enabled
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user