Skip to content

Commit a91b07b

Browse files
feat(observability): forward Winston error logs to PostHog Error Tracking (closes #3651) (#3654)
* feat(observability): add PostHogErrorTransport for Winston→PostHog error forwarding * feat(observability): wire PostHogErrorTransport in logger when errorTracking=true * feat(observability): dedup PostHog $exception captures via err.posthogCaptured flag * test(observability): end-to-end logger.error → PostHog \$exception
1 parent 48766e5 commit a91b07b

7 files changed

Lines changed: 263 additions & 0 deletions

lib/services/errorTracker.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const setupExpressErrorHandler = (app) => {
5252
? String(req.user.id)
5353
: 'anonymous';
5454
captureException(err, { distinctId, requestId: req.id });
55+
if (err && typeof err === 'object') err.posthogCaptured = true;
5556
next(err);
5657
});
5758
};

lib/services/logger.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import chalk from 'chalk';
33
import fs from 'fs';
44
import winston from 'winston';
55
import config from '../../config/index.js';
6+
import { PostHogErrorTransport } from './logger.posthog.transport.js';
67

78
// list of valid formats for the logging
89
const validFormats = ['combined', 'common', 'dev', 'short', 'tiny', 'custom'];
@@ -38,6 +39,10 @@ const logger = new winston.createLogger({
3839
exitOnError: false,
3940
});
4041

42+
if (config?.analytics?.posthog?.errorTracking === true) {
43+
logger.add(new PostHogErrorTransport());
44+
}
45+
4146
// A stream object with a write function that will call the built-in winston
4247
// logger.info() function.
4348
// Useful for integrating with stream-related mechanism like Morgan's stream
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Transport from 'winston-transport';
2+
import errorTracker from './errorTracker.js';
3+
4+
/**
5+
* Winston transport that forwards `error`-level (and above) logs to
6+
* PostHog Error Tracking via the existing errorTracker service.
7+
*
8+
* Dedup with the Express 4-arg error middleware: errors that have
9+
* already been captured (marked `err.posthogCaptured = true` by
10+
* `errorTracker.setupExpressErrorHandler`) are skipped so the same
11+
* exception doesn't land twice in PostHog.
12+
*
13+
* Safe-by-default: the transport's `log()` swallows any throw from
14+
* the underlying capture call so application logging never breaks
15+
* when PostHog is misconfigured or unreachable.
16+
*/
17+
export class PostHogErrorTransport extends Transport {
18+
constructor(opts = {}) {
19+
super({ ...opts, level: opts.level ?? 'error' });
20+
}
21+
22+
log(info, callback) {
23+
setImmediate(() => this.emit('logged', info));
24+
25+
const sourceErr = info instanceof Error
26+
? info
27+
: info?.error instanceof Error
28+
? info.error
29+
: null;
30+
31+
if (sourceErr?.posthogCaptured) {
32+
callback();
33+
return;
34+
}
35+
36+
const err = sourceErr ?? Object.assign(
37+
new Error(info?.message ?? 'logger.error'),
38+
info?.stack ? { stack: info.stack } : {},
39+
);
40+
41+
try {
42+
errorTracker.captureException(err, {
43+
distinctId: info?.distinctId,
44+
requestId: info?.requestId,
45+
properties: {
46+
source: 'system',
47+
logMessage: info?.message,
48+
logLevel: info?.level,
49+
},
50+
});
51+
if (sourceErr) sourceErr.posthogCaptured = true;
52+
} catch (_) { /* logging must never break the caller */ }
53+
54+
callback();
55+
}
56+
}
57+
58+
export default PostHogErrorTransport;

