diff --git a/server/entry-node.ts b/server/entry-node.ts index 3e68949b..5171f618 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -77,8 +77,10 @@ function reportNodeInstanceTelemetry(): void { db: platform.db, config: { configuredInstanceId: process.env.ZPAN_INSTANCE_ID, + siteUrl: process.env.ZPAN_PUBLIC_ORIGIN ?? process.env.BETTER_AUTH_URL, }, cron: INSTANCE_TELEMETRY_CRON, + trigger: 'runtime', runtime: { target: 'node/docker', osPlatform: process.platform, diff --git a/server/routes/internal.test.ts b/server/routes/internal.test.ts index 740f73a6..4f843466 100644 --- a/server/routes/internal.test.ts +++ b/server/routes/internal.test.ts @@ -41,6 +41,7 @@ describe('POST /api/internal/instance-telemetry/report', () => { const { app, db } = await createTestApp({ ZPAN_INTERNAL_API_TOKEN: 'test-token', ZPAN_INSTANCE_ID: 'configured-instance', + BETTER_AUTH_URL: 'https://zpan.example.com/path', }) const res = await app.request('/api/internal/instance-telemetry/report', { @@ -54,8 +55,10 @@ describe('POST /api/internal/instance-telemetry/report', () => { db, config: { configuredInstanceId: 'configured-instance', + siteUrl: 'https://zpan.example.com/path', }, cron: '0 */12 * * *', + trigger: 'deploy', runtime: expect.objectContaining({ target: 'node/docker', }), diff --git a/server/routes/internal.ts b/server/routes/internal.ts index 1e909a40..8a653af0 100644 --- a/server/routes/internal.ts +++ b/server/routes/internal.ts @@ -30,8 +30,10 @@ internal.post('/instance-telemetry/report', async (c) => { db: platform.db, config: { configuredInstanceId: platform.getEnv('ZPAN_INSTANCE_ID'), + siteUrl: platform.getEnv('ZPAN_PUBLIC_ORIGIN') ?? platform.getEnv('BETTER_AUTH_URL'), }, cron: INSTANCE_TELEMETRY_CRON, + trigger: 'deploy', runtime, }) diff --git a/server/scheduled-worker.test.ts b/server/scheduled-worker.test.ts index fb980bec..0d28c37a 100644 --- a/server/scheduled-worker.test.ts +++ b/server/scheduled-worker.test.ts @@ -73,8 +73,10 @@ describe('handleScheduled', () => { db: 'db', config: { configuredInstanceId: 'configured-instance', + siteUrl: 'https://zpan.example', }, cron: '0 */12 * * *', + trigger: 'scheduled', runtime: { target: 'cloudflare-worker', }, diff --git a/server/services/instance-telemetry.test.ts b/server/services/instance-telemetry.test.ts index 553e6f68..0a3c8397 100644 --- a/server/services/instance-telemetry.test.ts +++ b/server/services/instance-telemetry.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getOrCreateInstanceId } from '../licensing/instance-id' +import { getInstanceDisplayName } from '../licensing/instance-info' import type { Database } from '../platform/interface' import { INSTANCE_TELEMETRY_CRON, @@ -25,6 +26,10 @@ vi.mock('../licensing/instance-id', () => ({ getOrCreateInstanceId: vi.fn(), })) +vi.mock('../licensing/instance-info', () => ({ + getInstanceDisplayName: vi.fn(), +})) + vi.mock('posthog-node', () => ({ PostHog: posthogMocks.PostHog, })) @@ -32,11 +37,13 @@ vi.mock('posthog-node', () => ({ describe('instance telemetry', () => { beforeEach(() => { vi.mocked(getOrCreateInstanceId).mockReset() + vi.mocked(getInstanceDisplayName).mockReset() posthogMocks.PostHog.mockClear() posthogMocks.captureImmediate.mockReset() posthogMocks.shutdown.mockReset() posthogMocks.captureImmediate.mockResolvedValue(undefined) posthogMocks.shutdown.mockResolvedValue(undefined) + vi.mocked(getInstanceDisplayName).mockResolvedValue('Test Instance') }) it('does not call the telemetry endpoint when PostHog project token is disabled', async () => { @@ -50,6 +57,7 @@ describe('instance telemetry', () => { expect(result).toEqual({ reported: false, reason: 'disabled' }) expect(posthogMocks.PostHog).not.toHaveBeenCalled() expect(getOrCreateInstanceId).not.toHaveBeenCalled() + expect(getInstanceDisplayName).not.toHaveBeenCalled() }) it('captures the expected telemetry event with built-in PostHog host and project token', async () => { @@ -59,6 +67,7 @@ describe('instance telemetry', () => { db: {} as Database, config: { configuredInstanceId: 'configured-inst', + siteUrl: 'https://zpan.example.com/path', }, cron: INSTANCE_TELEMETRY_CRON, runtime: { @@ -72,6 +81,7 @@ describe('instance telemetry', () => { expect(result).toEqual({ reported: true }) expect(getOrCreateInstanceId).toHaveBeenCalledWith({}, 'configured-inst') + expect(getInstanceDisplayName).toHaveBeenCalledWith({}) expect(posthogMocks.PostHog).toHaveBeenCalledWith(INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, { host: INSTANCE_TELEMETRY_POSTHOG_HOST, flushAt: 1, @@ -85,13 +95,17 @@ describe('instance telemetry', () => { timestamp: new Date('2026-06-08T12:00:00.000Z'), properties: { instance_id: 'inst-1', - app_version: '0.0.1', + instance_name: 'Test Instance', + instance_url: 'https://zpan.example.com', + instance_version: '0.0.1', + $current_url: 'https://zpan.example.com', runtime_target: 'node/docker', os_platform: 'linux', os_arch: 'arm64', os_release: '6.8.0', - cron: INSTANCE_TELEMETRY_CRON, + report_trigger: 'scheduled', report_interval: '12h', + report_schedule: INSTANCE_TELEMETRY_CRON, reported_at: '2026-06-08T12:00:00.000Z', }, }) diff --git a/server/services/instance-telemetry.ts b/server/services/instance-telemetry.ts index 3cabcf14..cba2e34c 100644 --- a/server/services/instance-telemetry.ts +++ b/server/services/instance-telemetry.ts @@ -1,6 +1,7 @@ import { PostHog } from 'posthog-node' import packageJson from '../../package.json' import { getOrCreateInstanceId } from '../licensing/instance-id' +import { getInstanceDisplayName } from '../licensing/instance-info' import type { Database } from '../platform/interface' export const INSTANCE_TELEMETRY_CRON = '0 */12 * * *' @@ -13,6 +14,7 @@ export interface InstanceTelemetryConfig { posthogHost?: string posthogProjectToken?: string configuredInstanceId?: string + siteUrl?: string } export interface InstanceTelemetryRuntime { @@ -26,6 +28,7 @@ export interface InstanceTelemetryParams { db: Database config: InstanceTelemetryConfig cron: string + trigger?: 'deploy' | 'scheduled' | 'runtime' runtime: InstanceTelemetryRuntime now?: Date } @@ -41,6 +44,8 @@ export async function reportInstanceTelemetry(params: InstanceTelemetryParams): if (!posthogHost || !posthogProjectToken) return { reported: false, reason: 'disabled' } const instanceId = await getOrCreateInstanceId(params.db, params.config.configuredInstanceId) + const instanceName = await getInstanceDisplayName(params.db) + const instanceUrl = normalizeSiteUrl(params.config.siteUrl) const timestamp = (params.now ?? new Date()).toISOString() const client = new PostHog(posthogProjectToken, { host: posthogHost, @@ -55,7 +60,10 @@ export async function reportInstanceTelemetry(params: InstanceTelemetryParams): timestamp: new Date(timestamp), properties: buildTelemetryProperties({ instanceId, + instanceName, + instanceUrl, cron: params.cron, + trigger: params.trigger ?? 'scheduled', runtime: params.runtime, timestamp, }), @@ -67,19 +75,26 @@ export async function reportInstanceTelemetry(params: InstanceTelemetryParams): function buildTelemetryProperties(params: { instanceId: string + instanceName: string + instanceUrl?: string cron: string + trigger: 'deploy' | 'scheduled' | 'runtime' runtime: InstanceTelemetryRuntime timestamp: string }): Record { const properties: Record = { instance_id: params.instanceId, - app_version: packageJson.version, + instance_name: params.instanceName, + instance_version: packageJson.version, runtime_target: params.runtime.target, - cron: params.cron, + report_trigger: params.trigger, report_interval: INSTANCE_TELEMETRY_INTERVAL, + report_schedule: params.cron, reported_at: params.timestamp, } + addOptionalProperty(properties, 'instance_url', params.instanceUrl) + addOptionalProperty(properties, '$current_url', params.instanceUrl) addOptionalProperty(properties, 'os_platform', params.runtime.osPlatform) addOptionalProperty(properties, 'os_arch', params.runtime.osArch) addOptionalProperty(properties, 'os_release', params.runtime.osRelease) @@ -90,3 +105,15 @@ function buildTelemetryProperties(params: { function addOptionalProperty(properties: Record, key: string, value: string | undefined): void { if (value) properties[key] = value } + +function normalizeSiteUrl(value: string | undefined): string | undefined { + const input = value?.trim() + if (!input) return undefined + try { + const url = new URL(input) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined + return url.origin + } catch { + return undefined + } +} diff --git a/workers/scheduled.ts b/workers/scheduled.ts index 5452fe23..e348ab15 100644 --- a/workers/scheduled.ts +++ b/workers/scheduled.ts @@ -11,7 +11,9 @@ 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_CLOUD_URL?: string + ZPAN_PUBLIC_ORIGIN?: string ZPAN_INSTANCE_ID?: string [key: string]: unknown } @@ -33,8 +35,10 @@ export async function handleScheduled(event: ScheduledTrigger, env: ScheduledEnv db: platform.db, config: { configuredInstanceId: env.ZPAN_INSTANCE_ID, + siteUrl: env.ZPAN_PUBLIC_ORIGIN ?? env.BETTER_AUTH_URL, }, cron: event.cron, + trigger: 'scheduled', runtime: { target: 'cloudflare-worker', },