Skip to content

Commit 650f850

Browse files
authored
Merge pull request #117 from AgentWorkforce/fix/posthog-exception-missing-type
fix: move timestamp inside properties in $exception PostHog events
2 parents 9d764cc + 1b76a22 commit 650f850

6 files changed

Lines changed: 209 additions & 245 deletions

File tree

package-lock.json

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@relaycast/types": "1.1.2",
1818
"drizzle-orm": "^0.45.1",
1919
"hono": "^4.11.9",
20+
"posthog-node": "^5.29.2",
2021
"zod": "^4.3.6"
2122
},
2223
"repository": {

packages/server/src/lib/__tests__/telemetry.test.ts

Lines changed: 104 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,23 @@ import {
33
buildInternalTelemetryEvent,
44
captureInternalTelemetry,
55
captureInternalTelemetryBatched,
6-
flushInternalTelemetryBatchesForTests,
76
workspaceDistinctId,
87
} from '../telemetry.js';
98

9+
// Mock the posthog module
10+
vi.mock('../posthog.js', () => {
11+
const mockCapture = vi.fn();
12+
const mockShutdown = vi.fn().mockResolvedValue(undefined);
13+
return {
14+
getPostHogClient: vi.fn(() => ({
15+
capture: mockCapture,
16+
shutdown: mockShutdown,
17+
})),
18+
flushAllPostHogClients: vi.fn().mockResolvedValue(undefined),
19+
telemetryEnabled: vi.fn(() => true),
20+
};
21+
});
22+
1023
describe('server telemetry', () => {
1124
beforeEach(() => {
1225
vi.clearAllMocks();
@@ -31,9 +44,13 @@ describe('server telemetry', () => {
3144
})).toThrow(/Missing required properties/);
3245
});
3346

34-
it('sends capture events to PostHog with origin in properties', async () => {
35-
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
36-
vi.stubGlobal('fetch', fetchMock);
47+
it('sends capture events to PostHog via the SDK', async () => {
48+
const { getPostHogClient } = await import('../posthog.js');
49+
const mockCapture = vi.fn();
50+
(getPostHogClient as ReturnType<typeof vi.fn>).mockReturnValue({
51+
capture: mockCapture,
52+
shutdown: vi.fn().mockResolvedValue(undefined),
53+
});
3754

3855
await captureInternalTelemetry(
3956
{
@@ -57,21 +74,26 @@ describe('server telemetry', () => {
5774
},
5875
);
5976

60-
expect(fetchMock).toHaveBeenCalledTimes(1);
61-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
62-
expect(url).toBe('https://us.i.posthog.com/capture/');
63-
expect(init.method).toBe('POST');
64-
65-
const payload = JSON.parse(String(init.body));
66-
expect(payload.event).toBe('relaycast_server_search_executed');
67-
expect(payload.properties.origin_surface).toBe('sdk');
68-
expect(payload.properties.origin_client).toBe('@relaycast/sdk-ts');
69-
expect(payload.properties.origin_version).toBe('0.3.1');
77+
expect(mockCapture).toHaveBeenCalledTimes(1);
78+
expect(mockCapture).toHaveBeenCalledWith({
79+
distinctId: workspaceDistinctId('ws_123'),
80+
event: 'relaycast_server_search_executed',
81+
properties: expect.objectContaining({
82+
workspace_id: 'ws_123',
83+
origin_surface: 'sdk',
84+
origin_client: '@relaycast/sdk-ts',
85+
origin_version: '0.3.1',
86+
}),
87+
});
7088
});
7189

7290
it('is a no-op when POSTHOG_API_KEY is missing', async () => {
73-
const fetchMock = vi.fn();
74-
vi.stubGlobal('fetch', fetchMock);
91+
const { getPostHogClient } = await import('../posthog.js');
92+
const mockCapture = vi.fn();
93+
(getPostHogClient as ReturnType<typeof vi.fn>).mockReturnValue({
94+
capture: mockCapture,
95+
shutdown: vi.fn().mockResolvedValue(undefined),
96+
});
7597

7698
await captureInternalTelemetry(
7799
{
@@ -93,12 +115,17 @@ describe('server telemetry', () => {
93115
},
94116
);
95117

96-
expect(fetchMock).not.toHaveBeenCalled();
118+
expect(mockCapture).not.toHaveBeenCalled();
97119
});
98120

99121
it('is a no-op when opt-out env vars are enabled', async () => {
100-
const fetchMock = vi.fn();
101-
vi.stubGlobal('fetch', fetchMock);
122+
const { getPostHogClient, telemetryEnabled } = await import('../posthog.js');
123+
const mockCapture = vi.fn();
124+
(getPostHogClient as ReturnType<typeof vi.fn>).mockReturnValue({
125+
capture: mockCapture,
126+
shutdown: vi.fn().mockResolvedValue(undefined),
127+
});
128+
(telemetryEnabled as ReturnType<typeof vi.fn>).mockReturnValue(false);
102129

103130
await captureInternalTelemetry(
104131
{
@@ -144,13 +171,17 @@ describe('server telemetry', () => {
144171
},
145172
);
146173

147-
await flushInternalTelemetryBatchesForTests();
148-
expect(fetchMock).not.toHaveBeenCalled();
174+
expect(mockCapture).not.toHaveBeenCalled();
149175
});
150176

151177
it('does not auto-disable based on ENVIRONMENT name', async () => {
152-
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
153-
vi.stubGlobal('fetch', fetchMock);
178+
const { getPostHogClient, telemetryEnabled } = await import('../posthog.js');
179+
const mockCapture = vi.fn();
180+
(getPostHogClient as ReturnType<typeof vi.fn>).mockReturnValue({
181+
capture: mockCapture,
182+
shutdown: vi.fn().mockResolvedValue(undefined),
183+
});
184+
(telemetryEnabled as ReturnType<typeof vi.fn>).mockReturnValue(true);
154185

155186
await captureInternalTelemetry(
156187
{
@@ -173,58 +204,63 @@ describe('server telemetry', () => {
173204
},
174205
);
175206

176-
expect(fetchMock).toHaveBeenCalledTimes(1);
207+
expect(mockCapture).toHaveBeenCalledTimes(1);
177208
});
178209

179-
it('batches multiple events into one /batch request', async () => {
180-
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
181-
vi.stubGlobal('fetch', fetchMock);
182-
183-
const env = {
184-
ENVIRONMENT: 'production',
185-
POSTHOG_API_KEY: 'phc_test',
186-
POSTHOG_HOST: 'https://us.i.posthog.com/',
187-
} as any;
188-
189-
const p1 = captureInternalTelemetryBatched(env, {
190-
event: 'relaycast_server_search_executed',
191-
distinct_id: workspaceDistinctId('ws_123'),
192-
origin: {
193-
origin_surface: 'sdk',
194-
origin_client: '@relaycast/sdk-ts',
195-
origin_version: '0.3.1',
196-
},
197-
properties: {
198-
workspace_id: 'ws_123',
199-
query_length: 2,
200-
result_count: 1,
201-
},
210+
it('captureInternalTelemetryBatched delegates to the SDK (which handles batching internally)', async () => {
211+
const { getPostHogClient } = await import('../posthog.js');
212+
const mockCapture = vi.fn();
213+
(getPostHogClient as ReturnType<typeof vi.fn>).mockReturnValue({
214+
capture: mockCapture,
215+
shutdown: vi.fn().mockResolvedValue(undefined),
202216
});
203-
const p2 = captureInternalTelemetryBatched(env, {
204-
event: 'relaycast_server_search_executed',
205-
distinct_id: workspaceDistinctId('ws_123'),
206-
origin: {
207-
origin_surface: 'sdk',
208-
origin_client: '@relaycast/sdk-ts',
209-
origin_version: '0.3.1',
217+
218+
const p1 = captureInternalTelemetryBatched(
219+
{
220+
ENVIRONMENT: 'production',
221+
POSTHOG_API_KEY: 'phc_test',
222+
POSTHOG_HOST: 'https://us.i.posthog.com/',
223+
} as any,
224+
{
225+
event: 'relaycast_server_search_executed',
226+
distinct_id: workspaceDistinctId('ws_123'),
227+
origin: {
228+
origin_surface: 'sdk',
229+
origin_client: '@relaycast/sdk-ts',
230+
origin_version: '0.3.1',
231+
},
232+
properties: {
233+
workspace_id: 'ws_123',
234+
query_length: 2,
235+
result_count: 1,
236+
},
210237
},
211-
properties: {
212-
workspace_id: 'ws_123',
213-
query_length: 4,
214-
result_count: 2,
238+
);
239+
const p2 = captureInternalTelemetryBatched(
240+
{
241+
ENVIRONMENT: 'production',
242+
POSTHOG_API_KEY: 'phc_test',
243+
POSTHOG_HOST: 'https://us.i.posthog.com/',
244+
} as any,
245+
{
246+
event: 'relaycast_server_search_executed',
247+
distinct_id: workspaceDistinctId('ws_123'),
248+
origin: {
249+
origin_surface: 'sdk',
250+
origin_client: '@relaycast/sdk-ts',
251+
origin_version: '0.3.1',
252+
},
253+
properties: {
254+
workspace_id: 'ws_123',
255+
query_length: 4,
256+
result_count: 2,
257+
},
215258
},
216-
});
259+
);
217260

218-
await flushInternalTelemetryBatchesForTests();
219261
await Promise.all([p1, p2]);
220262

221-
expect(fetchMock).toHaveBeenCalledTimes(1);
222-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
223-
expect(url).toBe('https://us.i.posthog.com/batch/');
224-
expect(init.method).toBe('POST');
225-
226-
const payload = JSON.parse(String(init.body));
227-
expect(Array.isArray(payload.batch)).toBe(true);
228-
expect(payload.batch).toHaveLength(2);
263+
// SDK handles batching internally — we just verify both events were captured
264+
expect(mockCapture).toHaveBeenCalledTimes(2);
229265
});
230266
});

packages/server/src/lib/logger.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Context } from 'hono';
22
import type { AppEnv, CloudflareBindings } from '../env.js';
3+
import { getPostHogClient, telemetryEnabled } from './posthog.js';
34

45
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
56
type LogFields = Record<string, unknown>;
@@ -321,43 +322,37 @@ export interface CaptureExceptionOptions {
321322
}
322323

323324
/**
324-
* Sends a `$exception` event to PostHog Error Tracking.
325+
* Sends a `$exception` event to PostHog Error Tracking via the PostHog SDK.
325326
*
326-
* Returns a promise that resolves once the HTTP request completes (best-effort).
327-
* Safe to fire-and-forget or pass to `waitUntil`.
327+
* The returned promise resolves after the SDK has flushed the event, so call
328+
* sites can pass it to `waitUntil` to keep the isolate alive until delivery.
328329
*/
329330
export async function captureException(
330331
env: CloudflareBindings,
331332
error: unknown,
332333
options: CaptureExceptionOptions = {},
333334
): Promise<void> {
335+
if (!telemetryEnabled(env)) return;
334336
const apiKey = env.POSTHOG_API_KEY;
335337
if (!apiKey) return;
336338

337339
const exceptionList = buildExceptionList(error);
338-
const payload = {
339-
api_key: apiKey,
340-
event: '$exception',
341-
distinct_id: options.distinctId ?? SERVICE_NAME,
342-
properties: {
343-
$exception_list: exceptionList,
344-
$exception_type: exceptionList[0]?.type,
345-
$exception_message: exceptionList[0]?.value,
346-
$exception_level: 'error',
347-
service_name: SERVICE_NAME,
348-
environment: env.ENVIRONMENT,
349-
app_version: getAppVersion(env),
350-
...(options.properties ?? {}),
351-
},
352-
timestamp: new Date().toISOString(),
340+
const client = getPostHogClient(env, apiKey);
341+
342+
const additionalProperties: Record<string, unknown> = {
343+
$exception_list: exceptionList,
344+
$exception_type: exceptionList[0]?.type,
345+
$exception_message: exceptionList[0]?.value,
346+
$exception_level: 'error',
347+
service_name: SERVICE_NAME,
348+
environment: env.ENVIRONMENT,
349+
app_version: getAppVersion(env),
350+
...(options.properties ?? {}),
353351
};
354352

353+
client.captureException(error, options.distinctId ?? SERVICE_NAME, additionalProperties);
355354
try {
356-
await fetch(`${getPostHogHost(env)}/capture/`, {
357-
method: 'POST',
358-
headers: { 'Content-Type': 'application/json' },
359-
body: JSON.stringify(payload),
360-
});
355+
await client.flush();
361356
} catch {
362357
// Best effort — never break request handling for telemetry.
363358
}

0 commit comments

Comments
 (0)