mirror of
https://github.com/documenso/documenso.git
synced 2025-11-26 14:34:05 +10:00
chore: telemetry (#2240)
This commit is contained in:
190
packages/lib/server-only/telemetry/telemetry-client.ts
Normal file
190
packages/lib/server-only/telemetry/telemetry-client.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/* eslint-disable require-atomic-updates */
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { version } from '../../../../package.json';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { getSiteSetting } from '../site-settings/get-site-setting';
|
||||
import { SITE_SETTINGS_TELEMETRY_ID } from '../site-settings/schemas/telemetry';
|
||||
import { upsertSiteSetting } from '../site-settings/upsert-site-setting';
|
||||
|
||||
const TELEMETRY_KEY = process.env.NEXT_PRIVATE_TELEMETRY_KEY;
|
||||
const TELEMETRY_HOST = process.env.NEXT_PRIVATE_TELEMETRY_HOST;
|
||||
const TELEMETRY_DISABLED = !!process.env.DOCUMENSO_DISABLE_TELEMETRY;
|
||||
|
||||
const NODE_ID_FILENAME = '.documenso-node-id';
|
||||
const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Version is hardcoded to avoid rollup JSON import issues
|
||||
const APP_VERSION = version;
|
||||
|
||||
export class TelemetryClient {
|
||||
private static instance: TelemetryClient | null = null;
|
||||
|
||||
private client: PostHog | null = null;
|
||||
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
private installationId: string | null = null;
|
||||
private nodeId: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Start the telemetry client.
|
||||
*
|
||||
* This will initialize the PostHog client, load or create the installation ID and node ID,
|
||||
* capture a startup event, and start a heartbeat interval.
|
||||
*
|
||||
* If telemetry is disabled via `DOCUMENSO_DISABLE_TELEMETRY=true` or credentials are not
|
||||
* provided, this will be a no-op.
|
||||
*/
|
||||
public static async start(): Promise<void> {
|
||||
if (TELEMETRY_DISABLED) {
|
||||
console.log(
|
||||
'[Telemetry] Telemetry is disabled. To enable, remove the DOCUMENSO_DISABLE_TELEMETRY environment variable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TELEMETRY_KEY || !TELEMETRY_HOST) {
|
||||
console.log('[Telemetry] Telemetry credentials not configured. Telemetry will not be sent.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (TelemetryClient.instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new TelemetryClient();
|
||||
|
||||
TelemetryClient.instance = instance;
|
||||
|
||||
await instance.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the telemetry client.
|
||||
*
|
||||
* This will clear the heartbeat interval and shutdown the PostHog client.
|
||||
*/
|
||||
public static async stop(): Promise<void> {
|
||||
const instance = TelemetryClient.instance;
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (instance.heartbeatInterval) {
|
||||
clearInterval(instance.heartbeatInterval);
|
||||
}
|
||||
|
||||
if (instance.client) {
|
||||
await instance.client.shutdown();
|
||||
}
|
||||
|
||||
TelemetryClient.instance = null;
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
this.client = new PostHog(TELEMETRY_KEY!, {
|
||||
host: TELEMETRY_HOST,
|
||||
disableGeoip: false,
|
||||
});
|
||||
|
||||
// Load or create IDs
|
||||
this.installationId = await this.getOrCreateInstallationId();
|
||||
this.nodeId = await this.getOrCreateNodeId();
|
||||
|
||||
console.log(
|
||||
'[Telemetry] Telemetry is enabled. Documenso collects anonymous usage data to help improve the product.',
|
||||
);
|
||||
console.log(
|
||||
'[Telemetry] We collect: app version, installation ID, and node ID. No personal data, document contents, or user information is collected.',
|
||||
);
|
||||
console.log(
|
||||
'[Telemetry] To disable telemetry, set DOCUMENSO_DISABLE_TELEMETRY=true in your environment variables.',
|
||||
);
|
||||
console.log(
|
||||
'[Telemetry] Learn more: https://documenso.com/docs/developers/self-hosting/telemetry',
|
||||
);
|
||||
|
||||
// Capture startup event
|
||||
this.captureEvent('telemetry_selfhoster_startup');
|
||||
|
||||
// Start heartbeat
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.captureEvent('telemetry_selfhoster_heartbeat');
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private captureEvent(event: string): void {
|
||||
if (!this.client || !this.installationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.capture({
|
||||
distinctId: this.installationId,
|
||||
event,
|
||||
properties: {
|
||||
appVersion: APP_VERSION,
|
||||
installationId: this.installationId,
|
||||
nodeId: this.nodeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getOrCreateInstallationId(): Promise<string> {
|
||||
try {
|
||||
// Try to get from site settings
|
||||
const existing = await getSiteSetting({ id: SITE_SETTINGS_TELEMETRY_ID }).catch(() => null);
|
||||
|
||||
if (existing) {
|
||||
if (existing.data.installationId) {
|
||||
return existing.data.installationId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new installation ID
|
||||
const installationId = prefixedId('installation');
|
||||
|
||||
await upsertSiteSetting({
|
||||
id: SITE_SETTINGS_TELEMETRY_ID,
|
||||
data: { installationId },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return installationId;
|
||||
} catch {
|
||||
// If database is not available, generate a temporary ID
|
||||
return prefixedId('installation');
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateNodeId(): Promise<string | null> {
|
||||
const nodeIdPath = path.join(os.tmpdir(), NODE_ID_FILENAME);
|
||||
|
||||
try {
|
||||
const existingId = await fs.readFile(nodeIdPath, 'utf-8');
|
||||
|
||||
if (existingId.trim()) {
|
||||
return existingId.trim();
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or can't be read, continue to create
|
||||
}
|
||||
|
||||
// Generate new node ID
|
||||
const nodeId = prefixedId('node');
|
||||
|
||||
try {
|
||||
await fs.writeFile(nodeIdPath, nodeId, 'utf-8');
|
||||
} catch {
|
||||
// Read-only filesystem, use memory for nodeId
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user