fix: align telemetry client payload and dimensions with backend schema

Restructure the TelemetryClient to send the correct backend envelope
format ({app, schemaVersion, installId, events: [{name, occurredAt, dimensions}]})
instead of the old per-event format. Update all event dimension names
to match the backend registry (agent_role, adapter_type, error_code, etc.).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-31 12:30:15 -05:00
parent f16de6026d
commit 53dbcd185e
9 changed files with 49 additions and 48 deletions

View file

@ -359,7 +359,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
} }
const tc = getTelemetryClient(); const tc = getTelemetryClient();
if (tc) trackInstallStarted(tc, { setupMode }); if (tc) trackInstallStarted(tc);
let llm: PaperclipConfig["llm"] | undefined; let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv(); const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
@ -510,9 +510,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
writeConfig(config, opts.config); writeConfig(config, opts.config);
if (tc) trackInstallCompleted(tc, { if (tc) trackInstallCompleted(tc, {
setupMode, adapterType: server.deploymentMode,
dbMode: database.mode,
deploymentMode: server.deploymentMode,
}); });
p.note( p.note(

View file

@ -1,8 +1,7 @@
import os from "node:os";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { import type {
TelemetryConfig, TelemetryConfig,
TelemetryEventEnvelope, TelemetryEvent,
TelemetryEventName, TelemetryEventName,
TelemetryState, TelemetryState,
} from "./types.js"; } from "./types.js";
@ -12,11 +11,10 @@ const BATCH_SIZE = 50;
const SEND_TIMEOUT_MS = 5_000; const SEND_TIMEOUT_MS = 5_000;
export class TelemetryClient { export class TelemetryClient {
private queue: TelemetryEventEnvelope[] = []; private queue: TelemetryEvent[] = [];
private readonly config: TelemetryConfig; private readonly config: TelemetryConfig;
private readonly stateFactory: () => TelemetryState; private readonly stateFactory: () => TelemetryState;
private readonly version: string; private readonly version: string;
private readonly sessionId: string;
private state: TelemetryState | null = null; private state: TelemetryState | null = null;
private flushInterval: ReturnType<typeof setInterval> | null = null; private flushInterval: ReturnType<typeof setInterval> | null = null;
@ -24,22 +22,16 @@ export class TelemetryClient {
this.config = config; this.config = config;
this.stateFactory = stateFactory; this.stateFactory = stateFactory;
this.version = version; this.version = version;
this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
} }
track(eventName: TelemetryEventName, dimensions?: Record<string, string | number | boolean>): void { track(eventName: TelemetryEventName, dimensions?: Record<string, string | number | boolean>): void {
if (!this.config.enabled) return; if (!this.config.enabled) return;
const state = this.getState(); this.getState(); // ensure state is initialised (side-effect: creates state file on first call)
this.queue.push({ this.queue.push({
installId: state.installId, name: eventName,
sessionId: this.sessionId, occurredAt: new Date().toISOString(),
event: eventName,
dimensions: dimensions ?? {}, dimensions: dimensions ?? {},
timestamp: new Date().toISOString(),
version: this.version,
os: os.platform(),
arch: os.arch(),
}); });
if (this.queue.length >= BATCH_SIZE) { if (this.queue.length >= BATCH_SIZE) {
@ -51,7 +43,10 @@ export class TelemetryClient {
if (!this.config.enabled || this.queue.length === 0) return; if (!this.config.enabled || this.queue.length === 0) return;
const events = this.queue.splice(0); const events = this.queue.splice(0);
const state = this.getState();
const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT; const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT;
const app = this.config.app ?? "paperclip";
const schemaVersion = this.config.schemaVersion ?? "1";
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
@ -59,7 +54,12 @@ export class TelemetryClient {
await fetch(endpoint, { await fetch(endpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }), body: JSON.stringify({
app,
schemaVersion,
installId: state.installId,
events,
}),
signal: controller.signal, signal: controller.signal,
}); });
} catch { } catch {

View file

@ -1,14 +1,14 @@
import type { TelemetryClient } from "./client.js"; import type { TelemetryClient } from "./client.js";
export function trackInstallStarted(client: TelemetryClient, dims: { setupMode: string }): void { export function trackInstallStarted(client: TelemetryClient): void {
client.track("install.started", dims); client.track("install.started");
} }
export function trackInstallCompleted( export function trackInstallCompleted(
client: TelemetryClient, client: TelemetryClient,
dims: { setupMode: string; dbMode: string; deploymentMode: string }, dims: { adapterType: string },
): void { ): void {
client.track("install.completed", dims); client.track("install.completed", { adapter_type: dims.adapterType });
} }
export function trackCompanyImported( export function trackCompanyImported(
@ -17,33 +17,29 @@ export function trackCompanyImported(
): void { ): void {
const ref = dims.isPrivate ? client.hashPrivateRef(dims.sourceRef) : dims.sourceRef; const ref = dims.isPrivate ? client.hashPrivateRef(dims.sourceRef) : dims.sourceRef;
client.track("company.imported", { client.track("company.imported", {
sourceType: dims.sourceType, source_type: dims.sourceType,
sourceRef: ref, source_ref: ref,
sourceRefHashed: dims.isPrivate, source_ref_hashed: dims.isPrivate,
}); });
} }
export function trackAgentFirstHeartbeat( export function trackAgentFirstHeartbeat(
client: TelemetryClient, client: TelemetryClient,
dims: { adapterType: string }, dims: { agentRole: string },
): void { ): void {
client.track("agent.first_heartbeat", dims); client.track("agent.first_heartbeat", { agent_role: dims.agentRole });
} }
export function trackAgentTaskCompleted( export function trackAgentTaskCompleted(
client: TelemetryClient, client: TelemetryClient,
dims: { adapterType: string }, dims: { agentRole: string },
): void { ): void {
client.track("agent.task_completed", dims); client.track("agent.task_completed", { agent_role: dims.agentRole });
} }
export function trackErrorHandlerCrash( export function trackErrorHandlerCrash(
client: TelemetryClient, client: TelemetryClient,
dims: { errorName: string; route: string; method: string }, dims: { errorCode: string },
): void { ): void {
client.track("error.handler_crash", { client.track("error.handler_crash", { error_code: dims.errorCode });
errorName: dims.errorName,
route: dims.route,
method: dims.method,
});
} }

View file

@ -12,6 +12,7 @@ export {
export type { export type {
TelemetryConfig, TelemetryConfig,
TelemetryState, TelemetryState,
TelemetryEvent,
TelemetryEventEnvelope, TelemetryEventEnvelope,
TelemetryEventName, TelemetryEventName,
} from "./types.js"; } from "./types.js";

View file

@ -8,17 +8,23 @@ export interface TelemetryState {
export interface TelemetryConfig { export interface TelemetryConfig {
enabled: boolean; enabled: boolean;
endpoint?: string; endpoint?: string;
app?: string;
schemaVersion?: string;
} }
export interface TelemetryEventEnvelope { /** Per-event object inside the backend envelope */
installId: string; export interface TelemetryEvent {
sessionId: string; name: string;
event: string; occurredAt: string;
dimensions: Record<string, string | number | boolean>; dimensions: Record<string, string | number | boolean>;
timestamp: string; }
version: string;
os: string; /** Full payload sent to the backend ingest endpoint */
arch: string; export interface TelemetryEventEnvelope {
app: string;
schemaVersion: string;
installId: string;
events: TelemetryEvent[];
} }
export type TelemetryEventName = export type TelemetryEventName =

View file

@ -100,7 +100,7 @@ describe("issue telemetry routes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), { expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), {
adapterType: "codex_local", agentRole: "codex_local",
}); });
}); });

View file

@ -47,7 +47,7 @@ export function errorHandler(
err, err,
); );
const tc = getTelemetryClient(); const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorName: err.name, route: req.route?.path ?? req.path, method: req.method }); if (tc) trackErrorHandlerCrash(tc, { errorCode: err.name });
} }
res.status(err.status).json({ res.status(err.status).json({
error: err.message, error: err.message,
@ -72,7 +72,7 @@ export function errorHandler(
); );
const tc = getTelemetryClient(); const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorName: rootError.name, route: req.route?.path ?? req.path, method: req.method }); if (tc) trackErrorHandlerCrash(tc, { errorCode: rootError.name });
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }

View file

@ -1184,7 +1184,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
if (tc && actor.agentId) { if (tc && actor.agentId) {
const actorAgent = await agentsSvc.getById(actor.agentId); const actorAgent = await agentsSvc.getById(actor.agentId);
if (actorAgent) { if (actorAgent) {
trackAgentTaskCompleted(tc, { adapterType: actorAgent.adapterType }); trackAgentTaskCompleted(tc, { agentRole: actorAgent.adapterType });
} }
} }
} }

View file

@ -1832,7 +1832,7 @@ export function heartbeatService(db: Db) {
if (isFirstHeartbeat && updated) { if (isFirstHeartbeat && updated) {
const tc = getTelemetryClient(); const tc = getTelemetryClient();
if (tc) trackAgentFirstHeartbeat(tc, { adapterType: updated.adapterType }); if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.adapterType });
} }
if (updated) { if (updated) {