blob: c3b33929a4d9b4d7b8d01023156337a016454d13 [file]
import { CountMetric, SummaryMetric, TelemetryErrorOptions } from './types';
import { reportErrorToServer } from './reportErrorToServer';
/**
* @fileoverview This file defines a Telemetry class for sending frontend metrics.
* A singleton instance is exported for application-wide use. These metrics are
* used to track user interactions and performance.
*
* Metrics are buffered on the frontend for 5 seconds before being sent in batches
* to the `/_/fe_telemetry` endpoint. This reduces network traffic. Any pending metrics
* are also sent when the page visibility changes to 'hidden' (e.g., when the user
* navigates away or closes the tab) to prevent data loss.
*
* To add a new counter metric:
* 1. Add the metric name to the `CountMetric` enum in types.ts.
* 2. Call `telemetry.increaseCounter()` with the new metric name and optional tags.
*
* To add a new summary metric:
* 1. Add the metric name to the `SummaryMetric` enum in types.ts.
* 2. Call `telemetry.recordSummary()` with the new metric name, value, and optional tags.
*/
interface FrontendMetric {
metric_name: string;
metric_value: number;
tags: { [key: string]: string };
metric_type: 'counter' | 'summary';
}
export { CountMetric, SummaryMetric, reportErrorToServer, TelemetryErrorOptions };
class Telemetry {
private static readonly BUFFER_FLUSH_INTERVAL_MS = 5000; // 5 seconds
private static readonly MAX_BUFFER_SIZE = 1000; // Max 1000 metrics in buffer
private metricsBuffer: FrontendMetric[] = [];
private timerId: number | null = null;
constructor() {
// When the page visibility changes, flush the buffer. This helps ensure we
// capture metrics before the user navigates away or closes the tab.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (this.timerId) {
clearTimeout(this.timerId);
this.timerId = null;
}
this.sendBufferedMetrics();
}
});
}
// Flushes the metrics buffer by sending the data to the telemetry endpoint.
private async sendBufferedMetrics() {
if (this.metricsBuffer.length === 0) {
return;
}
const metricsToSend = [...this.metricsBuffer];
this.metricsBuffer.length = 0;
try {
const response = await fetch('/_/fe_telemetry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(metricsToSend),
});
if (!response.ok) {
// If the request fails, we log it and queue the metrics back.
this.queueMetrics(metricsToSend);
}
} catch (e) {
console.error(e, 'Failed to send frontend metrics:', metricsToSend);
this.queueMetrics(metricsToSend);
}
}
private queueMetric(metric: FrontendMetric) {
this.queueMetrics([metric]);
}
private queueMetrics(metrics: FrontendMetric[]) {
for (const m of metrics) {
if (this.metricsBuffer.length >= Telemetry.MAX_BUFFER_SIZE) {
console.warn('Frontend metrics buffer full, removing oldest metric to make space.');
this.metricsBuffer.shift(); // Remove the oldest metric (FIFO)
}
this.metricsBuffer.push(m);
}
if (!this.timerId) {
this.timerId = window.setTimeout(() => {
this.sendBufferedMetrics();
this.timerId = null;
}, Telemetry.BUFFER_FLUSH_INTERVAL_MS);
}
}
increaseCounter(metricName: CountMetric, tags = {}) {
this.queueMetric({
metric_name: metricName,
metric_value: 1,
tags: tags,
metric_type: 'counter',
});
}
recordSummary(metricName: SummaryMetric, val: number, tags = {}) {
this.queueMetric({
metric_name: metricName,
metric_value: val,
tags: tags,
metric_type: 'summary',
});
}
/**
* Reports an error message to the server.
* This is a pass-through to the reportErrorToServer function.
*/
reportErrorToServer(errorBody: string, options: TelemetryErrorOptions = {}) {
return reportErrorToServer(errorBody, options);
}
// The following are exposed for testing purposes.
_forTesting = {
reset: () => {
this.metricsBuffer.length = 0;
if (this.timerId) {
clearTimeout(this.timerId);
this.timerId = null;
}
},
getBuffer: () => this.metricsBuffer,
MAX_BUFFER_SIZE: Telemetry.MAX_BUFFER_SIZE,
};
}
export const telemetry = new Telemetry();