Compare commits

...

3 Commits

Author SHA1 Message Date
ec227d614f feat: implement Umami page speed tracking via Web Vitals
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m25s
Build & Deploy / 🏗️ Build (push) Successful in 3m59s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m55s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 45s
- Add WebVitalsTracker component using useReportWebVitals
- Report LCP, CLS, FID, FCP, TTFB, and INP as Umami events
- Include rating (good/needs-improvement/poor) for meaningful metrics
2026-02-28 19:35:06 +01:00
cb07b739b8 fix: glitchtip performance metrics + cleanup test submissions
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Refactor GlitchtipErrorReportingService to support dynamic DSN and tracesSampleRate
- Enable client-side performance tracing by setting tracesSampleRate: 0.1
- Configure production Mail variables and restart containers on alpha.mintel.me
2026-02-28 19:33:14 +01:00
55e9531698 fix: glitchtip errors (locale, email) + E2E submission cleanup
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Add fallback 'de' locale to toLocaleDateString() to prevent RangeError
- Skip sending emails for submissions from 'testing@mintel.me'
- Update check-forms.ts to automatically delete test submissions via Payload API
- (Manual) Configured MAIL_FROM and MAIL_RECIPIENTS on alpha.mintel.me
2026-02-28 19:31:36 +01:00
10 changed files with 160 additions and 46 deletions

View File

@@ -113,7 +113,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
</Heading> </Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium"> <div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning> <time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale, { {new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -150,7 +150,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
</Heading> </Heading>
<div className="flex items-center gap-6 text-text-primary/80 font-medium"> <div className="flex items-center gap-6 text-text-primary/80 font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning> <time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale, { {new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -198,7 +198,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase"> <div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
<time dateTime={post.frontmatter.date} suppressHydrationWarning> <time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale, { {new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -72,6 +72,7 @@ export async function sendContactFormAction(formData: FormData) {
? `Product Inquiry: ${productName}` ? `Product Inquiry: ${productName}`
: 'New Contact Form Submission'; : 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry'; const confirmationSubject = 'Thank you for your inquiry';
const isTestSubmission = email === 'testing@mintel.me';
try { try {
// 2a. Send notification to Mintel/Client // 2a. Send notification to Mintel/Client
@@ -84,26 +85,30 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
const notificationResult = await sendEmail({ if (!isTestSubmission) {
replyTo: email, const notificationResult = await sendEmail({
subject: notificationSubject, replyTo: email,
html: notificationHtml,
});
if (notificationResult.success) {
logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId,
});
} else {
logger.error('Notification email FAILED', {
error: notificationResult.error,
subject: notificationSubject, subject: notificationSubject,
email, html: notificationHtml,
}); });
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`), if (notificationResult.success) {
{ action: 'sendContactFormAction_notification', email }, logger.info('Notification email sent successfully', {
); messageId: notificationResult.messageId,
});
} else {
logger.error('Notification email FAILED', {
error: notificationResult.error,
subject: notificationSubject,
email,
});
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`),
{ action: 'sendContactFormAction_notification', email },
);
}
} else {
logger.info('Skipping notification email for test submission', { email });
} }
// 2b. Send confirmation to Customer (branded as KLZ Cables) // 2b. Send confirmation to Customer (branded as KLZ Cables)
@@ -115,26 +120,30 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
const confirmationResult = await sendEmail({ if (!isTestSubmission) {
to: email, const confirmationResult = await sendEmail({
subject: confirmationSubject,
html: confirmationHtml,
});
if (confirmationResult.success) {
logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId,
});
} else {
logger.error('Confirmation email FAILED', {
error: confirmationResult.error,
subject: confirmationSubject,
to: email, to: email,
subject: confirmationSubject,
html: confirmationHtml,
}); });
services.errors.captureException(
new Error(`Confirmation email failed: ${confirmationResult.error}`), if (confirmationResult.success) {
{ action: 'sendContactFormAction_confirmation', email }, logger.info('Confirmation email sent successfully', {
); messageId: confirmationResult.messageId,
});
} else {
logger.error('Confirmation email FAILED', {
error: confirmationResult.error,
subject: confirmationSubject,
to: email,
});
services.errors.captureException(
new Error(`Confirmation email failed: ${confirmationResult.error}`),
{ action: 'sendContactFormAction_confirmation', email },
);
}
} else {
logger.info('Skipping confirmation email for test submission', { email });
} }
// Notify via Gotify (Internal) // Notify via Gotify (Internal)

View File

@@ -9,6 +9,9 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), { const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false, ssr: false,
}); });
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
ssr: false,
});
export default function AnalyticsShell() { export default function AnalyticsShell() {
const [shouldLoad, setShouldLoad] = useState(false); const [shouldLoad, setShouldLoad] = useState(false);
@@ -34,6 +37,7 @@ export default function AnalyticsShell() {
<Suspense fallback={null}> <Suspense fallback={null}>
<DynamicAnalyticsProvider /> <DynamicAnalyticsProvider />
<DynamicScrollDepthTracker /> <DynamicScrollDepthTracker />
<DynamicWebVitalsTracker />
</Suspense> </Suspense>
); );
} }

