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
29 changes: 29 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ Breaking changes and upgrade notes for downstream projects.

---

## Sentry removed — PostHog Error Tracking is now sole source (2026-05-10)

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', ...)`.

### What changed

- **Deleted** : `lib/services/sentry.js` + its unit tests + the `@sentry/node` dependency.
- **`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).
- **`lib/app.js`** — Sentry init/shutdown calls removed from bootstrap and shutdown paths.
- **`config/defaults/{development,production,test}.config.js`** — `sentry: { ... }` blocks deleted.
- **`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.
- **`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`.
- **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.
- **`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.

### Action required for downstream projects (`/update-project`)

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.
2. **Drop `@sentry/*` deps** from project `package.json` if pinned downstream. Run `npm install` to regen lockfile.
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.
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).
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.

### Why

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.

---

## Test DB isolation: per-pid Mongo database default + globalTeardown (2026-04-24)

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.
Expand Down
7 changes: 1 addition & 6 deletions config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,14 @@ const config = {
trust: {
proxy: false,
},
sentry: {
dsn: process.env.DEVKIT_NODE_sentry_dsn || '',
environment: process.env.DEVKIT_NODE_sentry_environment || 'development',
enabled: false,
},
posthog: {
enabled: false, // set to true + apiKey to activate (default off, no breakage on unconfigured projects)
// apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '',
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://eu.i.posthog.com',
// appTag: process.env.DEVKIT_NODE_posthog_appTag ?? '', // e.g. 'trawl', 'comes' — auto-injected on every capture
flushAt: 20,
flushInterval: 10000,
errorTracking: false, // opt-in: capture exceptions to PostHog (default: off)
errorTracking: true, // PostHog Error Tracking — active when posthog.apiKey is set
autoCapture: false, // opt-in: auto-capture api_request events (default: off)
},
domain: '',
Expand Down
5 changes: 0 additions & 5 deletions config/defaults/production.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,6 @@ const config = {
json: true,
level: 'info',
},
sentry: {
dsn: process.env.DEVKIT_NODE_sentry_dsn || '',
environment: 'production',
enabled: !!process.env.DEVKIT_NODE_sentry_dsn,
},
};

export default config;
4 changes: 0 additions & 4 deletions config/defaults/test.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ const config = {
enabled: true,
ttlDays: 1,
},
sentry: {
dsn: '',
enabled: false,
},
organizations: {
enabled: false,
domainMatching: false,
Expand Down
3 changes: 0 additions & 3 deletions lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import express from './services/express.js';
import mongooseService from './services/mongoose.js';
import migrations from './services/migrations.js';
import AnalyticsService from './services/analytics.js';
import SentryService from './services/sentry.js';

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

try {
await SentryService.init();
db = await startMongoose();
// DEVKIT_MIGRATIONS_RAN is set by jest.globalSetup.js before any vm context
// is created, so it persists across all test suite vm context teardown cycles.
Expand Down Expand Up @@ -176,7 +174,6 @@ const shutdown = async (server) => {
try {
const value = await server;
await AnalyticsService.shutdown();
await SentryService.shutdown();
await mongooseService.disconnect();
value.http.close((err) => {
if (err) {
Expand Down
33 changes: 33 additions & 0 deletions lib/middlewares/posthog-context.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* PostHog context middleware.
*
* Parses the `User-Agent` header to determine the request source and
* attaches a `posthogContext` object to the request for downstream use
* (e.g. enriching analytics events with CLI vs web attribution).
*
* Detection: `@trawlme/cli/<version>` in UA → source: 'cli', cli_version: '<version>'
* Everything else (browser, curl, unknown) → source: 'web'
*/

const CLI_UA_RE = /@trawlme\/cli\/(\S+)/;

/**
* Attach PostHog context to every request based on the User-Agent header.
*
* Sets `req.posthogContext` with:
* - `source`: `'cli'` when `@trawlme/cli/<version>` is detected, `'web'` otherwise
* - `cli_version`: CLI version string (only present when source is `'cli'`)
*
* @param {import('express').Request} req - Express request
* @param {import('express').Response} _res - Express response (unused)
* @param {import('express').NextFunction} next - Next middleware
* @returns {void}
*/
export const posthogContextMiddleware = (req, _res, next) => {
const ua = req.get('User-Agent') || '';
const match = ua.match(CLI_UA_RE);
req.posthogContext = match
? { source: 'cli', cli_version: match[1] }
: { source: 'web' };
next();
};
84 changes: 84 additions & 0 deletions lib/middlewares/tests/posthog-context.middleware.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Module dependencies.
*/
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import { posthogContextMiddleware } from '../posthog-context.middleware.js';

