mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 10:01:12 +08:00
fix(telemetry): report instance after deployment (#417)
This commit is contained in:
@@ -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