chore: telemetry (#2240)

This commit is contained in:
Lucas Smith
2025-11-25 16:01:31 +11:00
committed by GitHub
parent 91642ddf0b
commit 11a56f3228
22 changed files with 421 additions and 32 deletions

View File

@@ -48,7 +48,7 @@
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"posthog-js": "^1.297.2",
"posthog-node": "^4.18.0",
"posthog-node": "4.18.0",
"react": "^18",
"react-pdf": "^10.2.0",
"remeda": "^2.32.0",
@@ -63,4 +63,4 @@
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
}
}
}

View File

@@ -1,16 +0,0 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: async (...args) => fetch(...args),
});
}

View File

@@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
import { ZSiteSettingSchema } from './schema';
export const getSiteSetting = async <
T extends TSiteSettingSchema['id'],
U = Extract<TSiteSettingSchema, { id: T }>,
>(options: {
id: T;
}): Promise<U> => {
const { id } = options;
const setting = await prisma.siteSettings.findFirstOrThrow({
where: {
id,
},
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ZSiteSettingSchema.parse(setting) as U;
};

View File

@@ -1,9 +1,12 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
// TODO: Use `z.union([...])` once we have more than one setting
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
export const ZSiteSettingSchema = z.union([
ZSiteSettingsBannerSchema,
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_TELEMETRY_ID = 'telemetry.installation';
export const ZSiteSettingsTelemetrySchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_TELEMETRY_ID),
data: z.object({
installationId: z.string(),
}),
});
export type TSiteSettingsTelemetrySchema = z.infer<typeof ZSiteSettingsTelemetrySchema>;

View File

@@ -1,9 +1,9 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
import { type TSiteSettingSchema } from './schema';
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
userId: number;
userId?: number | null;
};
export const upsertSiteSetting = async ({

View 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;
}
}