From 34044cdfcee2ba6073af06e218648a98194575fd Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 31 Mar 2026 08:08:18 -0500 Subject: [PATCH] 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 --- README.md | 13 ++ cli/src/__tests__/allowed-hostname.test.ts | 3 + cli/src/__tests__/doctor.test.ts | 3 + cli/src/__tests__/onboard.test.ts | 3 + cli/src/__tests__/telemetry.test.ts | 113 ++++++++++++++++ cli/src/__tests__/worktree.test.ts | 3 + cli/src/commands/client/company.ts | 8 ++ cli/src/commands/configure.ts | 3 + cli/src/commands/onboard.ts | 14 ++ cli/src/commands/worktree-lib.ts | 3 + cli/src/index.ts | 26 +++- cli/src/telemetry.ts | 40 ++++++ cli/src/version.ts | 10 ++ packages/shared/package.json | 5 + packages/shared/src/config-schema.ts | 6 + packages/shared/src/index.ts | 2 + packages/shared/src/telemetry/client.ts | 85 ++++++++++++ packages/shared/src/telemetry/config.ts | 25 ++++ packages/shared/src/telemetry/events.ts | 49 +++++++ packages/shared/src/telemetry/index.ts | 17 +++ packages/shared/src/telemetry/state.ts | 31 +++++ packages/shared/src/telemetry/types.ts | 30 +++++ .../__tests__/issue-telemetry-routes.test.ts | 122 ++++++++++++++++++ server/src/config.ts | 2 + server/src/index.ts | 2 + server/src/middleware/error-handler.ts | 7 + server/src/routes/issues.ts | 12 ++ server/src/services/heartbeat.ts | 9 ++ server/src/telemetry.ts | 29 +++++ 29 files changed, 670 insertions(+), 5 deletions(-) create mode 100644 cli/src/__tests__/telemetry.test.ts create mode 100644 cli/src/telemetry.ts create mode 100644 cli/src/version.ts create mode 100644 packages/shared/src/telemetry/client.ts create mode 100644 packages/shared/src/telemetry/config.ts create mode 100644 packages/shared/src/telemetry/events.ts create mode 100644 packages/shared/src/telemetry/index.ts create mode 100644 packages/shared/src/telemetry/state.ts create mode 100644 packages/shared/src/telemetry/types.ts create mode 100644 server/src/__tests__/issue-telemetry-routes.test.ts create mode 100644 server/src/telemetry.ts diff --git a/README.md b/README.md index 42f24cd1a8..c45776e74d 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,19 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) +## Telemetry + +Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent. + +Telemetry is **enabled by default** and can be disabled with any of the following: + +| Method | How | +|---|---| +| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` | +| Standard convention | `DO_NOT_TRACK=1` | +| CI environments | Automatically disabled when `CI=true` | +| Config file | Set `telemetry.enabled: false` in your Paperclip config | + ## Contributing We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details. diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 572689c4d5..8e17e56bfa 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -44,6 +44,9 @@ function writeBaseConfig(configPath: string) { baseUrlMode: "auto", disableSignUp: false, }, + telemetry: { + enabled: true, + }, storage: { provider: "local_disk", localDisk: { baseDir: "/tmp/paperclip-storage" }, diff --git a/cli/src/__tests__/doctor.test.ts b/cli/src/__tests__/doctor.test.ts index 83a678317b..2e1d7d8507 100644 --- a/cli/src/__tests__/doctor.test.ts +++ b/cli/src/__tests__/doctor.test.ts @@ -46,6 +46,9 @@ function createTempConfig(): string { baseUrlMode: "auto", disableSignUp: false, }, + telemetry: { + enabled: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index a5ffe44ad4..df1a91b810 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -44,6 +44,9 @@ function createExistingConfigFixture() { baseUrlMode: "auto", disableSignUp: false, }, + telemetry: { + enabled: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/__tests__/telemetry.test.ts b/cli/src/__tests__/telemetry.test.ts new file mode 100644 index 0000000000..705ad9494f --- /dev/null +++ b/cli/src/__tests__/telemetry.test.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const ORIGINAL_ENV = { ...process.env }; + +function makeConfigPath(root: string, enabled: boolean): string { + const configPath = path.join(root, ".paperclip", "config.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + $meta: { + version: 1, + updatedAt: "2026-03-31T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(root, "runtime", "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(root, "runtime", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(root, "runtime", "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + telemetry: { + enabled, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(root, "runtime", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(root, "runtime", "secrets", "master.key"), + }, + }, + }, null, 2)); + return configPath; +} + +describe("cli telemetry", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true }))); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("respects telemetry.enabled=false from the config file", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-")); + const configPath = makeConfigPath(root, false); + process.env.PAPERCLIP_HOME = path.join(root, "home"); + process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test"; + + const { initTelemetryFromConfigFile } = await import("../telemetry.js"); + const client = initTelemetryFromConfigFile(configPath); + + expect(client).toBeNull(); + expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false); + }); + + it("creates telemetry state only after the first event is tracked", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-")); + process.env.PAPERCLIP_HOME = path.join(root, "home"); + process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test"; + + const { initTelemetry, flushTelemetry } = await import("../telemetry.js"); + const client = initTelemetry({ enabled: true }); + const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"); + + expect(client).not.toBeNull(); + expect(fs.existsSync(statePath)).toBe(false); + + client!.track("install.started", { setupMode: "quickstart" }); + + expect(fs.existsSync(statePath)).toBe(true); + + await flushTelemetry(); + }); +}); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 3934278709..6f6af963a4 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -75,6 +75,9 @@ function buildSourceConfig(): PaperclipConfig { publicBaseUrl: "http://127.0.0.1:3100", disableSignUp: false, }, + telemetry: { + enabled: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index b7620ec597..7a1027c281 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -12,6 +12,8 @@ import type { CompanyPortabilityPreviewResult, CompanyPortabilityImportResult, } from "@paperclipai/shared"; +import { trackCompanyImported } from "@paperclipai/shared/telemetry"; +import { getTelemetryClient } from "../../telemetry.js"; import { ApiRequestError } from "../../client/http.js"; import { openUrl } from "../../client/board-auth.js"; import { binaryContentTypeByExtension, readZipArchive } from "./zip.js"; @@ -1440,6 +1442,12 @@ export function registerCompanyCommands(program: Command): void { if (!imported) { throw new Error("Import request returned no data."); } + const tc = getTelemetryClient(); + if (tc) { + const isPrivate = sourcePayload.type !== "github"; + const sourceRef = sourcePayload.type === "github" ? sourcePayload.url : from; + trackCompanyImported(tc, { sourceType: sourcePayload.type, sourceRef, isPrivate }); + } let companyUrl: string | undefined; if (!ctx.json) { try { diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 969ead97d4..83ff089be6 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -63,6 +63,9 @@ function defaultConfig(): PaperclipConfig { baseUrlMode: "auto", disableSignUp: false, }, + telemetry: { + enabled: true, + }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), }; diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index d470354f8d..cd7b6fe88b 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -33,6 +33,8 @@ import { } from "../config/home.js"; import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; +import { getTelemetryClient } from "../telemetry.js"; +import { trackInstallStarted, trackInstallCompleted } from "@paperclipai/shared/telemetry"; type SetupMode = "quickstart" | "advanced"; @@ -356,6 +358,9 @@ export async function onboard(opts: OnboardOptions): Promise { setupMode = setupModeChoice as SetupMode; } + const tc = getTelemetryClient(); + if (tc) trackInstallStarted(tc, { setupMode }); + let llm: PaperclipConfig["llm"] | undefined; const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv(); let { @@ -488,6 +493,9 @@ export async function onboard(opts: OnboardOptions): Promise { logging, server, auth, + telemetry: { + enabled: true, + }, storage, secrets, }; @@ -501,6 +509,12 @@ export async function onboard(opts: OnboardOptions): Promise { writeConfig(config, opts.config); + if (tc) trackInstallCompleted(tc, { + setupMode, + dbMode: database.mode, + deploymentMode: server.deploymentMode, + }); + p.note( [ `Database: ${database.mode}`, diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 5249acc29a..d2b6c5f734 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -224,6 +224,9 @@ export function buildWorktreeConfig(input: { ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), disableSignUp: source?.auth.disableSignUp ?? false, }, + telemetry: { + enabled: source?.telemetry?.enabled ?? true, + }, storage: { provider: source?.storage.provider ?? "local_disk", localDisk: { diff --git a/cli/src/index.ts b/cli/src/index.ts index c4e1655ee5..adec4cb1ec 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,9 +18,11 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { registerFeedbackCommands } from "./commands/client/feedback.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { loadPaperclipEnvFile } from "./config/env.js"; +import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; import { registerPluginCommands } from "./commands/client/plugin.js"; import { registerClientAuthCommands } from "./commands/client/auth.js"; +import { cliVersion } from "./version.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -29,7 +31,7 @@ const DATA_DIR_OPTION_HELP = program .name("paperclipai") .description("Paperclip CLI — setup, diagnose, and configure your instance") - .version("0.2.7"); + .version(cliVersion); program.hook("preAction", (_thisCommand, actionCommand) => { const options = actionCommand.optsWithGlobals() as DataDirOptionLike; @@ -39,6 +41,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => { hasContextOption: optionNames.has("context"), }); loadPaperclipEnvFile(options.config); + initTelemetryFromConfigFile(options.config); }); program @@ -156,7 +159,20 @@ auth registerClientAuthCommands(auth); -program.parseAsync().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); +async function main(): Promise { + let failed = false; + try { + await program.parseAsync(); + } catch (err) { + failed = true; + console.error(err instanceof Error ? err.message : String(err)); + } finally { + await flushTelemetry(); + } + + if (failed) { + process.exit(1); + } +} + +void main(); diff --git a/cli/src/telemetry.ts b/cli/src/telemetry.ts new file mode 100644 index 0000000000..0df0e121bc --- /dev/null +++ b/cli/src/telemetry.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { + TelemetryClient, + resolveTelemetryConfig, + loadOrCreateState, +} from "@paperclipai/shared/telemetry"; +import { resolvePaperclipInstanceRoot } from "./config/home.js"; +import { readConfig } from "./config/store.js"; +import { cliVersion } from "./version.js"; + +let client: TelemetryClient | null = null; + +export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null { + if (client) return client; + + const config = resolveTelemetryConfig(fileConfig); + if (!config.enabled) return null; + + const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry"); + client = new TelemetryClient(config, () => loadOrCreateState(stateDir, cliVersion), cliVersion); + return client; +} + +export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null { + try { + return initTelemetry(readConfig(configPath)?.telemetry); + } catch { + return initTelemetry(); + } +} + +export function getTelemetryClient(): TelemetryClient | null { + return client; +} + +export async function flushTelemetry(): Promise { + if (client) { + await client.flush(); + } +} diff --git a/cli/src/version.ts b/cli/src/version.ts new file mode 100644 index 0000000000..7b94c8b35d --- /dev/null +++ b/cli/src/version.ts @@ -0,0 +1,10 @@ +import { createRequire } from "node:module"; + +type PackageJson = { + version?: string; +}; + +const require = createRequire(import.meta.url); +const pkg = require("../package.json") as PackageJson; + +export const cliVersion = pkg.version ?? "0.0.0"; diff --git a/packages/shared/package.json b/packages/shared/package.json index 7aa086254c..5e01b9e482 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index 258131bf2b..48687878c9 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -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; export type SecretsConfig = z.infer; export type SecretsLocalEncryptedConfig = z.infer; export type AuthConfig = z.infer; +export type TelemetryConfig = z.infer; export type ConfigMeta = z.infer; export type DatabaseBackupConfig = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2e997ef383..325ccbdb20 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -611,6 +611,8 @@ export { storageLocalDiskConfigSchema, storageS3ConfigSchema, secretsLocalEncryptedConfigSchema, + telemetryConfigSchema, + type TelemetryConfig, type PaperclipConfig, type LlmConfig, type DatabaseBackupConfig, diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts new file mode 100644 index 0000000000..49f3b60311 --- /dev/null +++ b/packages/shared/src/telemetry/client.ts @@ -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): 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 { + 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; + } +} diff --git a/packages/shared/src/telemetry/config.ts b/packages/shared/src/telemetry/config.ts new file mode 100644 index 0000000000..0e5252fc55 --- /dev/null +++ b/packages/shared/src/telemetry/config.ts @@ -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 }; +} diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts new file mode 100644 index 0000000000..f44f7908a5 --- /dev/null +++ b/packages/shared/src/telemetry/events.ts @@ -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, + }); +} diff --git a/packages/shared/src/telemetry/index.ts b/packages/shared/src/telemetry/index.ts new file mode 100644 index 0000000000..520a56af90 --- /dev/null +++ b/packages/shared/src/telemetry/index.ts @@ -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"; diff --git a/packages/shared/src/telemetry/state.ts b/packages/shared/src/telemetry/state.ts new file mode 100644 index 0000000000..a060af26a8 --- /dev/null +++ b/packages/shared/src/telemetry/state.ts @@ -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; +} diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts new file mode 100644 index 0000000000..f5df091495 --- /dev/null +++ b/packages/shared/src/telemetry/types.ts @@ -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; + 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"; diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts new file mode 100644 index 0000000000..9e6b5fb6dd --- /dev/null +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -0,0 +1,122 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: mockTrackAgentTaskCompleted, +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + goalService: () => ({}), + heartbeatService: () => ({ + reportRunActivity: vi.fn(async () => undefined), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function makeIssue(status: "todo" | "done") { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + status, + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1018", + title: "Telemetry test", + }; +} + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +describe("issue telemetry routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockIssueService.getById.mockResolvedValue(makeIssue("todo")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("todo"), + ...patch, + })); + }); + + it("emits task-completed telemetry with the agent adapter type", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + adapterType: "codex_local", + }); + + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: null, + })) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "done" }); + + expect(res.status).toBe(200); + expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), { + adapterType: "codex_local", + }); + }); + + it("does not emit agent task-completed telemetry for board-driven completions", async () => { + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "done" }); + + expect(res.status).toBe(200); + expect(mockTrackAgentTaskCompleted).not.toHaveBeenCalled(); + expect(mockAgentService.getById).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 3317374604..71084cc097 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -75,6 +75,7 @@ export interface Config { heartbeatSchedulerEnabled: boolean; heartbeatSchedulerIntervalMs: number; companyDeletionEnabled: boolean; + telemetryEnabled: boolean; } export function loadConfig(): Config { @@ -267,5 +268,6 @@ export function loadConfig(): Config { heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), companyDeletionEnabled, + telemetryEnabled: fileConfig?.telemetry?.enabled ?? true, }; } diff --git a/server/src/index.ts b/server/src/index.ts index 23800d9528..2fcbaf17d5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -39,6 +39,7 @@ import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; +import { initTelemetry } from "./telemetry.js"; type BetterAuthSessionUser = { id: string; @@ -79,6 +80,7 @@ export interface StartedServer { export async function startServer(): Promise { let config = loadConfig(); + initTelemetry({ enabled: config.telemetryEnabled }); if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; } diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 7f86dfd08e..d68789ef31 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -1,6 +1,8 @@ import type { Request, Response, NextFunction } from "express"; import { ZodError } from "zod"; import { HttpError } from "../errors.js"; +import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry"; +import { getTelemetryClient } from "../telemetry.js"; export interface ErrorContext { error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown }; @@ -44,6 +46,8 @@ export function errorHandler( { message: err.message, stack: err.stack, name: err.name, details: err.details }, err, ); + const tc = getTelemetryClient(); + if (tc) trackErrorHandlerCrash(tc, { errorName: err.name, route: req.route?.path ?? req.path, method: req.method }); } res.status(err.status).json({ error: err.message, @@ -67,5 +71,8 @@ export function errorHandler( rootError, ); + const tc = getTelemetryClient(); + if (tc) trackErrorHandlerCrash(tc, { errorName: rootError.name, route: req.route?.path ?? req.path, method: req.method }); + res.status(500).json({ error: "Internal server error" }); } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7ecbd6de71..7c031c8b13 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -20,6 +20,8 @@ import { upsertIssueDocumentSchema, updateIssueSchema, } from "@paperclipai/shared"; +import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; +import { getTelemetryClient } from "../telemetry.js"; import type { StorageService } from "../storage/types.js"; import { validate } from "../middleware/validate.js"; import { @@ -1177,6 +1179,16 @@ export function issueRoutes(db: Db, storage: StorageService) { }, }); + if (issue.status === "done" && existing.status !== "done") { + const tc = getTelemetryClient(); + if (tc && actor.agentId) { + const actorAgent = await agentsSvc.getById(actor.agentId); + if (actorAgent) { + trackAgentTaskCompleted(tc, { adapterType: actorAgent.adapterType }); + } + } + } + let comment = null; if (commentBody) { comment = await svc.addComment(id, commentBody, { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ab6c94da16..e64c216c9f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -25,6 +25,8 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; +import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry"; +import { getTelemetryClient } from "../telemetry.js"; import { companySkillService } from "./company-skills.js"; import { budgetService, type BudgetEnforcementScope } from "./budgets.js"; import { secretService } from "./secrets.js"; @@ -1807,6 +1809,8 @@ export function heartbeatService(db: Db) { return; } + const isFirstHeartbeat = !existing.lastHeartbeatAt; + const runningCount = await countRunningRunsForAgent(agentId); const nextStatus = runningCount > 0 @@ -1826,6 +1830,11 @@ export function heartbeatService(db: Db) { .returning() .then((rows) => rows[0] ?? null); + if (isFirstHeartbeat && updated) { + const tc = getTelemetryClient(); + if (tc) trackAgentFirstHeartbeat(tc, { adapterType: updated.adapterType }); + } + if (updated) { publishLiveEvent({ companyId: updated.companyId, diff --git a/server/src/telemetry.ts b/server/src/telemetry.ts new file mode 100644 index 0000000000..f5823a0100 --- /dev/null +++ b/server/src/telemetry.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { + TelemetryClient, + resolveTelemetryConfig, + loadOrCreateState, +} from "@paperclipai/shared/telemetry"; +import { resolvePaperclipInstanceRoot } from "./home-paths.js"; +import { serverVersion } from "./version.js"; + +let client: TelemetryClient | null = null; + +export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null { + if (client) return client; + + const config = resolveTelemetryConfig(fileConfig); + if (!config.enabled) return null; + + const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry"); + client = new TelemetryClient( + config, + () => loadOrCreateState(stateDir, serverVersion), + serverVersion, + ); + return client; +} + +export function getTelemetryClient(): TelemetryClient | null { + return client; +}