fix(telemetry): report instance after deployment (#417)

This commit is contained in:
Jasper Van
2026-06-08 12:40:59 -04:00
committed by GitHub
parent 1d7ac9b071
commit bd9f047ebb
9 changed files with 218 additions and 36 deletions
+9 -20
View File
@@ -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
+1 -1
View File
@@ -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",
+83
View File
@@ -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))
}
+2
View File
@@ -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)
+6 -3
View File
@@ -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)
+64
View File
@@ -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',
}),
})
})
})
+41
View File
@@ -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<Env>()
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
+5 -5
View File
@@ -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',
+7 -7
View File
@@ -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<InstanceTelemetryResult> {
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<string, string> = {
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,