View File

@@ -0,0 +1,54 @@
'use client';
import { useReportWebVitals } from 'next/web-vitals';
import { useAnalytics } from './useAnalytics';
/**
* WebVitalsTracker component.
*
* Captures Next.js Web Vitals and reports them to Umami as custom events.
* This provides "meaningful" page speed tracking by measuring real user
* experiences (LCP, CLS, INP, etc.).
*/
export default function WebVitalsTracker() {
const { trackEvent } = useAnalytics();
useReportWebVitals((metric) => {
const { name, value, id, label } = metric;
// Determine rating (simplified version of web-vitals standards)
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
if (name === 'LCP') {
if (value > 4000) rating = 'poor';
else if (value > 2500) rating = 'needs-improvement';
} else if (name === 'CLS') {
if (value > 0.25) rating = 'poor';
else if (value > 0.1) rating = 'needs-improvement';
} else if (name === 'FID') {
if (value > 300) rating = 'poor';
else if (value > 100) rating = 'needs-improvement';
} else if (name === 'FCP') {
if (value > 3000) rating = 'poor';
else if (value > 1800) rating = 'needs-improvement';
} else if (name === 'TTFB') {
if (value > 1500) rating = 'poor';
else if (value > 800) rating = 'needs-improvement';
} else if (name === 'INP') {
if (value > 500) rating = 'poor';
else if (value > 200) rating = 'needs-improvement';
}
// Report to Umami
trackEvent('web-vital', {
metric: name,
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
rating,
id,
label,
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
});
});
return null;
}

View File

@@ -74,7 +74,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
suppressHydrationWarning suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md" className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
> >
{new Date(post.frontmatter.date).toLocaleDateString(locale, { {new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -65,7 +65,15 @@ export function getServerAppServices(): AppServices {
} }
const errors = config.errors.glitchtip.enabled const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications) ? new GlitchtipErrorReportingService(
{
enabled: true,
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 1.0, // Server-side we usually want higher visibility
},
logger,
notifications,
)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (config.errors.glitchtip.enabled) { if (config.errors.glitchtip.enabled) {

View File

@@ -69,7 +69,15 @@ export function getAppServices(): AppServices {
// 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 }, logger, notifications) ? new GlitchtipErrorReportingService(
{
enabled: true,
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 0.1, // Default to 10% sampling
},
logger,
notifications,
)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (sentryEnabled) { if (sentryEnabled) {

View File

@@ -8,6 +8,8 @@ import type { LoggerService } from '../logging/logger-service';
export type GlitchtipErrorReportingServiceOptions = { export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean; enabled: boolean;
dsn?: string;
tracesSampleRate?: number;
}; };
// 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.
@@ -46,12 +48,12 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
if (!this.sentryPromise) { if (!this.sentryPromise) {
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => { this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
// Client-side initialization must happen here since sentry.client.config.ts is empty // Client-side initialization must happen here since sentry.client.config.ts is empty
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') { if (typeof window !== 'undefined' && this.options.enabled) {
Sentry.init({ Sentry.init({
dsn: 'https://public@errors.infra.mintel.me/1', dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1',
tunnel: '/errors/api/relay', tunnel: '/errors/api/relay',
enabled: true, enabled: true,
tracesSampleRate: 0, tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
}); });

View File

@@ -168,9 +168,38 @@ async function main() {
hasErrors = true; hasErrors = true;
} }
// 5. Cleanup: Delete test submissions from Payload CMS
console.log(`\n🧹 Starting cleanup of test submissions...`);
try {
const apiUrl = `${targetUrl.replace(/\/$/, '')}/api/form-submissions`;
const searchUrl = `${apiUrl}?where[email][equals]=testing@mintel.me`;
// Fetch test submissions
const searchResponse = await axios.get(searchUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
const testSubmissions = searchResponse.data.docs || [];
console.log(` Found ${testSubmissions.length} test submissions to clean up.`);
for (const doc of testSubmissions) {
try {
await axios.delete(`${apiUrl}/${doc.id}`, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
console.log(` ✅ Deleted submission: ${doc.id}`);
} catch (delErr: any) {
console.error(` ❌ Failed to delete submission ${doc.id}: ${delErr.message}`);
}
}
} catch (err: any) {
console.error(`❌ Cleanup failed: ${err.message}`);
// Don't mark the whole test as failed just because cleanup failed
}
await browser.close(); await browser.close();
// 5. Evaluation // 6. Evaluation
if (hasErrors) { if (hasErrors) {
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`); console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
process.exit(1); process.exit(1);