Skip to content

Commit 131eddf

Browse files
feat(monitoring): single-source PostHog Error Tracking + CLI User-Agent middleware (#3640)
* feat(monitoring): single-source PostHog Error Tracking + CLI UA middleware - Remove @sentry/node dep + delete lib/services/sentry.js + sentry tests - Simplify errorTracker.js: PostHog-only fan-out, drop Sentry references - Drop sentryService.init() from app.js bootstrap and shutdown - Drop sentry config from development.config.js + production.config.js - Flip posthog.errorTracking default to true - Replace monitoring/Sentry readiness row with errorTracking/PostHog row - NEW posthog-context.middleware.js: parse @trawlme/cli/<ver> UA header - Wire posthogContextMiddleware in express.js after CORS, before routes - analytics.capture() accepts optional req to merge req.posthogContext - NEW posthog-context.middleware.unit.tests.js (7 cases) - NEW home.service.unit.tests.js (readiness unit tests) - Update errorTracker.unit.tests.js and home.integration.tests.js * chore(config): drop leftover sentry block from test.config.js DeepSeek pre-merge audit P1 finding: development.config.js + production.config.js Sentry blocks were dropped, but test.config.js was missed. No functional break (nothing reads it post-removal), but stale config invites confusion. * docs(migrations) + test(analytics): address P2 audit findings DeepSeek pre-merge audit P2 findings: - MIGRATIONS.md: add v10 "Sentry removed" section so /update-project sub-agents understand the propagation expectations (env var drops, @sentry/* dep cleanup, config override removal, posthog.errorTracking opt-in semantics). - analytics.capture.unit.tests.js: add 3 tests covering the new req.posthogContext injection path (merges into defaults, user properties win on conflict, absent req is backward-compat — no source/cli_version leak).
1 parent 3eee971 commit 131eddf

19 files changed

Lines changed: 351 additions & 1154 deletions

MIGRATIONS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Sentry removed — PostHog Error Tracking is now sole source (2026-05-10)
8+
9+
The `@sentry/node` integration shipped in 2026-03-26 (still documented below as **PostHog Analytics (2026-03-26)** + the now-removed Sentry monitoring section) is dropped. Error capture moves entirely to PostHog Error Tracking via `posthog.capture('$exception', ...)`.
10+
11+
### What changed
12+
13+
- **Deleted** : `lib/services/sentry.js` + its unit tests + the `@sentry/node` dependency.
14+
- **`lib/services/errorTracker.js`** simplified to PostHog-only path. The `captureExceptionPostHogOnly` fan-out helper is removed (collapsed into `captureException` since there is no longer a double-reporting risk from a parallel Sentry Express handler).
15+
- **`lib/app.js`** — Sentry init/shutdown calls removed from bootstrap and shutdown paths.
16+
- **`config/defaults/{development,production,test}.config.js`**`sentry: { ... }` blocks deleted.
17+
- **`config/defaults/development.config.js` + `production.config.js`**`posthog.errorTracking` default flipped from `false``true`. Error capture is now enabled by default whenever `posthog.apiKey` is set.
18+
- **`modules/home/services/home.service.js`** `getReadinessStatus()` — the `monitoring` row (Sentry presence) is replaced by an `errorTracking` row that gates on `posthog.apiKey && posthog.errorTracking === true`.
19+
- **NEW `lib/middlewares/posthog-context.middleware.js`** — parses the `User-Agent` header, attaches `req.posthogContext = { source: 'cli'|'web', cli_version? }` for CLI-source attribution. Wired in `lib/services/express.js` after CORS / before routes.
20+
- **`lib/services/analytics.js`** `capture()` accepts an optional `req` param. When provided, `req.posthogContext` is merged into event defaults so that CLI-originated requests carry `source` + `cli_version` automatically. Backward-compatible: callers that omit `req` see no behaviour change.
21+
22+
### Action required for downstream projects (`/update-project`)
23+
24+
1. **Drop env vars** `SENTRY_DSN` + any `SENTRY_*` references from `.env`, K8s manifests (`clusters/*/apps/*-node.yaml`), `.env.example`, deploy scripts, and CI secrets — they are no longer read.
25+
2. **Drop `@sentry/*` deps** from project `package.json` if pinned downstream. Run `npm install` to regen lockfile.
26+
3. **Remove project `config/defaults/*.config.js` overrides** of the `sentry: { ... }` block — they were either referencing the now-removed config path (no-op merge) or overriding fields that no longer exist.
27+
4. **Confirm `posthog.errorTracking`** : if downstream config explicitly sets `posthog.errorTracking: false` to suppress capture, that override still wins via deepmerge. To opt into error tracking, set it to `true` (or rely on the new default if you remove the override).
28+
5. **Optional — wire `req` into existing `capture()` callers** : if you want CLI-source attribution on existing events, change `capture({ distinctId, event, properties })``capture({ distinctId, event, properties, req })`. Without this opt-in, events still capture correctly but lack the `source`/`cli_version` properties.
29+
30+
### Why
31+
32+
Cf `infra/docs/superpowers/plans/2026-05-10-posthog-observability-followups.md` (decision matrix). PostHog Error Tracking is GA, free tier covers 100k exceptions/mo, and the single-tracker setup eliminates dual-config drift + cross-tool funnel friction.
33+
34+
---
35+
736
## Test DB isolation: per-pid Mongo database default + globalTeardown (2026-04-24)
837

938
Default test database is now `mongodb://127.0.0.1:27017/NodeTest_${process.pid}` instead of the shared `NodeTest`. Concurrent jest invocations (e.g. multiple agent worktrees running `npm run test:coverage` in parallel) get isolated databases, eliminating the 401 / 404 / 422 / `MongoPoolClosedError` flake patterns documented in trawl_node#980.

config/defaults/development.config.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,14 @@ const config = {
7979
trust: {
8080
proxy: false,
8181
},
82-
sentry: {
83-
dsn: process.env.DEVKIT_NODE_sentry_dsn || '',
84-
environment: process.env.DEVKIT_NODE_sentry_environment || 'development',
85-
enabled: false,
86-
},
8782
posthog: {
8883
enabled: false, // set to true + apiKey to activate (default off, no breakage on unconfigured projects)
8984
// apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '',
9085
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://eu.i.posthog.com',
9186
// appTag: process.env.DEVKIT_NODE_posthog_appTag ?? '', // e.g. 'trawl', 'comes' — auto-injected on every capture
9287
flushAt: 20,
9388
flushInterval: 10000,
94-
errorTracking: false, // opt-in: capture exceptions to PostHog (default: off)
89+
errorTracking: true, // PostHog Error Tracking — active when posthog.apiKey is set
9590
autoCapture: false, // opt-in: auto-capture api_request events (default: off)
9691
},
9792
domain: '',

config/defaults/production.config.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,6 @@ const config = {
5555
json: true,
5656
level: 'info',
5757
},
58-
sentry: {
59-
dsn: process.env.DEVKIT_NODE_sentry_dsn || '',
60-
environment: 'production',
61-
enabled: !!process.env.DEVKIT_NODE_sentry_dsn,
62-
},
6358
};
6459

6560
export default config;

config/defaults/test.config.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ const config = {
2626
enabled: true,
2727
ttlDays: 1,
2828
},
29-
sentry: {
30-
dsn: '',
31-
enabled: false,
32-
},
3329
organizations: {
3430
enabled: false,
3531
domainMatching: false,

lib/app.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import express from './services/express.js';
1111
import mongooseService from './services/mongoose.js';
1212
import migrations from './services/migrations.js';
1313
import AnalyticsService from './services/analytics.js';
14-
import SentryService from './services/sentry.js';
1514

1615
// Establish a MongoDB connection, instantiating all models
1716
const startMongoose = async () => {
@@ -65,7 +64,6 @@ const bootstrap = async () => {
6564
let app;
6665

6766
try {
68-
await SentryService.init();
6967
db = await startMongoose();
7068
// DEVKIT_MIGRATIONS_RAN is set by jest.globalSetup.js before any vm context
7169
// is created, so it persists across all test suite vm context teardown cycles.
@@ -176,7 +174,6 @@ const shutdown = async (server) => {
176174
try {
177175
const value = await server;
178176
await AnalyticsService.shutdown();
179-
await SentryService.shutdown();
180177
await mongooseService.disconnect();
181178
value.http.close((err) => {
182179
if (err) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* PostHog context middleware.
3+
*
4+
* Parses the `User-Agent` header to determine the request source and
5+
* attaches a `posthogContext` object to the request for downstream use
6+
* (e.g. enriching analytics events with CLI vs web attribution).
7+
*
8+
* Detection: `@trawlme/cli/<version>` in UA → source: 'cli', cli_version: '<version>'
9+
* Everything else (browser, curl, unknown) → source: 'web'
10+
*/
11+
12+
const CLI_UA_RE = /@trawlme\/cli\/(\S+)/;
13+
14+
/**
15+
* Attach PostHog context to every request based on the User-Agent header.
16+
*
17+
* Sets `req.posthogContext` with:
18+
* - `source`: `'cli'` when `@trawlme/cli/<version>` is detected, `'web'` otherwise
19+
* - `cli_version`: CLI version string (only present when source is `'cli'`)
20+
*
21+
* @param {import('express').Request} req - Express request
22+
* @param {import('express').Response} _res - Express response (unused)
23+
* @param {import('express').NextFunction} next - Next middleware
24+
* @returns {void}
25+
*/
26+
export const posthogContextMiddleware = (req, _res, next) => {
27+
const ua = req.get('User-Agent') || '';
28+
const match = ua.match(CLI_UA_RE);
29+
req.posthogContext = match
30+
? { source: 'cli', cli_version: match[1] }
31+
: { source: 'web' };
32+
next();
33+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
import { posthogContextMiddleware } from '../posthog-context.middleware.js';
6+
7+
/**
8+
* Unit tests for posthog-context middleware.
9+
* Verifies User-Agent parsing for CLI vs web source attribution:
10+
* 1. CLI UA with version → source:'cli', cli_version:'<version>'
11+
* 2. CLI UA without explicit version segment → source:'cli' fallback
12+
* 3. Web browser UA → source:'web'
13+
* 4. Missing UA → source:'web'
14+
*/
15+
describe('posthogContextMiddleware unit tests:', () => {
16+
let req;
17+
let res;
18+
let next;
19+
20+
beforeEach(() => {
21+
req = {
22+
get: jest.fn(),
23+
};
24+
res = {};
25+
next = jest.fn();
26+
});
27+
28+
test('CLI UA with version → source:cli + cli_version', () => {
29+
req.get.mockReturnValue('@trawlme/cli/1.2.3');
30+
posthogContextMiddleware(req, res, next);
31+
32+
expect(req.posthogContext).toEqual({ source: 'cli', cli_version: '1.2.3' });
33+
expect(next).toHaveBeenCalledTimes(1);
34+
});
35+
36+
test('CLI UA with pre-release version → source:cli + cli_version', () => {
37+
req.get.mockReturnValue('@trawlme/cli/2.0.0-beta.1 node/22.0.0');
38+
posthogContextMiddleware(req, res, next);
39+
40+
expect(req.posthogContext).toEqual({ source: 'cli', cli_version: '2.0.0-beta.1' });
41+
expect(next).toHaveBeenCalledTimes(1);
42+
});
43+
44+
test('web browser UA → source:web (no cli_version)', () => {
45+
req.get.mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36');
46+
posthogContextMiddleware(req, res, next);
47+
48+
expect(req.posthogContext).toEqual({ source: 'web' });
49+
expect(req.posthogContext).not.toHaveProperty('cli_version');
50+
expect(next).toHaveBeenCalledTimes(1);
51+
});
52+
53+
test('missing User-Agent header → source:web (no cli_version)', () => {
54+
req.get.mockReturnValue(undefined);
55+
posthogContextMiddleware(req, res, next);
56+
57+
expect(req.posthogContext).toEqual({ source: 'web' });
58+
expect(req.posthogContext).not.toHaveProperty('cli_version');
59+
expect(next).toHaveBeenCalledTimes(1);
60+
});
61+
62+
test('empty User-Agent header → source:web', () => {
63+
req.get.mockReturnValue('');
64+
posthogContextMiddleware(req, res, next);
65+
66+
expect(req.posthogContext).toEqual({ source: 'web' });
67+
expect(next).toHaveBeenCalledTimes(1);
68+
});
69+
70+
test('curl UA → source:web', () => {
71+
req.get.mockReturnValue('curl/8.7.1');
72+
posthogContextMiddleware(req, res, next);
73+
74+
expect(req.posthogContext).toEqual({ source: 'web' });
75+
expect(next).toHaveBeenCalledTimes(1);
76+
});
77+
78+
test('always calls next()', () => {
79+
req.get.mockReturnValue('@trawlme/cli/0.1.0');
80+
posthogContextMiddleware(req, res, next);
81+
82+
expect(next).toHaveBeenCalledWith(); // called with no args (no error)
83+
});
84+
});

lib/services/analytics.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,24 @@ const track = (distinctId, event, properties, groups) => {
6161
* into every event. Custom properties take precedence over defaults.
6262
* No-op when client is not initialised, distinctId or event are missing.
6363
*
64+
* When `req` is supplied and `req.posthogContext` is set (by posthogContextMiddleware),
65+
* its properties (source, cli_version) are merged into event properties so that
66+
* CLI-originated requests are attributed correctly.
67+
*
6468
* @param {Object} params - Event parameters
6569
* @param {string} params.distinctId - User or anonymous identifier
6670
* @param {string} params.event - Event name
6771
* @param {Object} [params.properties] - Additional event properties (win over defaults)
72+
* @param {import('express').Request} [params.req] - Optional Express request for context injection
6873
* @returns {void}
6974
*/
70-
const capture = ({ distinctId, event, properties = {} } = {}) => {
75+
const capture = ({ distinctId, event, properties = {}, req } = {}) => {
7176
if (!client) return;
7277
if (!distinctId || !event) return;
7378
const defaults = {
7479
env: process.env.NODE_ENV || 'development',
7580
...(_appTag ? { app: _appTag } : {}),
81+
...(req?.posthogContext ?? {}),
7682
};
7783
try {
7884
client.capture({ distinctId, event, properties: { ...defaults, ...properties } });

lib/services/errorTracker.js

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22
* Module dependencies
33
*/
44
import config from '../../config/index.js';
5-
import sentryService from './sentry.js';
65
import analyticsService from './analytics.js';
76

87
/**
9-
* Capture an exception, fanning out to all active trackers.
8+
* Capture an exception in PostHog.
109
*
11-
* - Sentry : active when `config.sentry.dsn` is set (and `enabled !== false`)
12-
* - PostHog : active when `config.posthog.apiKey` is set AND
13-
* `config.posthog.errorTracking === true`
10+
* Active when `config.posthog.apiKey` is set AND
11+
* `config.posthog.errorTracking === true`.
1412
*
15-
* Safe no-op when neither tracker is configured.
13+
* Safe no-op when PostHog is not configured.
1614
*
1715
* @param {Error} err - Error to capture
1816
* @param {Object} [ctx] - Optional context attached to the event
@@ -21,78 +19,45 @@ import analyticsService from './analytics.js';
2119
* @returns {void}
2220
*/
2321
const captureException = (err, ctx = {}) => {
24-
// Sentry fan-out
25-
const sentryConfig = config?.sentry ?? {};
26-
if (sentryConfig.dsn && sentryConfig.enabled !== false) {
27-
sentryService.captureException(err);
28-
}
29-
30-
// PostHog fan-out — only when errorTracking is explicitly opted-in
31-
const posthogConfig = config?.posthog ?? {};
32-
if (posthogConfig.apiKey && posthogConfig.errorTracking === true) {
33-
analyticsService.captureException(err, ctx);
34-
}
35-
};
36-
37-
/**
38-
* Capture an exception in PostHog only (skips Sentry).
39-
* Used inside the Express error middleware where Sentry's own Express handler
40-
* has already reported the error — calling captureException() here would
41-
* report it to Sentry a second time.
42-
*
43-
* @param {Error} err - Error to capture
44-
* @param {Object} [ctx] - Optional context
45-
* @returns {void}
46-
*/
47-
const captureExceptionPostHogOnly = (err, ctx = {}) => {
4822
const posthogConfig = config?.posthog ?? {};
4923
if (posthogConfig.apiKey && posthogConfig.errorTracking === true) {
5024
analyticsService.captureException(err, ctx);
5125
}
5226
};
5327

5428
/**
55-
* Initialise all configured trackers (Sentry + PostHog).
56-
* Safe to call when neither is configured.
29+
* Initialise PostHog analytics (error tracking backend).
30+
* Safe to call when PostHog is not configured.
5731
* @returns {Promise<void>}
5832
*/
5933
const init = async () => {
60-
await Promise.all([
61-
sentryService.init(),
62-
analyticsService.init(),
63-
]);
34+
await analyticsService.init();
6435
};
6536

6637
/**
67-
* Set up Express error handling for all active trackers.
38+
* Set up Express error handling for PostHog Error Tracking.
6839
*
6940
* Must be called after all routes are mounted.
70-
* Mounts Sentry's Express error handler first (captures structured request
71-
* context), then a PostHog-only fan-out middleware to avoid double-reporting
72-
* to Sentry (which is already covered by Sentry's own Express handler).
41+
* Mounts a 4-arg error middleware that captures the exception
42+
* to PostHog and passes it down to the next handler.
7343
*
7444
* @param {import('express').Express} app - Express application instance
7545
*/
7646
const setupExpressErrorHandler = (app) => {
77-
// Sentry Express handler (structured request/response context)
78-
sentryService.setupExpressErrorHandler(app);
79-
80-
// PostHog-only fan-out middleware — Sentry already handled above
8147
// `_res` is required for Express to recognise this as a 4-arg error handler
8248
app.use((err, req, _res, next) => {
8349
const distinctId = req.user?._id
8450
? String(req.user._id)
8551
: req.user?.id
8652
? String(req.user.id)
8753
: 'anonymous';
88-
captureExceptionPostHogOnly(err, { distinctId, requestId: req.id });
54+
captureException(err, { distinctId, requestId: req.id });
8955
next(err);
9056
});
9157
};
9258

9359
export default {
9460
init,
9561
captureException,
96-
captureExceptionPostHogOnly,
9762
setupExpressErrorHandler,
9863
};

lib/services/express.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import config from '../../config/index.js';
2121
import guidesHelper from '../helpers/guides.js';
2222
import logger from './logger.js';
2323
import requestId from '../middlewares/requestId.js';
24+
import { posthogContextMiddleware } from '../middlewares/posthog-context.middleware.js';
2425
import errorTracker from './errorTracker.js';
2526
import AnalyticsService from './analytics.js';
2627
import analyticsMiddleware from '../middlewares/analytics.js';
@@ -300,8 +301,10 @@ const init = async () => {
300301
} catch (err) {
301302
logger.warn('[analytics] init failed, running without analytics: %s', err.message);
302303
}
303-
// Initialize Express middleware
304+
// Initialize Express middleware (includes CORS)
304305
initMiddleware(app);
306+
// Attach PostHog context (source: 'cli'|'web') to req after CORS, before routes
307+
app.use(posthogContextMiddleware);
305308
// Initialize Helmet security headers
306309
initHelmetHeaders(app);
307310
// Initialize modules static client routes,
@@ -314,7 +317,7 @@ const init = async () => {
314317
await initModulesServerPolicies(app);
315318
// Initialize modules server routes
316319
await initModulesServerRoutes(app);
317-
// Mount error tracker handler (Sentry + fan-out) — must be after routes
320+
// Mount error tracker handler (PostHog Error Tracking) — must be after routes
318321
errorTracker.setupExpressErrorHandler(app);
319322
// Initialize error routes
320323
initErrorRoutes(app);

0 commit comments

Comments
 (0)