mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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
|
|
@ -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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue