From bd9f047ebb68d70971fcce3a1f08978e155fb6e6 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Mon, 8 Jun 2026 12:40:59 -0400 Subject: [PATCH] fix(telemetry): report instance after deployment (#417) --- .github/workflows/deploy-cloudflare.yml | 29 +++----- package.json | 2 +- scripts/report-deploy-telemetry.mjs | 83 ++++++++++++++++++++++ server/app.ts | 2 + server/entry-node.ts | 9 ++- server/routes/internal.test.ts | 64 +++++++++++++++++ server/routes/internal.ts | 41 +++++++++++ server/services/instance-telemetry.test.ts | 10 +-- server/services/instance-telemetry.ts | 14 ++-- 9 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 scripts/report-deploy-telemetry.mjs create mode 100644 server/routes/internal.test.ts create mode 100644 server/routes/internal.ts diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 042f81f1..bf9f4dbd 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -140,26 +140,6 @@ jobs: - name: Patch wrangler.toml with D1 database ID run: sed -i "s/database_id = \"[^\"]*\"/database_id = \"${{ steps.d1.outputs.id }}\"/" wrangler.toml - - name: Apply D1 migrations - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: pnpm exec wrangler d1 migrations apply DB --remote - - - name: Build - run: pnpm exec vite build - - - name: Deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - # Run wrangler from the repo root so it uses the @cloudflare/vite-plugin's - # redirect config (.wrangler/deploy/config.json) which points at the - # built Worker (dist/zpan/wrangler.json). Cd'ing into dist/zpan instead - # causes wrangler 4.x to error on conflicting base paths between the - # two configs. - run: pnpm exec wrangler deploy - - name: Set BETTER_AUTH_SECRET (first deploy only) env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -184,3 +164,12 @@ jobs: run: | echo "${{ steps.r2.outputs.url }}" | pnpm exec wrangler secret put PUBLIC_IMAGES_URL echo "Set PUBLIC_IMAGES_URL = ${{ steps.r2.outputs.url }}" + + - name: Deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + ZPAN_DEPLOY_URL: ${{ vars.ZPAN_DEPLOY_URL }} + # Keep post-deploy hooks inside the repository deploy command so + # GitHub Actions and Cloudflare Workers Builds can share the same path. + run: pnpm deploy diff --git a/package.json b/package.json index d5480d34..4dc0bd4a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client", "build:netlify": "tsup server/entry-netlify.ts --format esm --outDir netlify/functions --external @libsql/client", "build:azure": "vite build && tsup server/entry-azure.ts --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist", - "deploy": "pnpm db:migrate:d1:prod && wrangler deploy", + "deploy": "pnpm db:migrate:d1:prod && wrangler deploy && node scripts/report-deploy-telemetry.mjs", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:migrate:d1": "wrangler d1 migrations apply zpan-db-staging --local --env staging", diff --git a/scripts/report-deploy-telemetry.mjs b/scripts/report-deploy-telemetry.mjs new file mode 100644 index 00000000..f49f49eb --- /dev/null +++ b/scripts/report-deploy-telemetry.mjs @@ -0,0 +1,83 @@ +import { spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' + +const INTERNAL_TOKEN_ENV = 'ZPAN_INTERNAL_API_TOKEN' +const REPORT_PATH = '/api/internal/instance-telemetry/report' +const DEFAULT_DEPLOY_URL = 'https://zpan.saltbo.workers.dev' + +const token = randomBytes(32).toString('hex') +if (process.env.GITHUB_ACTIONS === 'true') { + console.log(`::add-mask::${token}`) +} + +let secretSet = false +let reportError +try { + putInternalToken(token) + secretSet = true + await reportDeployTelemetry(token) +} catch (err) { + reportError = err + throw err +} finally { + if (secretSet) { + try { + deleteInternalToken() + } catch (err) { + if (!reportError) throw err + const message = err instanceof Error ? err.message : String(err) + console.error(`Failed to delete ${INTERNAL_TOKEN_ENV}: ${message}`) + } + } +} + +function putInternalToken(internalToken) { + console.log(`Setting ${INTERNAL_TOKEN_ENV}`) + const res = spawnSync('wrangler', ['secret', 'put', INTERNAL_TOKEN_ENV], { + input: internalToken, + encoding: 'utf8', + stdio: ['pipe', 'inherit', 'inherit'], + }) + if (res.status !== 0) throw new Error(`wrangler secret put failed status=${res.status ?? 1}`) +} + +function deleteInternalToken() { + console.log(`Deleting ${INTERNAL_TOKEN_ENV}`) + const res = spawnSync('wrangler', ['secret', 'delete', INTERNAL_TOKEN_ENV], { + input: 'y\n', + encoding: 'utf8', + stdio: ['pipe', 'inherit', 'inherit'], + }) + if (res.status !== 0) throw new Error(`wrangler secret delete failed status=${res.status ?? 1}`) +} + +async function reportDeployTelemetry(internalToken) { + const url = new URL(REPORT_PATH, resolveDeployUrl()) + + for (let attempt = 1; attempt <= 5; attempt += 1) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${internalToken}`, + }, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) + console.log(`Reported deployed instance telemetry: ${url}`) + return + } catch (err) { + if (attempt === 5) throw err + const code = err instanceof Error ? err.message : String(err) + console.warn(`Deploy telemetry report failed attempt=${attempt} error=${code}`) + await sleep(1000 * attempt) + } + } +} + +function resolveDeployUrl() { + return process.env.ZPAN_DEPLOY_URL?.trim() || process.env.BETTER_AUTH_URL?.trim() || DEFAULT_DEPLOY_URL +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/server/app.ts b/server/app.ts index 25ac57e8..8df8f8fa 100644 --- a/server/app.ts +++ b/server/app.ts @@ -19,6 +19,7 @@ import downloaders, { downloaderSelfRoute } from './routes/downloaders' import emailConfig from './routes/email-config' import ihost from './routes/ihost' import ihostConfig from './routes/ihost-config' +import internal from './routes/internal' import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes' import licensing from './routes/licensing' import licensingAdmin from './routes/licensing-admin' @@ -80,6 +81,7 @@ export function createApp(platform: Platform, auth: Auth) { app.route('/api/branding', publicBranding) app.route('/api/site-invitations', publicSiteInvitations) app.route('/api/store', cloudStoreWebhooks) + app.route('/api/internal', internal) app.use('/api/*', authMiddleware) diff --git a/server/entry-node.ts b/server/entry-node.ts index ccb67c9a..3e68949b 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -70,8 +70,7 @@ setInterval(() => { void syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl }) }, TRAFFIC_SYNC_INTERVAL_MS) -console.log('instance.telemetry.scheduler.started interval=12h') -setInterval(() => { +function reportNodeInstanceTelemetry(): void { void (async () => { try { await reportInstanceTelemetry({ @@ -92,4 +91,8 @@ setInterval(() => { console.error(`instance.telemetry.error code=${code}`) } })() -}, INSTANCE_TELEMETRY_INTERVAL_MS) +} + +console.log('instance.telemetry.scheduler.started interval=12h') +reportNodeInstanceTelemetry() +setInterval(reportNodeInstanceTelemetry, INSTANCE_TELEMETRY_INTERVAL_MS) diff --git a/server/routes/internal.test.ts b/server/routes/internal.test.ts new file mode 100644 index 00000000..740f73a6 --- /dev/null +++ b/server/routes/internal.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { reportInstanceTelemetry } from '../services/instance-telemetry' +import { createTestApp } from '../test/setup.js' + +vi.mock('../services/instance-telemetry', () => ({ + INSTANCE_TELEMETRY_CRON: '0 */12 * * *', + reportInstanceTelemetry: vi.fn(), +})) + +describe('POST /api/internal/instance-telemetry/report', () => { + beforeEach(() => { + vi.mocked(reportInstanceTelemetry).mockReset() + vi.mocked(reportInstanceTelemetry).mockResolvedValue({ reported: true }) + }) + + it('returns 404 when the internal token is not configured', async () => { + const { app } = await createTestApp() + + const res = await app.request('/api/internal/instance-telemetry/report', { + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + }) + + expect(res.status).toBe(404) + expect(reportInstanceTelemetry).not.toHaveBeenCalled() + }) + + it('rejects requests with the wrong token', async () => { + const { app } = await createTestApp({ ZPAN_INTERNAL_API_TOKEN: 'test-token' }) + + const res = await app.request('/api/internal/instance-telemetry/report', { + method: 'POST', + headers: { Authorization: 'Bearer wrong-token' }, + }) + + expect(res.status).toBe(401) + expect(reportInstanceTelemetry).not.toHaveBeenCalled() + }) + + it('reports telemetry with the configured internal token', async () => { + const { app, db } = await createTestApp({ + ZPAN_INTERNAL_API_TOKEN: 'test-token', + ZPAN_INSTANCE_ID: 'configured-instance', + }) + + const res = await app.request('/api/internal/instance-telemetry/report', { + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ reported: true }) + expect(reportInstanceTelemetry).toHaveBeenCalledWith({ + db, + config: { + configuredInstanceId: 'configured-instance', + }, + cron: '0 */12 * * *', + runtime: expect.objectContaining({ + target: 'node/docker', + }), + }) + }) +}) diff --git a/server/routes/internal.ts b/server/routes/internal.ts new file mode 100644 index 00000000..1e909a40 --- /dev/null +++ b/server/routes/internal.ts @@ -0,0 +1,41 @@ +import { release as osRelease } from 'node:os' +import { Hono } from 'hono' +import type { Env } from '../middleware/platform' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../services/instance-telemetry' + +const INTERNAL_API_TOKEN_ENV = 'ZPAN_INTERNAL_API_TOKEN' + +const internal = new Hono() + +internal.post('/instance-telemetry/report', async (c) => { + const platform = c.get('platform') + const token = platform.getEnv(INTERNAL_API_TOKEN_ENV)?.trim() + if (!token) return c.json({ error: 'Not found' }, 404) + + const auth = c.req.header('authorization') ?? '' + if (auth !== `Bearer ${token}`) return c.json({ error: 'Unauthorized' }, 401) + + const runtime = platform.getBinding('DB') + ? { + target: 'cloudflare-worker' as const, + } + : { + target: 'node/docker' as const, + osPlatform: process.platform, + osArch: process.arch, + osRelease: osRelease(), + } + + const result = await reportInstanceTelemetry({ + db: platform.db, + config: { + configuredInstanceId: platform.getEnv('ZPAN_INSTANCE_ID'), + }, + cron: INSTANCE_TELEMETRY_CRON, + runtime, + }) + + return c.json(result) +}) + +export default internal diff --git a/server/services/instance-telemetry.test.ts b/server/services/instance-telemetry.test.ts index 72db511d..0218a360 100644 --- a/server/services/instance-telemetry.test.ts +++ b/server/services/instance-telemetry.test.ts @@ -5,7 +5,7 @@ import { INSTANCE_TELEMETRY_CRON, INSTANCE_TELEMETRY_ENDPOINT, INSTANCE_TELEMETRY_EVENT, - INSTANCE_TELEMETRY_PRODUCT_TOKEN, + INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, reportInstanceTelemetry, } from './instance-telemetry' @@ -18,12 +18,12 @@ describe('instance telemetry', () => { vi.mocked(getOrCreateInstanceId).mockReset() }) - it('does not call the telemetry endpoint when product token is disabled', async () => { + 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: { productToken: '' }, + config: { posthogProjectToken: '' }, cron: INSTANCE_TELEMETRY_CRON, runtime: { target: 'cloudflare-worker' }, fetchFn, @@ -34,7 +34,7 @@ describe('instance telemetry', () => { expect(getOrCreateInstanceId).not.toHaveBeenCalled() }) - it('captures the expected telemetry event with built-in endpoint and product token', async () => { + it('captures the expected telemetry event with built-in PostHog endpoint and project token', async () => { vi.mocked(getOrCreateInstanceId).mockResolvedValue('inst-1') const fetchFn = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) @@ -67,7 +67,7 @@ describe('instance telemetry', () => { const body = JSON.parse(fetchFn.mock.calls[0][1].body) expect(body).toMatchObject({ - api_key: INSTANCE_TELEMETRY_PRODUCT_TOKEN, + api_key: INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, event: INSTANCE_TELEMETRY_EVENT, distinct_id: 'inst-1', timestamp: '2026-06-08T12:00:00.000Z', diff --git a/server/services/instance-telemetry.ts b/server/services/instance-telemetry.ts index 6e926d4b..4172dafa 100644 --- a/server/services/instance-telemetry.ts +++ b/server/services/instance-telemetry.ts @@ -6,11 +6,11 @@ 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_PRODUCT_TOKEN = 'pub_4709cd351f9bf91df7a4926d8ec835f423b0b2539a1d6f53' +export const INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN = 'pub_4709cd351f9bf91df7a4926d8ec835f423b0b2539a1d6f53' export interface InstanceTelemetryConfig { endpoint?: string - productToken?: string + posthogProjectToken?: string configuredInstanceId?: string } @@ -45,8 +45,8 @@ interface TelemetryCapturePayload { export async function reportInstanceTelemetry(params: InstanceTelemetryParams): Promise { const endpoint = (params.config.endpoint ?? INSTANCE_TELEMETRY_ENDPOINT).trim() - const productToken = (params.config.productToken ?? INSTANCE_TELEMETRY_PRODUCT_TOKEN).trim() - if (!endpoint || !productToken) return { reported: false, reason: 'disabled' } + const posthogProjectToken = (params.config.posthogProjectToken ?? INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN).trim() + if (!endpoint || !posthogProjectToken) return { reported: false, reason: 'disabled' } const instanceId = await getOrCreateInstanceId(params.db, params.config.configuredInstanceId) const timestamp = (params.now ?? new Date()).toISOString() @@ -55,7 +55,7 @@ export async function reportInstanceTelemetry(params: InstanceTelemetryParams): cron: params.cron, runtime: params.runtime, timestamp, - productToken, + posthogProjectToken, }) const res = await (params.fetchFn ?? fetch)(endpoint, { @@ -75,7 +75,7 @@ function buildTelemetryPayload(params: { cron: string runtime: InstanceTelemetryRuntime timestamp: string - productToken: string + posthogProjectToken: string }): TelemetryCapturePayload { const properties: Record = { instance_id: params.instanceId, @@ -91,7 +91,7 @@ function buildTelemetryPayload(params: { addOptionalProperty(properties, 'os_release', params.runtime.osRelease) return { - api_key: params.productToken, + api_key: params.posthogProjectToken, event: INSTANCE_TELEMETRY_EVENT, distinct_id: params.instanceId, properties,