diff --git a/package.json b/package.json index 4dc0bd4a..d0bd20e0 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "nodemailer": "^8.0.5", "paseto-ts": "^2.0.6", "pdfjs-dist": "^5.6.205", + "posthog-node": "^5.36.4", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 895434ad..a966e743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: pdfjs-dist: specifier: ^5.6.205 version: 5.6.205 + posthog-node: + specifier: ^5.36.4 + version: 5.36.4 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1872,6 +1875,12 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/core@1.30.10': + resolution: {integrity: sha512-R7Z5jDB3ugwfSujMmRd5osPPR6L6BqfcaSNcYOekzRMZ4Jklq74p05xByP09EnUvKXb5czI+RQVCITTWRWuFXw==} + + '@posthog/types@1.382.0': + resolution: {integrity: sha512-iK4OcSgvtmS9FZ9EUpvwlRZmHCLXaZ3+6dbRjkE7q9LL0zHLewxJH84H6uGvCw8aGzxs5rIliZqPHgimTcQEaw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -4761,6 +4770,15 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + posthog-node@5.36.4: + resolution: {integrity: sha512-N+1WiypMHf3SO3NNoXTUFRzX98TuM5w4bDCm8RenYPf0rvX6r8v+yH6IzL1g/Me+wWA5+sfE1JZ6kGV6pWTZyQ==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -6973,6 +6991,12 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@posthog/core@1.30.10': + dependencies: + '@posthog/types': 1.382.0 + + '@posthog/types@1.382.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -10041,6 +10065,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-node@5.36.4: + dependencies: + '@posthog/core': 1.30.10 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 diff --git a/server/services/instance-telemetry.test.ts b/server/services/instance-telemetry.test.ts index 0218a360..553e6f68 100644 --- a/server/services/instance-telemetry.test.ts +++ b/server/services/instance-telemetry.test.ts @@ -3,40 +3,57 @@ import { getOrCreateInstanceId } from '../licensing/instance-id' import type { Database } from '../platform/interface' import { INSTANCE_TELEMETRY_CRON, - INSTANCE_TELEMETRY_ENDPOINT, INSTANCE_TELEMETRY_EVENT, + INSTANCE_TELEMETRY_POSTHOG_HOST, INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, reportInstanceTelemetry, } from './instance-telemetry' +const posthogMocks = vi.hoisted(() => { + const captureImmediateMock = vi.fn() + const shutdownMock = vi.fn() + const PostHog = vi.fn( + class { + captureImmediate = captureImmediateMock + shutdown = shutdownMock + }, + ) + return { PostHog, captureImmediate: captureImmediateMock, shutdown: shutdownMock } +}) + vi.mock('../licensing/instance-id', () => ({ getOrCreateInstanceId: vi.fn(), })) +vi.mock('posthog-node', () => ({ + PostHog: posthogMocks.PostHog, +})) + describe('instance telemetry', () => { beforeEach(() => { vi.mocked(getOrCreateInstanceId).mockReset() + posthogMocks.PostHog.mockClear() + posthogMocks.captureImmediate.mockReset() + posthogMocks.shutdown.mockReset() + posthogMocks.captureImmediate.mockResolvedValue(undefined) + posthogMocks.shutdown.mockResolvedValue(undefined) }) it('does not call the telemetry endpoint when PostHog project token is disabled', async () => { - const fetchFn = vi.fn() - const result = await reportInstanceTelemetry({ db: {} as Database, config: { posthogProjectToken: '' }, cron: INSTANCE_TELEMETRY_CRON, runtime: { target: 'cloudflare-worker' }, - fetchFn, }) expect(result).toEqual({ reported: false, reason: 'disabled' }) - expect(fetchFn).not.toHaveBeenCalled() + expect(posthogMocks.PostHog).not.toHaveBeenCalled() expect(getOrCreateInstanceId).not.toHaveBeenCalled() }) - it('captures the expected telemetry event with built-in PostHog endpoint and project token', async () => { + it('captures the expected telemetry event with built-in PostHog host and project token', async () => { vi.mocked(getOrCreateInstanceId).mockResolvedValue('inst-1') - const fetchFn = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) const result = await reportInstanceTelemetry({ db: {} as Database, @@ -51,26 +68,21 @@ describe('instance telemetry', () => { 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(INSTANCE_TELEMETRY_ENDPOINT, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: expect.any(String), + expect(posthogMocks.PostHog).toHaveBeenCalledWith(INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, { + host: INSTANCE_TELEMETRY_POSTHOG_HOST, + flushAt: 1, + flushInterval: 0, + disableCompression: true, }) - - const body = JSON.parse(fetchFn.mock.calls[0][1].body) - expect(body).toMatchObject({ - api_key: INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, + expect(posthogMocks.captureImmediate).toHaveBeenCalledTimes(1) + expect(posthogMocks.captureImmediate).toHaveBeenCalledWith({ event: INSTANCE_TELEMETRY_EVENT, - distinct_id: 'inst-1', - timestamp: '2026-06-08T12:00:00.000Z', + distinctId: 'inst-1', + timestamp: new Date('2026-06-08T12:00:00.000Z'), properties: { instance_id: 'inst-1', app_version: '0.0.1', @@ -83,5 +95,6 @@ describe('instance telemetry', () => { reported_at: '2026-06-08T12:00:00.000Z', }, }) + expect(posthogMocks.shutdown).toHaveBeenCalledTimes(1) }) }) diff --git a/server/services/instance-telemetry.ts b/server/services/instance-telemetry.ts index 4172dafa..8c31cae6 100644 --- a/server/services/instance-telemetry.ts +++ b/server/services/instance-telemetry.ts @@ -1,3 +1,4 @@ +import { PostHog } from 'posthog-node' import packageJson from '../../package.json' import { getOrCreateInstanceId } from '../licensing/instance-id' import type { Database } from '../platform/interface' @@ -5,11 +6,11 @@ import type { Database } from '../platform/interface' export const INSTANCE_TELEMETRY_CRON = '0 */12 * * *' export const INSTANCE_TELEMETRY_EVENT = 'heartbeat' export const INSTANCE_TELEMETRY_INTERVAL = '12h' -export const INSTANCE_TELEMETRY_ENDPOINT = 'https://e.zpan.space/capture/' +export const INSTANCE_TELEMETRY_POSTHOG_HOST = 'https://e.zpan.space' export const INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN = 'pub_4709cd351f9bf91df7a4926d8ec835f423b0b2539a1d6f53' export interface InstanceTelemetryConfig { - endpoint?: string + posthogHost?: string posthogProjectToken?: string configuredInstanceId?: string } @@ -27,7 +28,6 @@ export interface InstanceTelemetryParams { cron: string runtime: InstanceTelemetryRuntime now?: Date - fetchFn?: typeof fetch } export interface InstanceTelemetryResult { @@ -35,48 +35,42 @@ export interface InstanceTelemetryResult { reason?: 'disabled' } -interface TelemetryCapturePayload { - api_key: string - event: string - distinct_id: string - properties: Record - timestamp: string -} - export async function reportInstanceTelemetry(params: InstanceTelemetryParams): Promise { - const endpoint = (params.config.endpoint ?? INSTANCE_TELEMETRY_ENDPOINT).trim() + const posthogHost = (params.config.posthogHost ?? INSTANCE_TELEMETRY_POSTHOG_HOST).trim() const posthogProjectToken = (params.config.posthogProjectToken ?? INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN).trim() - if (!endpoint || !posthogProjectToken) return { reported: false, reason: 'disabled' } + 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 = buildTelemetryPayload({ - instanceId, - cron: params.cron, - runtime: params.runtime, - timestamp, - posthogProjectToken, + const client = new PostHog(posthogProjectToken, { + host: posthogHost, + flushAt: 1, + flushInterval: 0, + disableCompression: true, }) - const res = await (params.fetchFn ?? fetch)(endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(payload), + await client.captureImmediate({ + distinctId: instanceId, + event: INSTANCE_TELEMETRY_EVENT, + timestamp: new Date(timestamp), + properties: buildTelemetryProperties({ + instanceId, + cron: params.cron, + runtime: params.runtime, + timestamp, + }), }) + await client.shutdown() - if (!res.ok) throw new Error(`instance_telemetry_failed_${res.status}`) return { reported: true } } -function buildTelemetryPayload(params: { +function buildTelemetryProperties(params: { instanceId: string cron: string runtime: InstanceTelemetryRuntime timestamp: string - posthogProjectToken: string -}): TelemetryCapturePayload { +}): Record { const properties: Record = { instance_id: params.instanceId, app_version: packageJson.version, @@ -90,13 +84,7 @@ function buildTelemetryPayload(params: { 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, - } + return properties } function addOptionalProperty(properties: Record, key: string, value: string | undefined): void {