diff --git a/package-lock.json b/package-lock.json index 2a5fb1e3..bbae7b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3259,6 +3259,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@posthog/core": { + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.25.2.tgz", + "integrity": "sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg==", + "license": "MIT" + }, "node_modules/@relaycast/mcp": { "resolved": "packages/mcp", "link": true @@ -9861,6 +9867,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-node": { + "version": "5.29.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.29.2.tgz", + "integrity": "sha512-rI7kkF0XqDc0G1qjx+Hb4iuY9NAlL+XQNoGOpnEpRNTUcXvjY6WlsRGZ9m2whgc39emrrYdszi/YT8wZkr2xsg==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.25.2" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13902,6 +13928,7 @@ "@relaycast/types": "1.1.0", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", + "posthog-node": "^5.29.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/packages/server/package.json b/packages/server/package.json index e4988672..f16eea0d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,6 +17,7 @@ "@relaycast/types": "1.1.0", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", + "posthog-node": "^5.29.2", "zod": "^4.3.6" }, "repository": { diff --git a/packages/server/src/lib/__tests__/telemetry.test.ts b/packages/server/src/lib/__tests__/telemetry.test.ts index 9ec251ca..29a913da 100644 --- a/packages/server/src/lib/__tests__/telemetry.test.ts +++ b/packages/server/src/lib/__tests__/telemetry.test.ts @@ -3,10 +3,23 @@ import { buildInternalTelemetryEvent, captureInternalTelemetry, captureInternalTelemetryBatched, - flushInternalTelemetryBatchesForTests, workspaceDistinctId, } from '../telemetry.js'; +// Mock the posthog module +vi.mock('../posthog.js', () => { + const mockCapture = vi.fn(); + const mockShutdown = vi.fn().mockResolvedValue(undefined); + return { + getPostHogClient: vi.fn(() => ({ + capture: mockCapture, + shutdown: mockShutdown, + })), + flushAllPostHogClients: vi.fn().mockResolvedValue(undefined), + telemetryEnabled: vi.fn(() => true), + }; +}); + describe('server telemetry', () => { beforeEach(() => { vi.clearAllMocks(); @@ -31,9 +44,13 @@ describe('server telemetry', () => { })).toThrow(/Missing required properties/); }); - it('sends capture events to PostHog with origin in properties', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal('fetch', fetchMock); + it('sends capture events to PostHog via the SDK', async () => { + const { getPostHogClient } = await import('../posthog.js'); + const mockCapture = vi.fn(); + (getPostHogClient as ReturnType).mockReturnValue({ + capture: mockCapture, + shutdown: vi.fn().mockResolvedValue(undefined), + }); await captureInternalTelemetry( { @@ -57,21 +74,26 @@ describe('server telemetry', () => { }, ); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('https://us.i.posthog.com/capture/'); - expect(init.method).toBe('POST'); - - const payload = JSON.parse(String(init.body)); - expect(payload.event).toBe('relaycast_server_search_executed'); - expect(payload.properties.origin_surface).toBe('sdk'); - expect(payload.properties.origin_client).toBe('@relaycast/sdk-ts'); - expect(payload.properties.origin_version).toBe('0.3.1'); + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: workspaceDistinctId('ws_123'), + event: 'relaycast_server_search_executed', + properties: expect.objectContaining({ + workspace_id: 'ws_123', + origin_surface: 'sdk', + origin_client: '@relaycast/sdk-ts', + origin_version: '0.3.1', + }), + }); }); it('is a no-op when POSTHOG_API_KEY is missing', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); + const { getPostHogClient } = await import('../posthog.js'); + const mockCapture = vi.fn(); + (getPostHogClient as ReturnType).mockReturnValue({ + capture: mockCapture, + shutdown: vi.fn().mockResolvedValue(undefined), + }); await captureInternalTelemetry( { @@ -93,12 +115,17 @@ describe('server telemetry', () => { }, ); - expect(fetchMock).not.toHaveBeenCalled(); + expect(mockCapture).not.toHaveBeenCalled(); }); it('is a no-op when opt-out env vars are enabled', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); + const { getPostHogClient, telemetryEnabled } = await import('../posthog.js'); + const mockCapture = vi.fn(); + (getPostHogClient as ReturnType).mockReturnValue({ + capture: mockCapture, + shutdown: vi.fn().mockResolvedValue(undefined), + }); + (telemetryEnabled as ReturnType).mockReturnValue(false); await captureInternalTelemetry( { @@ -144,13 +171,17 @@ describe('server telemetry', () => { }, ); - await flushInternalTelemetryBatchesForTests(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(mockCapture).not.toHaveBeenCalled(); }); it('does not auto-disable based on ENVIRONMENT name', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal('fetch', fetchMock); + const { getPostHogClient, telemetryEnabled } = await import('../posthog.js'); + const mockCapture = vi.fn(); + (getPostHogClient as ReturnType).mockReturnValue({ + capture: mockCapture, + shutdown: vi.fn().mockResolvedValue(undefined), + }); + (telemetryEnabled as ReturnType).mockReturnValue(true); await captureInternalTelemetry( { @@ -173,58 +204,63 @@ describe('server telemetry', () => { }, ); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(mockCapture).toHaveBeenCalledTimes(1); }); - it('batches multiple events into one /batch request', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - const env = { - ENVIRONMENT: 'production', - POSTHOG_API_KEY: 'phc_test', - POSTHOG_HOST: 'https://us.i.posthog.com/', - } as any; - - const p1 = captureInternalTelemetryBatched(env, { - event: 'relaycast_server_search_executed', - distinct_id: workspaceDistinctId('ws_123'), - origin: { - origin_surface: 'sdk', - origin_client: '@relaycast/sdk-ts', - origin_version: '0.3.1', - }, - properties: { - workspace_id: 'ws_123', - query_length: 2, - result_count: 1, - }, + it('captureInternalTelemetryBatched delegates to the SDK (which handles batching internally)', async () => { + const { getPostHogClient } = await import('../posthog.js'); + const mockCapture = vi.fn(); + (getPostHogClient as ReturnType).mockReturnValue({ + capture: mockCapture, + shutdown: vi.fn().mockResolvedValue(undefined), }); - const p2 = captureInternalTelemetryBatched(env, { - event: 'relaycast_server_search_executed', - distinct_id: workspaceDistinctId('ws_123'), - origin: { - origin_surface: 'sdk', - origin_client: '@relaycast/sdk-ts', - origin_version: '0.3.1', + + const p1 = captureInternalTelemetryBatched( + { + ENVIRONMENT: 'production', + POSTHOG_API_KEY: 'phc_test', + POSTHOG_HOST: 'https://us.i.posthog.com/', + } as any, + { + event: 'relaycast_server_search_executed', + distinct_id: workspaceDistinctId('ws_123'), + origin: { + origin_surface: 'sdk', + origin_client: '@relaycast/sdk-ts', + origin_version: '0.3.1', + }, + properties: { + workspace_id: 'ws_123', + query_length: 2, + result_count: 1, + }, }, - properties: { - workspace_id: 'ws_123', - query_length: 4, - result_count: 2, + ); + const p2 = captureInternalTelemetryBatched( + { + ENVIRONMENT: 'production', + POSTHOG_API_KEY: 'phc_test', + POSTHOG_HOST: 'https://us.i.posthog.com/', + } as any, + { + event: 'relaycast_server_search_executed', + distinct_id: workspaceDistinctId('ws_123'), + origin: { + origin_surface: 'sdk', + origin_client: '@relaycast/sdk-ts', + origin_version: '0.3.1', + }, + properties: { + workspace_id: 'ws_123', + query_length: 4, + result_count: 2, + }, }, - }); + ); - await flushInternalTelemetryBatchesForTests(); await Promise.all([p1, p2]); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('https://us.i.posthog.com/batch/'); - expect(init.method).toBe('POST'); - - const payload = JSON.parse(String(init.body)); - expect(Array.isArray(payload.batch)).toBe(true); - expect(payload.batch).toHaveLength(2); + // SDK handles batching internally — we just verify both events were captured + expect(mockCapture).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/server/src/lib/logger.ts b/packages/server/src/lib/logger.ts index e5816329..40e8fb62 100644 --- a/packages/server/src/lib/logger.ts +++ b/packages/server/src/lib/logger.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono'; import type { AppEnv, CloudflareBindings } from '../env.js'; +import { getPostHogClient, telemetryEnabled } from './posthog.js'; type LogLevel = 'debug' | 'info' | 'warn' | 'error'; type LogFields = Record; @@ -321,43 +322,37 @@ export interface CaptureExceptionOptions { } /** - * Sends a `$exception` event to PostHog Error Tracking. + * Sends a `$exception` event to PostHog Error Tracking via the PostHog SDK. * - * Returns a promise that resolves once the HTTP request completes (best-effort). - * Safe to fire-and-forget or pass to `waitUntil`. + * The returned promise resolves after the SDK has flushed the event, so call + * sites can pass it to `waitUntil` to keep the isolate alive until delivery. */ export async function captureException( env: CloudflareBindings, error: unknown, options: CaptureExceptionOptions = {}, ): Promise { + if (!telemetryEnabled(env)) return; const apiKey = env.POSTHOG_API_KEY; if (!apiKey) return; const exceptionList = buildExceptionList(error); - const payload = { - api_key: apiKey, - event: '$exception', - distinct_id: options.distinctId ?? SERVICE_NAME, - properties: { - $exception_list: exceptionList, - $exception_type: exceptionList[0]?.type, - $exception_message: exceptionList[0]?.value, - $exception_level: 'error', - service_name: SERVICE_NAME, - environment: env.ENVIRONMENT, - app_version: getAppVersion(env), - ...(options.properties ?? {}), - }, - timestamp: new Date().toISOString(), + const client = getPostHogClient(env, apiKey); + + const additionalProperties: Record = { + $exception_list: exceptionList, + $exception_type: exceptionList[0]?.type, + $exception_message: exceptionList[0]?.value, + $exception_level: 'error', + service_name: SERVICE_NAME, + environment: env.ENVIRONMENT, + app_version: getAppVersion(env), + ...(options.properties ?? {}), }; + client.captureException(error, options.distinctId ?? SERVICE_NAME, additionalProperties); try { - await fetch(`${getPostHogHost(env)}/capture/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); + await client.flush(); } catch { // Best effort — never break request handling for telemetry. } diff --git a/packages/server/src/lib/posthog.ts b/packages/server/src/lib/posthog.ts new file mode 100644 index 00000000..aea12863 --- /dev/null +++ b/packages/server/src/lib/posthog.ts @@ -0,0 +1,50 @@ +import { PostHog } from 'posthog-node'; +import type { CloudflareBindings } from '../env.js'; + +const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'; + +function isTruthy(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function telemetryEnabled(env: CloudflareBindings): boolean { + return !isTruthy(env.DO_NOT_TRACK) && !isTruthy(env.RELAYCAST_TELEMETRY_DISABLED); +} + +function getPostHogHost(env: CloudflareBindings): string { + const configured = env.POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST; + return configured.endsWith('/') ? configured.slice(0, -1) : configured; +} + +type ClientState = { + client: PostHog; +}; + +const clients = new Map(); + +export function getPostHogClient(env: CloudflareBindings, apiKey: string): PostHog { + const key = `${getPostHogHost(env)}|${apiKey}`; + const existing = clients.get(key); + if (existing) return existing.client; + + const client = new PostHog(apiKey, { + host: getPostHogHost(env), + flushAt: 20, + flushInterval: 250, + }); + + clients.set(key, { client }); + return client; +} + +export { telemetryEnabled }; + +export async function flushAllPostHogClients(): Promise { + try { + await Promise.all([...clients.values()].map(({ client }) => client.shutdown())); + } finally { + clients.clear(); + } +} diff --git a/packages/server/src/lib/telemetry.ts b/packages/server/src/lib/telemetry.ts index e2a8ebcd..bb3c82d1 100644 --- a/packages/server/src/lib/telemetry.ts +++ b/packages/server/src/lib/telemetry.ts @@ -5,12 +5,9 @@ import { type InternalTelemetryEvent, type TelemetryOrigin, } from '@relaycast/types'; +import { flushAllPostHogClients, getPostHogClient, telemetryEnabled } from './posthog.js'; import type { CloudflareBindings } from '../env.js'; -const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'; -const DEFAULT_BATCH_MAX_SIZE = 20; -const DEFAULT_BATCH_FLUSH_MS = 250; - export interface InternalTelemetryCaptureInput { event: InternalTelemetryEvent['event']; distinct_id: string; @@ -18,36 +15,6 @@ export interface InternalTelemetryCaptureInput { properties?: Record; } -type BatchedEvent = { - event: InternalTelemetryEvent; - resolve: () => void; -}; - -type BatchState = { - host: string; - apiKey: string; - queue: BatchedEvent[]; - timer?: ReturnType; - flushing: boolean; -}; - -const telemetryBatchStates = new Map(); - -function isTruthy(value: string | undefined): boolean { - if (!value) return false; - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; -} - -function telemetryEnabled(env: CloudflareBindings): boolean { - return !isTruthy(env.DO_NOT_TRACK) && !isTruthy(env.RELAYCAST_TELEMETRY_DISABLED); -} - -function getPostHogHost(env: CloudflareBindings): string { - const configured = env.POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST; - return configured.endsWith('/') ? configured.slice(0, -1) : configured; -} - export function workspaceDistinctId(workspaceId: string): string { return workspaceId; } @@ -61,71 +28,6 @@ export function buildInternalTelemetryEvent(input: InternalTelemetryCaptureInput }); } -function getBatchKey(host: string, apiKey: string): string { - return `${host}|${apiKey}`; -} - -function eventToBatchItem(event: InternalTelemetryEvent): Record { - return { - event: event.event, - distinct_id: event.distinct_id, - properties: { - distinct_id: event.distinct_id, - ...event.properties, - origin_surface: event.origin_surface, - origin_client: event.origin_client, - origin_version: event.origin_version, - }, - }; -} - -async function flushBatchState(stateKey: string): Promise { - const state = telemetryBatchStates.get(stateKey); - if (!state || state.flushing || state.queue.length === 0) return; - - if (state.timer) { - clearTimeout(state.timer); - state.timer = undefined; - } - - state.flushing = true; - const batch = state.queue.splice(0, DEFAULT_BATCH_MAX_SIZE); - - try { - const response = await fetch(`${state.host}/batch/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - api_key: state.apiKey, - batch: batch.map((item) => eventToBatchItem(item.event)), - }), - }); - if (!response.ok) { - // Best effort only. - } - } catch { - // Best effort only. - } finally { - for (const item of batch) item.resolve(); - state.flushing = false; - - if (state.queue.length >= DEFAULT_BATCH_MAX_SIZE) { - void flushBatchState(stateKey); - return; - } - - if (state.queue.length > 0) { - state.timer = setTimeout(() => { - state.timer = undefined; - void flushBatchState(stateKey); - }, DEFAULT_BATCH_FLUSH_MS); - return; - } - - telemetryBatchStates.delete(stateKey); - } -} - export async function captureInternalTelemetry( env: CloudflareBindings, input: InternalTelemetryCaptureInput | InternalTelemetryEvent, @@ -137,75 +39,28 @@ export async function captureInternalTelemetry( const event = 'origin' in input ? buildInternalTelemetryEvent(input) : parseInternalTelemetryEvent(input); - const payload = { - api_key: apiKey, + + const client = getPostHogClient(env, apiKey); + client.capture({ + distinctId: event.distinct_id, event: event.event, - distinct_id: event.distinct_id, properties: { ...event.properties, origin_surface: event.origin_surface, origin_client: event.origin_client, origin_version: event.origin_version, }, - }; - - try { - const response = await fetch(`${getPostHogHost(env)}/capture/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - // Best effort only. - } - } catch { - // Best effort only. - } + }); } export async function captureInternalTelemetryBatched( env: CloudflareBindings, input: InternalTelemetryCaptureInput | InternalTelemetryEvent, ): Promise { - if (!telemetryEnabled(env)) return; - const apiKey = env.POSTHOG_API_KEY; - if (!apiKey) return; - - const event = 'origin' in input - ? buildInternalTelemetryEvent(input) - : parseInternalTelemetryEvent(input); - const host = getPostHogHost(env); - const key = getBatchKey(host, apiKey); - - let state = telemetryBatchStates.get(key); - if (!state) { - state = { - host, - apiKey, - queue: [], - flushing: false, - }; - telemetryBatchStates.set(key, state); - } - - return new Promise((resolve) => { - state.queue.push({ event, resolve }); - - if (state.queue.length >= DEFAULT_BATCH_MAX_SIZE) { - void flushBatchState(key); - return; - } - - if (!state.timer) { - state.timer = setTimeout(() => { - state.timer = undefined; - void flushBatchState(key); - }, DEFAULT_BATCH_FLUSH_MS); - } - }); + // Batching is handled automatically by the SDK client (flushAt: 20, flushInterval: 250) + return captureInternalTelemetry(env, input); } export async function flushInternalTelemetryBatchesForTests(): Promise { - const keys = [...telemetryBatchStates.keys()]; - await Promise.all(keys.map((key) => flushBatchState(key))); + await flushAllPostHogClients(); }