From de0213ad5d4af00b3d2c697bb866614b0ac7f099 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Mon, 8 Jun 2026 10:50:54 -0400 Subject: [PATCH] feat: add PostHog instance telemetry (#414) * feat: add PostHog instance telemetry Agent-Profile: https://agent-kanban.dev/agents/7bf89fb1be06098c * fix: include node os release in telemetry Agent-Profile: https://agent-kanban.dev/agents/7bf89fb1be06098c --- .dev.vars.example | 4 + server/entry-node.ts | 36 +++++++ server/scheduled-worker.test.ts | 41 ++++++++ server/services/instance-telemetry.test.ts | 83 ++++++++++++++++ server/services/instance-telemetry.ts | 106 +++++++++++++++++++++ workers/scheduled.ts | 33 +++++++ wrangler.toml | 2 +- 7 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 server/services/instance-telemetry.test.ts create mode 100644 server/services/instance-telemetry.ts diff --git a/.dev.vars.example b/.dev.vars.example index de0c70ff..46bf983b 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -10,3 +10,7 @@ TRUSTED_ORIGINS=http://localhost:5185 # Local ZPan Cloud URL. ZPAN_CLOUD_URL=http://localhost:5186 + +# Optional instance telemetry reporting. +ZPAN_POSTHOG_HOST=https://e.zpan.space +ZPAN_POSTHOG_PROJECT_TOKEN= diff --git a/server/entry-node.ts b/server/entry-node.ts index fff1f732..896bb3c8 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -1,3 +1,4 @@ +import { release as osRelease } from 'node:os' import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' @@ -7,11 +8,13 @@ import { buildCloudInstanceInfo } from './licensing/instance-info' import { createLibsqlPlatform } from './platform/libsql' import { createNodePlatform } from './platform/node' import { syncPendingCloudTrafficReports } from './services/cloud-traffic-metering' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry' import { runLicensingRefresh } from './services/licensing-refresh-runner' import { syncPendingRemoteDownloadUsageReports } from './services/remote-download-usage' const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes +const INSTANCE_TELEMETRY_INTERVAL_MS = 12 * 60 * 60 * 1000 // 12 hours const platform = process.env.TURSO_DATABASE_URL ? await createLibsqlPlatform({ @@ -66,3 +69,36 @@ setInterval(() => { void syncPendingCloudTrafficReports({ db: platform.db, cloudBaseUrl }) void syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl }) }, TRAFFIC_SYNC_INTERVAL_MS) + +console.log('instance.telemetry.scheduler.started interval=12h') +setInterval(() => { + void (async () => { + try { + await reportInstanceTelemetry({ + db: platform.db, + config: { + posthogHost: process.env.ZPAN_POSTHOG_HOST, + posthogProjectToken: process.env.ZPAN_POSTHOG_PROJECT_TOKEN, + configuredInstanceId: process.env.ZPAN_INSTANCE_ID, + }, + cron: INSTANCE_TELEMETRY_CRON, + runtime: { + target: 'node/docker', + hostname: configuredTelemetryHostname(), + osPlatform: process.platform, + osArch: process.arch, + osRelease: osRelease(), + }, + }) + } catch (err) { + const code = err instanceof Error ? err.message : String(err) + console.error(`instance.telemetry.error code=${code}`) + } + })() +}, INSTANCE_TELEMETRY_INTERVAL_MS) + +function configuredTelemetryHostname(): string | undefined { + const instanceUrl = configuredPublicOrigin() + if (instanceUrl) return new URL(instanceUrl).hostname + return process.env.HOSTNAME +} diff --git a/server/scheduled-worker.test.ts b/server/scheduled-worker.test.ts index e0f830e9..f71b9277 100644 --- a/server/scheduled-worker.test.ts +++ b/server/scheduled-worker.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { syncPendingCloudTrafficReports } from '../server/services/cloud-traffic-metering' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/services/instance-telemetry' import { runLicensingRefresh } from '../server/services/licensing-refresh-runner' import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage' import { handleScheduled } from '../workers/scheduled' @@ -14,6 +15,11 @@ vi.mock('../server/services/cloud-traffic-metering', () => ({ syncPendingCloudTrafficReports: vi.fn(), })) +vi.mock('../server/services/instance-telemetry', () => ({ + INSTANCE_TELEMETRY_CRON: '0 */12 * * *', + reportInstanceTelemetry: vi.fn(), +})) + vi.mock('../server/services/licensing-refresh-runner', () => ({ runLicensingRefresh: vi.fn(), })) @@ -26,6 +32,7 @@ describe('handleScheduled', () => { beforeEach(() => { vi.mocked(syncPendingCloudTrafficReports).mockReset() vi.mocked(syncPendingRemoteDownloadUsageReports).mockReset() + vi.mocked(reportInstanceTelemetry).mockReset() vi.mocked(runLicensingRefresh).mockReset() }) @@ -38,6 +45,7 @@ describe('handleScheduled', () => { cloudBaseUrl: 'https://cloud.example', }) expect(runLicensingRefresh).not.toHaveBeenCalled() + expect(reportInstanceTelemetry).not.toHaveBeenCalled() }) it('refreshes licensing on the licensing cron only', async () => { @@ -46,5 +54,38 @@ describe('handleScheduled', () => { expect(runLicensingRefresh).toHaveBeenCalledWith('db', 'https://cloud.example') expect(syncPendingCloudTrafficReports).not.toHaveBeenCalled() expect(syncPendingRemoteDownloadUsageReports).not.toHaveBeenCalled() + expect(reportInstanceTelemetry).not.toHaveBeenCalled() + }) + + it('reports instance telemetry on the 12-hour telemetry cron only', async () => { + await handleScheduled( + { cron: INSTANCE_TELEMETRY_CRON }, + { + DB: {} as D1Database, + BETTER_AUTH_URL: 'https://zpan.example', + ZPAN_CLOUD_URL: 'https://cloud.example', + ZPAN_INSTANCE_ID: 'configured-instance', + ZPAN_POSTHOG_HOST: 'https://e.zpan.space', + ZPAN_POSTHOG_PROJECT_TOKEN: 'ph-token', + }, + ) + + expect(reportInstanceTelemetry).toHaveBeenCalledTimes(1) + expect(reportInstanceTelemetry).toHaveBeenCalledWith({ + db: 'db', + config: { + posthogHost: 'https://e.zpan.space', + posthogProjectToken: 'ph-token', + configuredInstanceId: 'configured-instance', + }, + cron: '0 */12 * * *', + runtime: { + target: 'cloudflare-worker', + hostname: 'zpan.example', + }, + }) + expect(runLicensingRefresh).not.toHaveBeenCalled() + expect(syncPendingCloudTrafficReports).not.toHaveBeenCalled() + expect(syncPendingRemoteDownloadUsageReports).not.toHaveBeenCalled() }) }) diff --git a/server/services/instance-telemetry.test.ts b/server/services/instance-telemetry.test.ts new file mode 100644 index 00000000..61a7d826 --- /dev/null +++ b/server/services/instance-telemetry.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getOrCreateInstanceId } from '../licensing/instance-id' +import type { Database } from '../platform/interface' +import { INSTANCE_TELEMETRY_CRON, INSTANCE_TELEMETRY_EVENT, reportInstanceTelemetry } from './instance-telemetry' + +vi.mock('../licensing/instance-id', () => ({ + getOrCreateInstanceId: vi.fn(), +})) + +describe('instance telemetry', () => { + beforeEach(() => { + vi.mocked(getOrCreateInstanceId).mockReset() + }) + + it('does not call PostHog when config is disabled', async () => { + const fetchFn = vi.fn() + + const result = await reportInstanceTelemetry({ + db: {} as Database, + config: { posthogHost: 'https://e.zpan.space' }, + cron: INSTANCE_TELEMETRY_CRON, + runtime: { target: 'cloudflare-worker' }, + fetchFn, + }) + + expect(result).toEqual({ reported: false, reason: 'disabled' }) + expect(fetchFn).not.toHaveBeenCalled() + expect(getOrCreateInstanceId).not.toHaveBeenCalled() + }) + + it('captures the expected PostHog event when config is enabled', async () => { + vi.mocked(getOrCreateInstanceId).mockResolvedValue('inst-1') + const fetchFn = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) + + const result = await reportInstanceTelemetry({ + db: {} as Database, + config: { + posthogHost: 'https://e.zpan.space/', + posthogProjectToken: 'ph-token', + configuredInstanceId: 'configured-inst', + }, + cron: INSTANCE_TELEMETRY_CRON, + runtime: { + target: 'node/docker', + hostname: 'zpan.example', + osPlatform: 'linux', + osArch: 'arm64', + osRelease: '6.8.0', + }, + now: new Date('2026-06-08T12:00:00.000Z'), + fetchFn, + }) + + expect(result).toEqual({ reported: true }) + expect(getOrCreateInstanceId).toHaveBeenCalledWith({}, 'configured-inst') + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(fetchFn).toHaveBeenCalledWith('https://e.zpan.space/capture/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: expect.any(String), + }) + + const body = JSON.parse(fetchFn.mock.calls[0][1].body) + expect(body).toMatchObject({ + api_key: 'ph-token', + event: INSTANCE_TELEMETRY_EVENT, + distinct_id: 'inst-1', + timestamp: '2026-06-08T12:00:00.000Z', + properties: { + instance_id: 'inst-1', + app_version: '0.0.1', + runtime_target: 'node/docker', + hostname: 'zpan.example', + os_platform: 'linux', + os_arch: 'arm64', + os_release: '6.8.0', + cron: INSTANCE_TELEMETRY_CRON, + report_interval: '12h', + reported_at: '2026-06-08T12:00:00.000Z', + }, + }) + }) +}) diff --git a/server/services/instance-telemetry.ts b/server/services/instance-telemetry.ts new file mode 100644 index 00000000..c5fdd032 --- /dev/null +++ b/server/services/instance-telemetry.ts @@ -0,0 +1,106 @@ +import packageJson from '../../package.json' +import { getOrCreateInstanceId } from '../licensing/instance-id' +import type { Database } from '../platform/interface' + +export const INSTANCE_TELEMETRY_CRON = '0 */12 * * *' +export const INSTANCE_TELEMETRY_EVENT = 'zpan instance reported' +export const INSTANCE_TELEMETRY_INTERVAL = '12h' + +export interface InstanceTelemetryConfig { + posthogHost?: string + posthogProjectToken?: string + configuredInstanceId?: string +} + +export interface InstanceTelemetryRuntime { + target: 'cloudflare-worker' | 'node/docker' + hostname?: string + osPlatform?: string + osArch?: string + osRelease?: string +} + +export interface InstanceTelemetryParams { + db: Database + config: InstanceTelemetryConfig + cron: string + runtime: InstanceTelemetryRuntime + now?: Date + fetchFn?: typeof fetch +} + +export interface InstanceTelemetryResult { + reported: boolean + reason?: 'disabled' +} + +interface PostHogCapturePayload { + api_key: string + event: string + distinct_id: string + properties: Record + timestamp: string +} + +export async function reportInstanceTelemetry(params: InstanceTelemetryParams): Promise { + const posthogHost = params.config.posthogHost?.trim() + const posthogProjectToken = params.config.posthogProjectToken?.trim() + if (!posthogHost || !posthogProjectToken) return { reported: false, reason: 'disabled' } + + const instanceId = await getOrCreateInstanceId(params.db, params.config.configuredInstanceId) + const timestamp = (params.now ?? new Date()).toISOString() + const payload = buildPostHogCapturePayload({ + instanceId, + posthogProjectToken, + cron: params.cron, + runtime: params.runtime, + timestamp, + }) + + const res = await (params.fetchFn ?? fetch)(posthogCaptureUrl(posthogHost), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!res.ok) throw new Error(`posthog_capture_failed_${res.status}`) + return { reported: true } +} + +function buildPostHogCapturePayload(params: { + instanceId: string + posthogProjectToken: string + cron: string + runtime: InstanceTelemetryRuntime + timestamp: string +}): PostHogCapturePayload { + const properties: Record = { + instance_id: params.instanceId, + app_version: packageJson.version, + runtime_target: params.runtime.target, + cron: params.cron, + report_interval: INSTANCE_TELEMETRY_INTERVAL, + reported_at: params.timestamp, + } + + addOptionalProperty(properties, 'hostname', params.runtime.hostname) + addOptionalProperty(properties, 'os_platform', params.runtime.osPlatform) + addOptionalProperty(properties, 'os_arch', params.runtime.osArch) + addOptionalProperty(properties, 'os_release', params.runtime.osRelease) + + return { + api_key: params.posthogProjectToken, + event: INSTANCE_TELEMETRY_EVENT, + distinct_id: params.instanceId, + properties, + timestamp: params.timestamp, + } +} + +function addOptionalProperty(properties: Record, key: string, value: string | undefined): void { + if (value) properties[key] = value +} + +function posthogCaptureUrl(host: string): string { + return `${host.replace(/\/+$/, '')}/capture/` +} diff --git a/workers/scheduled.ts b/workers/scheduled.ts index f831dd08..f7cf779f 100644 --- a/workers/scheduled.ts +++ b/workers/scheduled.ts @@ -2,6 +2,7 @@ import { createCloudflarePlatform } from '../server/platform/cloudflare' import { syncPendingCloudTrafficReports } from '../server/services/cloud-traffic-metering' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/services/instance-telemetry' import { runLicensingRefresh } from '../server/services/licensing-refresh-runner' import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage' import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants' @@ -10,7 +11,12 @@ import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants' // The full Env is defined in bootstrap.ts; this avoids circular imports. export interface ScheduledEnv { DB: D1Database + BETTER_AUTH_URL?: string + ZPAN_PUBLIC_ORIGIN?: string ZPAN_CLOUD_URL?: string + ZPAN_INSTANCE_ID?: string + ZPAN_POSTHOG_HOST?: string + ZPAN_POSTHOG_PROJECT_TOKEN?: string [key: string]: unknown } @@ -26,5 +32,32 @@ export async function handleScheduled(event: ScheduledTrigger, env: ScheduledEnv return } + if (event.cron === INSTANCE_TELEMETRY_CRON) { + await reportInstanceTelemetry({ + db: platform.db, + config: { + posthogHost: env.ZPAN_POSTHOG_HOST, + posthogProjectToken: env.ZPAN_POSTHOG_PROJECT_TOKEN, + configuredInstanceId: env.ZPAN_INSTANCE_ID, + }, + cron: event.cron, + runtime: { + target: 'cloudflare-worker', + hostname: configuredHostname(env), + }, + }) + return + } + await runLicensingRefresh(platform.db, cloudBaseUrl) } + +function configuredHostname(env: ScheduledEnv): string | undefined { + const value = env.ZPAN_PUBLIC_ORIGIN ?? env.BETTER_AUTH_URL + if (!value) return undefined + try { + return new URL(value).hostname + } catch { + return undefined + } +} diff --git a/wrangler.toml b/wrangler.toml index f7a6501e..8232e33f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -38,7 +38,7 @@ max_retries = 3 enabled = true [triggers] -crons = ["*/10 * * * *", "0 */6 * * *"] +crons = ["*/10 * * * *", "0 */6 * * *", "0 */12 * * *"] # ---------------------------------------------------------------------------- # Staging environment — used by non-production branch builds.