feat: implement app-side telemetry sender

Add the shared telemetry sender, wire the CLI/server emit points,
and cover the config and completion behavior with tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-31 08:08:18 -05:00
parent ca5659f734
commit 34044cdfce
29 changed files with 670 additions and 5 deletions

View file

@ -14,6 +14,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./telemetry": "./src/telemetry/index.ts",
"./*": "./src/*.ts"
},
"publishConfig": {
@ -23,6 +24,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./telemetry": {
"types": "./dist/telemetry/index.d.ts",
"import": "./dist/telemetry/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js"

View file

@ -95,6 +95,10 @@ export const secretsConfigSchema = z.object({
}),
});
export const telemetryConfigSchema = z.object({
enabled: z.boolean().default(true),
}).default({});
export const paperclipConfigSchema = z
.object({
$meta: configMetaSchema,
@ -102,6 +106,7 @@ export const paperclipConfigSchema = z
database: databaseConfigSchema,
logging: loggingConfigSchema,
server: serverConfigSchema,
telemetry: telemetryConfigSchema,
auth: authConfigSchema.default({
baseUrlMode: "auto",
disableSignUp: false,
@ -174,5 +179,6 @@ export type StorageS3Config = z.infer<typeof storageS3ConfigSchema>;
export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
export type AuthConfig = z.infer<typeof authConfigSchema>;
export type TelemetryConfig = z.infer<typeof telemetryConfigSchema>;
export type ConfigMeta = z.infer<typeof configMetaSchema>;
export type DatabaseBackupConfig = z.infer<typeof databaseBackupConfigSchema>;

View file

@ -611,6 +611,8 @@ export {
storageLocalDiskConfigSchema,
storageS3ConfigSchema,
secretsLocalEncryptedConfigSchema,
telemetryConfigSchema,
type TelemetryConfig,
type PaperclipConfig,
type LlmConfig,
type DatabaseBackupConfig,

View file

@ -0,0 +1,85 @@
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;
}
}

View file

@ -0,0 +1,25 @@
import type { TelemetryConfig } from "./types.js";
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
function isCI(): boolean {
return CI_ENV_VARS.some((key) => process.env[key] === "true" || process.env[key] === "1");
}
export function resolveTelemetryConfig(fileConfig?: { enabled?: boolean }): TelemetryConfig {
if (process.env.PAPERCLIP_TELEMETRY_DISABLED === "1") {
return { enabled: false };
}
if (process.env.DO_NOT_TRACK === "1") {
return { enabled: false };
}
if (isCI()) {
return { enabled: false };
}
if (fileConfig?.enabled === false) {
return { enabled: false };
}
const endpoint = process.env.PAPERCLIP_TELEMETRY_ENDPOINT || undefined;
return { enabled: true, endpoint };
}

View file

@ -0,0 +1,49 @@
import type { TelemetryClient } from "./client.js";
export function trackInstallStarted(client: TelemetryClient, dims: { setupMode: string }): void {
client.track("install.started", dims);
}
export function trackInstallCompleted(
client: TelemetryClient,
dims: { setupMode: string; dbMode: string; deploymentMode: string },
): void {
client.track("install.completed", dims);
}
export function trackCompanyImported(
client: TelemetryClient,
dims: { sourceType: string; sourceRef: string; isPrivate: boolean },
): void {
const ref = dims.isPrivate ? client.hashPrivateRef(dims.sourceRef) : dims.sourceRef;
client.track("company.imported", {
sourceType: dims.sourceType,
sourceRef: ref,
sourceRefHashed: dims.isPrivate,
});
}
export function trackAgentFirstHeartbeat(
client: TelemetryClient,
dims: { adapterType: string },
): void {
client.track("agent.first_heartbeat", dims);
}
export function trackAgentTaskCompleted(
client: TelemetryClient,
dims: { adapterType: string },
): void {
client.track("agent.task_completed", dims);
}
export function trackErrorHandlerCrash(
client: TelemetryClient,
dims: { errorName: string; route: string; method: string },
): void {
client.track("error.handler_crash", {
errorName: dims.errorName,
route: dims.route,
method: dims.method,
});
}

View file

@ -0,0 +1,17 @@
export { TelemetryClient } from "./client.js";
export { resolveTelemetryConfig } from "./config.js";
export { loadOrCreateState } from "./state.js";
export {
trackInstallStarted,
trackInstallCompleted,
trackCompanyImported,
trackAgentFirstHeartbeat,
trackAgentTaskCompleted,
trackErrorHandlerCrash,
} from "./events.js";
export type {
TelemetryConfig,
TelemetryState,
TelemetryEventEnvelope,
TelemetryEventName,
} from "./types.js";

View file

@ -0,0 +1,31 @@
import { randomUUID, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { TelemetryState } from "./types.js";
export function loadOrCreateState(stateDir: string, version: string): TelemetryState {
const filePath = path.join(stateDir, "state.json");
if (existsSync(filePath)) {
try {
const raw = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as TelemetryState;
if (parsed.installId && parsed.salt) {
return parsed;
}
} catch {
// Corrupted state file — recreate
}
}
const state: TelemetryState = {
installId: randomUUID(),
salt: randomBytes(32).toString("hex"),
createdAt: new Date().toISOString(),
firstSeenVersion: version,
};
mkdirSync(stateDir, { recursive: true });
writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
return state;
}

View file

@ -0,0 +1,30 @@
export interface TelemetryState {
installId: string;
salt: string;
createdAt: string;
firstSeenVersion: string;
}
export interface TelemetryConfig {
enabled: boolean;
endpoint?: string;
}
export interface TelemetryEventEnvelope {
installId: string;
sessionId: string;
event: string;
dimensions: Record<string, string | number | boolean>;
timestamp: string;
version: string;
os: string;
arch: string;
}
export type TelemetryEventName =
| "install.started"
| "install.completed"
| "company.imported"
| "agent.first_heartbeat"
| "agent.task_completed"
| "error.handler_crash";