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:
parent
ca5659f734
commit
34044cdfce
29 changed files with 670 additions and 5 deletions
13
README.md
13
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.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ function writeBaseConfig(configPath: string) {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: { baseDir: "/tmp/paperclip-storage" },
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ function createTempConfig(): string {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ function createExistingConfigFixture() {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
113
cli/src/__tests__/telemetry.test.ts
Normal file
113
cli/src/__tests__/telemetry.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -75,6 +75,9 @@ function buildSourceConfig(): PaperclipConfig {
|
|||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ function defaultConfig(): PaperclipConfig {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
logging,
|
||||
server,
|
||||
auth,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage,
|
||||
secrets,
|
||||
};
|
||||
|
|
@ -501,6 +509,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||
|
||||
writeConfig(config, opts.config);
|
||||
|
||||
if (tc) trackInstallCompleted(tc, {
|
||||
setupMode,
|
||||
dbMode: database.mode,
|
||||
deploymentMode: server.deploymentMode,
|
||||
});
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Database: ${database.mode}`,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
|
|||
40
cli/src/telemetry.ts
Normal file
40
cli/src/telemetry.ts
Normal file
|
|
@ -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<void> {
|
||||
if (client) {
|
||||
await client.flush();
|
||||
}
|
||||
}
|
||||
10
cli/src/version.ts
Normal file
10
cli/src/version.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -611,6 +611,8 @@ export {
|
|||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
secretsLocalEncryptedConfigSchema,
|
||||
telemetryConfigSchema,
|
||||
type TelemetryConfig,
|
||||
type PaperclipConfig,
|
||||
type LlmConfig,
|
||||
type DatabaseBackupConfig,
|
||||
|
|
|
|||
85
packages/shared/src/telemetry/client.ts
Normal file
85
packages/shared/src/telemetry/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/shared/src/telemetry/config.ts
Normal file
25
packages/shared/src/telemetry/config.ts
Normal 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 };
|
||||
}
|
||||
49
packages/shared/src/telemetry/events.ts
Normal file
49
packages/shared/src/telemetry/events.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
17
packages/shared/src/telemetry/index.ts
Normal file
17
packages/shared/src/telemetry/index.ts
Normal 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";
|
||||
31
packages/shared/src/telemetry/state.ts
Normal file
31
packages/shared/src/telemetry/state.ts
Normal 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;
|
||||
}
|
||||
30
packages/shared/src/telemetry/types.ts
Normal file
30
packages/shared/src/telemetry/types.ts
Normal 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";
|
||||
122
server/src/__tests__/issue-telemetry-routes.test.ts
Normal file
122
server/src/__tests__/issue-telemetry-routes.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>) => ({
|
||||
...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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StartedServer> {
|
||||
let config = loadConfig();
|
||||
initTelemetry({ enabled: config.telemetryEnabled });
|
||||
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
29
server/src/telemetry.ts
Normal file
29
server/src/telemetry.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue