/** * Custom Playwright Reporter for Grafana Loki * * Streams test events and results to Loki in real-time for centralized logging and monitoring. */ import { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult, TestStep, } from '@playwright/test/reporter'; interface LokiStream { stream: Record; values: Array<[string, string]>; } interface LokiPushRequest { streams: LokiStream[]; } interface LokiReporterOptions { lokiUrl?: string; username?: string; password?: string; batchSize?: number; flushInterval?: number; labels?: Record; } /** * Custom Playwright reporter that sends logs to Grafana Loki */ export default class LokiReporter implements Reporter { private lokiUrl: string; private basicAuth?: string; private batchSize: number; private flushInterval: number; private buffer: LokiStream[] = []; private flushTimer?: NodeJS.Timeout; private labels: Record; private testStartTime?: number; private maxRetries: number = 3; constructor(options: LokiReporterOptions = {}) { this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100'; this.batchSize = options.batchSize || 5; this.flushInterval = options.flushInterval || 5000; // Setup basic auth if credentials provided const username = options.username || process.env.GRAFANA_LOKI_USERNAME; const password = options.password || process.env.GRAFANA_LOKI_PASSWORD; if (username && password) { this.basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); } // Base labels for all logs - sanitize to ensure Grafana Cloud compatibility this.labels = this.sanitizeLabels({ job: 'playwright_tests', workflow: process.env.GITHUB_WORKFLOW || 'local', branch: process.env.GITHUB_REF_NAME || 'local', commit: process.env.GITHUB_SHA || 'local', run_id: process.env.GITHUB_RUN_ID || 'local', ...options.labels, }); // Setup periodic flush this.flushTimer = setInterval(() => this.flush(), this.flushInterval); } /** * Called once before running tests */ async onBegin(config: FullConfig, suite: Suite) { this.testStartTime = Date.now(); const testCount = suite.allTests().length; await this.log({ event: 'test_suite_start', message: `Starting Playwright test suite with ${testCount} tests`, total_tests: testCount, workers: config.workers, }); } /** * Called after a test has been started */ async onTestBegin(test: TestCase) { await this.log({ event: 'test_start', test_name: test.title, test_file: this.getRelativePath(test.location.file), project: test.parent.project()?.name || 'unknown', message: `Test started: ${test.title}`, }, { browser: test.parent.project()?.name || 'unknown', test_file: this.getRelativePath(test.location.file), }); } /** * Called after a test has been finished */ async onTestEnd(test: TestCase, result: TestResult) { const status = result.status; const duration = result.duration; const browser = test.parent.project()?.name || 'unknown'; const testFile = this.getRelativePath(test.location.file); // Determine log message based on status let message = `Test ${status}: ${test.title}`; if (status === 'failed' || status === 'timedOut') { message = `${message} - ${result.error?.message || 'Unknown error'}`; } await this.log({ event: 'test_end', test_name: test.title, test_file: testFile, status, duration_ms: duration, retry: result.retry, message, error: status === 'failed' ? result.error?.message : undefined, error_stack: status === 'failed' ? result.error?.stack : undefined, }, { browser, test_file: testFile, test_name: test.title, status, }); // Log individual test steps for failed tests if (status === 'failed') { for (const step of result.steps) { await this.logStep(test, step, browser, testFile); } } } /** * Log test step details */ private async logStep(test: TestCase, step: TestStep, browser: string, testFile: string) { await this.log({ event: 'test_step', test_name: test.title, step_title: step.title, step_category: step.category, duration_ms: step.duration, error: step.error?.message, message: `Step: ${step.title}`, }, { browser, test_file: testFile, step_category: step.category, }); } /** * Called after all tests have been finished */ async onEnd(result: FullResult) { const duration = this.testStartTime ? Date.now() - this.testStartTime : 0; await this.log({ event: 'test_suite_end', status: result.status, duration_ms: duration, message: `Test suite ${result.status} in ${(duration / 1000).toFixed(2)}s`, }); // Flush remaining logs await this.flush(); // Clear flush timer if (this.flushTimer) { clearInterval(this.flushTimer); } } /** * Sanitize label names to match Grafana Cloud requirements [a-zA-Z_][a-zA-Z0-9_]* */ private sanitizeLabels(labels: Record): Record { const sanitized: Record = {}; for (const [key, value] of Object.entries(labels)) { const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&'); const sanitizedValue = String(value).replace(/[\n\r\t]/g, ' '); sanitized[sanitizedKey] = sanitizedValue; } return sanitized; } /** * Log a message to Loki */ private async log(data: Record, extraLabels: Record = {}) { const timestamp = Date.now() * 1000000; // Convert to nanoseconds const stream: LokiStream = { stream: this.sanitizeLabels({ ...this.labels, ...extraLabels, event: data.event || 'log', }), values: [[timestamp.toString(), JSON.stringify(data)]], }; this.buffer.push(stream); // Flush if buffer is full if (this.buffer.length >= this.batchSize) { await this.flush(); } } /** * Flush buffered logs to Loki with retry logic for Grafana Cloud */ private async flush() { if (this.buffer.length === 0) { return; } const payload: LokiPushRequest = { streams: this.buffer, }; this.buffer = []; // Retry with exponential backoff for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': 'ThrillWiki-Playwright-Tests/1.0', }; if (this.basicAuth) { headers['Authorization'] = `Basic ${this.basicAuth}`; } const response = await fetch(`${this.lokiUrl}/loki/api/v1/push`, { method: 'POST', headers, body: JSON.stringify(payload), }); if (response.ok || response.status === 204) { // Success return; } if (response.status === 401 || response.status === 403) { console.error(`Loki authentication failed: ${response.status}. Check GRAFANA_LOKI_USERNAME and GRAFANA_LOKI_PASSWORD`); return; // Don't retry auth errors } if (response.status === 429) { console.warn(`Loki rate limit hit, retrying... (attempt ${attempt + 1}/${this.maxRetries})`); } else { console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`); const errorText = await response.text(); console.error(`Response: ${errorText}`); } // Don't retry on last attempt if (attempt < this.maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); } } catch (error) { console.error(`Error sending logs to Loki (attempt ${attempt + 1}/${this.maxRetries}):`, error); if (attempt < this.maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); } else { // Re-add to buffer on final failure this.buffer.push(...payload.streams); } } } } /** * Get relative path from project root */ private getRelativePath(filePath: string): string { const cwd = process.cwd(); if (filePath.startsWith(cwd)) { return filePath.substring(cwd.length + 1); } return filePath; } /** * Print summary to console */ printsToStdio() { return false; } }