feat: Integrate Grafana Loki

This commit is contained in:
gpt-engineer-app[bot]
2025-10-30 15:54:32 +00:00
parent 8ac61e01e3
commit 72a7cb7f7c
9 changed files with 1261 additions and 2 deletions

View File

@@ -0,0 +1,267 @@
/**
* 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<string, string>;
values: Array<[string, string]>;
}
interface LokiPushRequest {
streams: LokiStream[];
}
interface LokiReporterOptions {
lokiUrl?: string;
username?: string;
password?: string;
batchSize?: number;
flushInterval?: number;
labels?: Record<string, string>;
}
/**
* 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<string, string>;
private testStartTime?: number;
constructor(options: LokiReporterOptions = {}) {
this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100';
this.batchSize = options.batchSize || 10;
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
this.labels = {
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);
}
}
/**
* Log a message to Loki
*/
private async log(data: Record<string, any>, extraLabels: Record<string, string> = {}) {
const timestamp = Date.now() * 1000000; // Convert to nanoseconds
const stream: LokiStream = {
stream: {
...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
*/
private async flush() {
if (this.buffer.length === 0) {
return;
}
const payload: LokiPushRequest = {
streams: this.buffer,
};
this.buffer = [];
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
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) {
console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error(`Response: ${errorText}`);
}
} catch (error) {
console.error('Error sending logs to Loki:', error);
// Re-add to buffer to retry
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;
}
}