Skip to content

feat(observability): forward Winston error-level logs to PostHog Error Tracking via custom transport #3651

@PierreBrisorgueil

Description

@PierreBrisorgueil

Problem

Today, only unhandled errors caught by the Express 4-arg error middleware (lib/services/errorTracker.js:setupExpressErrorHandler) reach PostHog Error Tracking. Every logger.error(...) call inside a try/catch or a .catch(err => …) lives in stdout only.

Quantified gap:

  • pierreb-devkit/Node : 87 logger.error sites
  • comes-io/trawl_node (downstream after propagation) : 187 sites

Paradox: Vue side already forwards uncaught errors + unhandled rejections via captureException in src/main.js. Node is less instrumented than the frontend.

Spec

Custom Winston transport in lib/services/logger.posthog.transport.js:

import Transport from 'winston-transport';
import errorTracker from './errorTracker.js';

export class PostHogErrorTransport extends Transport {
  constructor(opts = {}) {
    super({ ...opts, level: 'error' });  // error + fatal only
  }
  log(info, callback) {
    setImmediate(() => this.emit('logged', info));
    if (info.posthogCaptured) return callback();  // de-dupe with express middleware
    const err = info instanceof Error ? info
              : info.error instanceof Error ? info.error
              : Object.assign(new Error(info.message), { stack: info.stack });
    errorTracker.captureException(err, {
      distinctId: info.distinctId ?? 'system',
      requestId: info.requestId,
      logMessage: info.message,
      logLevel: info.level,
    });
    callback();
  }
}

Wire in lib/services/logger.js:

if (config.analytics?.posthog?.errorTracking === true) {
  transports.push(new PostHogErrorTransport());
}

Mark the express middleware path so the transport skips dual-capture:

// in errorTracker.setupExpressErrorHandler
captureException(err, { distinctId, requestId: req.id });
err.posthogCaptured = true;
next(err);

Acceptance criteria

  • PostHogErrorTransport class shipped + unit tests (info-as-Error, info-with-error-property, info-as-string-message, dedupe via posthogCaptured flag)
  • Wired in logger.js gated on config.analytics.posthog.errorTracking === true
  • Express error middleware tags err.posthogCaptured = true after capture
  • Integration test: trigger a logger.error('boom', err) from a fake module → assert a single $exception event hits the PostHog client mock
  • Coverage thresholds not lowered
  • README/CHANGELOG entry documenting the dual capture path

Gotchas to address in PR

  1. Double-capture : posthogCaptured flag mitigation. Verify also for the errorTracker.captureException direct call path (out-of-band, no next(err)).
  2. PII : logger.error('Failed to charge ${email}') would leak email to PostHog. Two options for now:
    • Document the convention "no PII in error messages" in CLAUDE.md and accept it pre-launch
    • Add a minimal redactor (regex for emails, JWT shapes, Stripe IDs) — defer if more than a few hours
  3. Volume : 274 sites × N invocations/day. Free tier 100k exceptions/mo should fit pre-PMF. Watch for noisy callers (consider adding a rate limiter in the transport if a single fingerprint exceeds N/min).
  4. Fingerprint : pose \$exception_fingerprint via info.fingerprint ?? hash(err.stack first frame) so PostHog Error Tracking UI groups properly. Currently events don't group well (cf manual validation 2026-05-11).
  5. Loop risk : if PostHog SDK itself throws on capture, transport must NOT re-trigger via Winston. The SDK has internal try/catch already; verify the wrapper doesn't bubble.

Out of scope

  • Forwarding warn/info/debug levels to PostHog Logs (separate product, separate integration, not needed pre-PMF).
  • Sourcemaps upload CI (separate plan section A.2 in infra/docs/superpowers/plans/2026-05-10-posthog-observability-followups.md).

Context

Follow-up to:

Discovered 2026-05-11 during end-to-end validation: PostHog Error Tracking UI showed empty, root cause = no organic unhandled errors + handled errors invisible.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions