mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
fix(telemetry): report instance after deployment (#417)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user