lib/services/tests/errorTracker.unit.tests.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,29 @@ describe('errorTracker service unit tests:', () => {
200200
});
201201
});
202202
});
203+
204+
describe('setupExpressErrorHandler — dedup flag:', () => {
205+
test('marks err.posthogCaptured = true after capture so Winston transport skips it', async () => {
206+
jest.unstable_mockModule('../analytics.js', () => ({
207+
default: { captureException: jest.fn(), init: jest.fn().mockResolvedValue() },
208+
}));
209+
jest.unstable_mockModule('../../../config/index.js', () => ({
210+
default: { analytics: { posthog: { enabled: true, key: 'phc_test', errorTracking: true } } },
211+
}));
212+
const mod = await import('../errorTracker.js');
213+
const errorTracker = mod.default;
214+
215+
const handlers = [];
216+
const app = { use: (fn) => handlers.push(fn) };
217+
errorTracker.setupExpressErrorHandler(app);
218+
219+
const middleware = handlers[handlers.length - 1];
220+
const err = new Error('boom');
221+
const next = jest.fn();
222+
middleware(err, { user: { _id: 'u1' }, id: 'r1' }, {}, next);
223+
224+
expect(err.posthogCaptured).toBe(true);
225+
expect(next).toHaveBeenCalledWith(err);
226+
});
227+
});
203228
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
2+
3+
describe('logger.error → PostHog $exception (integration):', () => {
4+
let logger;
5+
let mockPostHogInstance;
6+
7+
beforeEach(async () => {
8+
jest.resetModules();
9+
mockPostHogInstance = {
10+
capture: jest.fn(),
11+
identify: jest.fn(),
12+
groupIdentify: jest.fn(),
13+
getFeatureFlag: jest.fn().mockResolvedValue(undefined),
14+
isFeatureEnabled: jest.fn().mockResolvedValue(undefined),
15+
shutdown: jest.fn().mockResolvedValue(undefined),
16+
};
17+
jest.unstable_mockModule('posthog-node', () => ({
18+
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
19+
}));
20+
jest.unstable_mockModule('../../../config/index.js', () => ({
21+
default: {
22+
analytics: { posthog: { enabled: true, key: 'phc_test', host: 'https://eu.i.posthog.com', errorTracking: true, appTag: 'devkit' } },
23+
logger: { level: 'info' },
24+
log: { level: 'info', fileLogger: {} },
25+
},
26+
}));
27+
28+
const analyticsMod = await import('../analytics.js');
29+
await analyticsMod.default.init();
30+
const loggerMod = await import('../logger.js');
31+
logger = loggerMod.default ?? loggerMod.logger;
32+
});
33+
34+
afterEach(() => { jest.restoreAllMocks(); });
35+
36+
test('logger.error(message, { error }) emits a single $exception event', () => {
37+
const err = new Error('payment failed');
38+
logger.error('Charge failed for user', { error: err });
39+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
40+
event: '$exception',
41+
properties: expect.objectContaining({
42+
$exception_message: 'payment failed',
43+
$exception_type: 'Error',
44+
logMessage: 'Charge failed for user',
45+
logLevel: 'error',
46+
source: 'system',
47+
}),
48+
}));
49+
});
50+
51+
test('logger.error(err) directly emits a single $exception event', () => {
52+
const err = new Error('boom');
53+
logger.error(err);
54+
expect(mockPostHogInstance.capture).toHaveBeenCalledTimes(1);
55+
});
56+
57+
test('error already marked posthogCaptured does NOT re-emit', () => {
58+
const err = Object.assign(new Error('boom'), { posthogCaptured: true });
59+
logger.error('skipped', { error: err });
60+
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
61+
});
62+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
2+
3+
describe('PostHogErrorTransport:', () => {
4+
let PostHogErrorTransport;
5+
let captureExceptionMock;
6+
7+
beforeEach(async () => {
8+
jest.resetModules();
9+
captureExceptionMock = jest.fn();
10+
jest.unstable_mockModule('../errorTracker.js', () => ({
11+
default: { captureException: captureExceptionMock },
12+
}));
13+
const mod = await import('../logger.posthog.transport.js');
14+
PostHogErrorTransport = mod.PostHogErrorTransport;
15+
});
16+
17+
afterEach(() => { jest.restoreAllMocks(); });
18+
19+
test('only listens at error level by default', () => {
20+
const t = new PostHogErrorTransport();
21+
expect(t.level).toBe('error');
22+
});
23+
24+
test('forwards info-as-Error to errorTracker.captureException', () => {
25+
const t = new PostHogErrorTransport();
26+
const err = new Error('boom');
27+
const cb = jest.fn();
28+
t.log(err, cb);
29+
expect(captureExceptionMock).toHaveBeenCalledWith(err, expect.objectContaining({
30+
properties: expect.objectContaining({ source: 'system', logLevel: undefined }),
31+
}));
32+
expect(cb).toHaveBeenCalled();
33+
});
34+
35+
test('extracts info.error when info is a plain object', () => {
36+
const t = new PostHogErrorTransport();
37+
const err = new Error('boom');
38+
const info = { level: 'error', message: 'something failed', error: err, requestId: 'r1' };
39+
const cb = jest.fn();
40+
t.log(info, cb);
41+
expect(captureExceptionMock).toHaveBeenCalledWith(err, expect.objectContaining({
42+
requestId: 'r1',
43+
properties: expect.objectContaining({
44+
source: 'system',
45+
logMessage: 'something failed',
46+
logLevel: 'error',
47+
}),
48+
}));
49+
});
50+
51+
test('wraps string-only info into a synthetic Error', () => {
52+
const t = new PostHogErrorTransport();
53+
const info = { level: 'error', message: 'no error object here' };
54+
const cb = jest.fn();
55+
t.log(info, cb);
56+
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
57+
const [err] = captureExceptionMock.mock.calls[0];
58+
expect(err).toBeInstanceOf(Error);
59+
expect(err.message).toBe('no error object here');
60+
});
61+
62+
test('skips when err.posthogCaptured is true (dedup with express middleware)', () => {
63+
const t = new PostHogErrorTransport();
64+
const err = Object.assign(new Error('boom'), { posthogCaptured: true });
65+
const cb = jest.fn();
66+
t.log(err, cb);
67+
expect(captureExceptionMock).not.toHaveBeenCalled();
68+
expect(cb).toHaveBeenCalled();
69+
});
70+
71+
test('sets posthogCaptured = true on the source Error after forwarding', () => {
72+
const t = new PostHogErrorTransport();
73+
const err = new Error('boom');
74+
const cb = jest.fn();
75+
t.log(err, cb);
76+
expect(err.posthogCaptured).toBe(true);
77+
});
78+
79+
test('callback is always invoked even on captureException throw', () => {
80+
captureExceptionMock.mockImplementation(() => { throw new Error('SDK down'); });
81+
const t = new PostHogErrorTransport();
82+
const err = new Error('boom');
83+
const cb = jest.fn();
84+
expect(() => t.log(err, cb)).not.toThrow();
85+
expect(cb).toHaveBeenCalled();
86+
});
87+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { jest, beforeEach, describe, test, expect } from '@jest/globals';
2+
3+
describe('logger.js — PostHogErrorTransport wiring:', () => {
4+
beforeEach(() => { jest.resetModules(); });
5+
6+
test('PostHogErrorTransport is registered when analytics.posthog.errorTracking=true', async () => {
7+
jest.unstable_mockModule('../../../config/index.js', () => ({
8+
default: { analytics: { posthog: { errorTracking: true, enabled: true, key: 'phc_test' } }, logger: { level: 'info' }, log: { level: 'info', fileLogger: {} } },
9+
}));
10+
const mod = await import('../logger.js');
11+
const logger = mod.default ?? mod.logger;
12+
const hasTransport = logger.transports.some((t) => t.constructor.name === 'PostHogErrorTransport');
13+
expect(hasTransport).toBe(true);
14+
});
15+
16+
test('PostHogErrorTransport is NOT registered when errorTracking=false', async () => {
17+
jest.unstable_mockModule('../../../config/index.js', () => ({
18+
default: { analytics: { posthog: { errorTracking: false } }, logger: { level: 'info' }, log: { level: 'info', fileLogger: {} } },
19+
}));
20+
const mod = await import('../logger.js');
21+
const logger = mod.default ?? mod.logger;
22+
const hasTransport = logger.transports.some((t) => t.constructor.name === 'PostHogErrorTransport');
23+
expect(hasTransport).toBe(false);
24+
});
25+
});

0 commit comments

Comments
 (0)