/**
* Unit tests for posthog-context middleware.
* Verifies User-Agent parsing for CLI vs web source attribution:
* 1. CLI UA with version → source:'cli', cli_version:'<version>'
* 2. CLI UA without explicit version segment → source:'cli' fallback
* 3. Web browser UA → source:'web'
* 4. Missing UA → source:'web'
Comment on lines +11 to +13
*/
describe('posthogContextMiddleware unit tests:', () => {
let req;
let res;
let next;

beforeEach(() => {
req = {
get: jest.fn(),
};
res = {};
next = jest.fn();
});

test('CLI UA with version → source:cli + cli_version', () => {
req.get.mockReturnValue('@trawlme/cli/1.2.3');
posthogContextMiddleware(req, res, next);

expect(req.posthogContext).toEqual({ source: 'cli', cli_version: '1.2.3' });
expect(next).toHaveBeenCalledTimes(1);
});

test('CLI UA with pre-release version → source:cli + cli_version', () => {
req.get.mockReturnValue('@trawlme/cli/2.0.0-beta.1 node/22.0.0');
posthogContextMiddleware(req, res, next);

expect(req.posthogContext).toEqual({ source: 'cli', cli_version: '2.0.0-beta.1' });
expect(next).toHaveBeenCalledTimes(1);
});

test('web browser UA → source:web (no cli_version)', () => {
req.get.mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36');
posthogContextMiddleware(req, res, next);

expect(req.posthogContext).toEqual({ source: 'web' });
expect(req.posthogContext).not.toHaveProperty('cli_version');
expect(next).toHaveBeenCalledTimes(1);
});

test('missing User-Agent header → source:web (no cli_version)', () => {
req.get.mockReturnValue(undefined);
posthogContextMiddleware(req, res, next);

expect(req.posthogContext).toEqual({ source: 'web' });
expect(req.posthogContext).not.toHaveProperty('cli_version');
expect(next).toHaveBeenCalledTimes(1);
});

test('empty User-Agent header → source:web', () => {
req.get.mockReturnValue('');
posthogContextMiddleware(req, res, next);

expect(req.posthogContext).toEqual({ source: 'web' });
expect(next).toHaveBeenCalledTimes(1);
});

test('curl UA → source:web', () => {
req.get.mockReturnValue('curl/8.7.1');
posthogContextMiddleware(req, res, next);

expect(req.posthogContext).toEqual({ source: 'web' });
expect(next).toHaveBeenCalledTimes(1);
});

test('always calls next()', () => {
req.get.mockReturnValue('@trawlme/cli/0.1.0');
posthogContextMiddleware(req, res, next);

expect(next).toHaveBeenCalledWith(); // called with no args (no error)
});
});
8 changes: 7 additions & 1 deletion lib/services/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,24 @@ const track = (distinctId, event, properties, groups) => {
* into every event. Custom properties take precedence over defaults.
* No-op when client is not initialised, distinctId or event are missing.
*
* When `req` is supplied and `req.posthogContext` is set (by posthogContextMiddleware),
* its properties (source, cli_version) are merged into event properties so that
* CLI-originated requests are attributed correctly.
*
* @param {Object} params - Event parameters
* @param {string} params.distinctId - User or anonymous identifier
* @param {string} params.event - Event name
* @param {Object} [params.properties] - Additional event properties (win over defaults)
* @param {import('express').Request} [params.req] - Optional Express request for context injection
* @returns {void}
*/
const capture = ({ distinctId, event, properties = {} } = {}) => {
const capture = ({ distinctId, event, properties = {}, req } = {}) => {
if (!client) return;
if (!distinctId || !event) return;
const defaults = {
env: process.env.NODE_ENV || 'development',
...(_appTag ? { app: _appTag } : {}),
...(req?.posthogContext ?? {}),
};
Comment on lines +64 to 82
try {
client.capture({ distinctId, event, properties: { ...defaults, ...properties } });
Expand Down
57 changes: 11 additions & 46 deletions lib/services/errorTracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
* Module dependencies
*/
import config from '../../config/index.js';
import sentryService from './sentry.js';
import analyticsService from './analytics.js';

/**
* Capture an exception, fanning out to all active trackers.
* Capture an exception in PostHog.
*
* - Sentry : active when `config.sentry.dsn` is set (and `enabled !== false`)
* - PostHog : active when `config.posthog.apiKey` is set AND
* `config.posthog.errorTracking === true`
* Active when `config.posthog.apiKey` is set AND
* `config.posthog.errorTracking === true`.
*
* Safe no-op when neither tracker is configured.
* Safe no-op when PostHog is not configured.
*
* @param {Error} err - Error to capture
* @param {Object} [ctx] - Optional context attached to the event
Expand All @@ -21,78 +19,45 @@ import analyticsService from './analytics.js';
* @returns {void}
*/
const captureException = (err, ctx = {}) => {
// Sentry fan-out
const sentryConfig = config?.sentry ?? {};
if (sentryConfig.dsn && sentryConfig.enabled !== false) {
sentryService.captureException(err);
}

// PostHog fan-out — only when errorTracking is explicitly opted-in
const posthogConfig = config?.posthog ?? {};
if (posthogConfig.apiKey && posthogConfig.errorTracking === true) {
analyticsService.captureException(err, ctx);
}
};

/**
* Capture an exception in PostHog only (skips Sentry).
* Used inside the Express error middleware where Sentry's own Express handler
* has already reported the error — calling captureException() here would
* report it to Sentry a second time.
*
* @param {Error} err - Error to capture
* @param {Object} [ctx] - Optional context
* @returns {void}
*/
const captureExceptionPostHogOnly = (err, ctx = {}) => {
const posthogConfig = config?.posthog ?? {};
if (posthogConfig.apiKey && posthogConfig.errorTracking === true) {
analyticsService.captureException(err, ctx);
}
};

/**
* Initialise all configured trackers (Sentry + PostHog).
* Safe to call when neither is configured.
* Initialise PostHog analytics (error tracking backend).
* Safe to call when PostHog is not configured.
* @returns {Promise<void>}
*/
const init = async () => {
await Promise.all([
sentryService.init(),
analyticsService.init(),
]);
await analyticsService.init();
};

/**
* Set up Express error handling for all active trackers.
* Set up Express error handling for PostHog Error Tracking.
*
* Must be called after all routes are mounted.
* Mounts Sentry's Express error handler first (captures structured request
* context), then a PostHog-only fan-out middleware to avoid double-reporting
* to Sentry (which is already covered by Sentry's own Express handler).
* Mounts a 4-arg error middleware that captures the exception
* to PostHog and passes it down to the next handler.
*
* @param {import('express').Express} app - Express application instance
*/
const setupExpressErrorHandler = (app) => {
// Sentry Express handler (structured request/response context)
sentryService.setupExpressErrorHandler(app);

// PostHog-only fan-out middleware — Sentry already handled above
// `_res` is required for Express to recognise this as a 4-arg error handler
app.use((err, req, _res, next) => {
const distinctId = req.user?._id
? String(req.user._id)
: req.user?.id
? String(req.user.id)
: 'anonymous';
captureExceptionPostHogOnly(err, { distinctId, requestId: req.id });
captureException(err, { distinctId, requestId: req.id });
next(err);
});
};

export default {
init,
captureException,
captureExceptionPostHogOnly,
setupExpressErrorHandler,
};
7 changes: 5 additions & 2 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import config from '../../config/index.js';
import guidesHelper from '../helpers/guides.js';
import logger from './logger.js';
import requestId from '../middlewares/requestId.js';
import { posthogContextMiddleware } from '../middlewares/posthog-context.middleware.js';
import errorTracker from './errorTracker.js';
import AnalyticsService from './analytics.js';
import analyticsMiddleware from '../middlewares/analytics.js';
Expand Down Expand Up @@ -300,8 +301,10 @@ const init = async () => {
} catch (err) {
logger.warn('[analytics] init failed, running without analytics: %s', err.message);
}
// Initialize Express middleware
// Initialize Express middleware (includes CORS)
initMiddleware(app);
// Attach PostHog context (source: 'cli'|'web') to req after CORS, before routes
app.use(posthogContextMiddleware);
// Initialize Helmet security headers
initHelmetHeaders(app);
// Initialize modules static client routes,
Expand All @@ -314,7 +317,7 @@ const init = async () => {
await initModulesServerPolicies(app);
// Initialize modules server routes
await initModulesServerRoutes(app);
// Mount error tracker handler (Sentry + fan-out) — must be after routes
// Mount error tracker handler (PostHog Error Tracking) — must be after routes
errorTracker.setupExpressErrorHandler(app);
// Initialize error routes
initErrorRoutes(app);
Expand Down
Loading
Loading