Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
172 changes: 104 additions & 68 deletions packages/server/src/lib/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<typeof vi.fn>).mockReturnValue({
capture: mockCapture,
shutdown: vi.fn().mockResolvedValue(undefined),
});

await captureInternalTelemetry(
{
Expand All @@ -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<typeof vi.fn>).mockReturnValue({
capture: mockCapture,
shutdown: vi.fn().mockResolvedValue(undefined),
});

await captureInternalTelemetry(
{
Expand All @@ -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<typeof vi.fn>).mockReturnValue({
capture: mockCapture,
shutdown: vi.fn().mockResolvedValue(undefined),
});
(telemetryEnabled as ReturnType<typeof vi.fn>).mockReturnValue(false);

await captureInternalTelemetry(
{
Expand Down Expand Up @@ -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<typeof vi.fn>).mockReturnValue({
capture: mockCapture,
shutdown: vi.fn().mockResolvedValue(undefined),
});
(telemetryEnabled as ReturnType<typeof vi.fn>).mockReturnValue(true);

await captureInternalTelemetry(
{
Expand All @@ -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<typeof vi.fn>).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);
});
});
41 changes: 18 additions & 23 deletions packages/server/src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
Expand Down Expand Up @@ -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.
*/
Comment thread
willwashburn marked this conversation as resolved.
export async function captureException(
env: CloudflareBindings,
error: unknown,
options: CaptureExceptionOptions = {},
): Promise<void> {
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<string, unknown> = {
$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.
}
Expand Down
Loading
Loading