mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
feat: Integrate Grafana Loki
This commit is contained in:
267
tests/helpers/loki-reporter.ts
Normal file
267
tests/helpers/loki-reporter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user