paperclip/packages/shared/src/telemetry/client.ts

86 lines
2.3 KiB
TypeScript
Raw Normal View History

import os from "node:os";
import { createHash } from "node:crypto";
import type {
TelemetryConfig,
TelemetryEventEnvelope,
TelemetryEventName,
TelemetryState,
} from "./types.js";
const DEFAULT_ENDPOINT = "https://telemetry.paperclip.ing/ingest";
const BATCH_SIZE = 50;
const SEND_TIMEOUT_MS = 5_000;
export class TelemetryClient {
private queue: TelemetryEventEnvelope[] = [];
private readonly config: TelemetryConfig;
private readonly stateFactory: () => TelemetryState;
private readonly version: string;
private readonly sessionId: string;
private state: TelemetryState | null = null;
constructor(config: TelemetryConfig, stateFactory: () => TelemetryState, version: string) {
this.config = config;
this.stateFactory = stateFactory;
this.version = version;
this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
track(eventName: TelemetryEventName, dimensions?: Record<string, string | number | boolean>): void {
if (!this.config.enabled) return;
const state = this.getState();
this.queue.push({
installId: state.installId,
sessionId: this.sessionId,
event: eventName,
dimensions: dimensions ?? {},
timestamp: new Date().toISOString(),
version: this.version,
os: os.platform(),
arch: os.arch(),
});
if (this.queue.length >= BATCH_SIZE) {
void this.flush();
}
}
async flush(): Promise<void> {
if (!this.config.enabled || this.queue.length === 0) return;
const events = this.queue.splice(0);
const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
try {
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }),
signal: controller.signal,
});
} catch {
// Fire-and-forget: silent failure, no retries
} finally {
clearTimeout(timer);
}
}
hashPrivateRef(value: string): string {
const state = this.getState();
return createHash("sha256")
.update(state.salt + value)
.digest("hex")
.slice(0, 16);
}
private getState(): TelemetryState {
if (!this.state) {
this.state = this.stateFactory();
}
return this.state;
}
}