Meteor needs a standard instrumentation seam — the design is done and de-risked (read-only lifecycle events for methods, publications & DDP)

Meteor has no standard way to observe its own lifecycle. To add structured logging, an APM (Datadog/Honeycomb), OpenTelemetry, Sentry, or even a small dev/debug panel, you have to monkey-patch Meteor.methods, publications, and DDP — privately, fragilely, redundantly, and it breaks across versions.

I’ve taken this past the idea stage: a full design, the technical risk validated with a runtime spike, and the one prerequisite fix already up as a PR. It’s a small, stable, read-only lifecycle instrumentation seam — and the punchline is that it can be built quickly and without risk, because the hard questions are already answered (details at the bottom). Here’s exactly what it looks like.

The seam, not the stack. Core emits standardized events; you build the OTel / logger / Sentry / dev-panel layer on top. None of those define the core API. Core packages emit, consumers listen, nobody mutates behavior :sunglasses:

Core already asked for this

Two comments sit right at the method and subscription seams in ddp-server:

It’d be much better if we had generic hooks where any package can hook into subscription handling, but in the mean while we special case ddp-rate-limiter

It’d be better if we could hook into method handlers better…

ddp-rate-limiter is already special-cased inline at exactly these seams precisely because there is no general hook. This is that general hook — minus the “generic mutable middleware” trap. A userland package can wrap these internals (I have), but it can’t make the seam stable for the ecosystem — and a stable surface that survives version changes is the whole point. (Prior art: Rails’ ActiveSupport::Notifications, which the entire Ruby APM/logging world plugs into.)

What it looks like

Subscribing to the lifecycle

import { Instrumentation } from 'meteor/instrumentation';

Instrumentation.on('method.start',  (event) => {});
Instrumentation.on('method.end',    (event) => {});
Instrumentation.on('method.error',  (event) => {});

Instrumentation.on('publication.start', (event) => {});
Instrumentation.on('publication.ready', (event) => {});
Instrumentation.on('publication.stop',  (event) => {});
Instrumentation.on('publication.error', (event) => {});

Instrumentation.on('ddp.connection.open',  (event) => {});
Instrumentation.on('ddp.connection.close', (event) => {});

A real consumer — “Meteor + Pino” in ~6 lines

import { Instrumentation } from 'meteor/instrumentation';
import pino from 'pino';

const log = pino();
Instrumentation.on('method.end',        (e) => log.info(e,  'method'));
Instrumentation.on('method.error',      (e) => log.error(e, 'method failed'));
Instrumentation.on('publication.ready', (e) => log.info(e,  'sub ready'));
Instrumentation.on('ddp.connection.open',  (e) => log.info(e, 'ddp open'));
Instrumentation.on('ddp.connection.close', (e) => log.info(e, 'ddp close'));

That’s the entire integration — no monkey-patching. An OpenTelemetry adapter is the same shape (method.start → span start, method.end → span end, traceId/spanId already in the payload). So is a Sentry adapter (*.error), or a dev panel.

The event payload

{
  type: 'method.end',          // canonical contract — what Instrumentation.on() matches
  eventName: 'method.end',     // prefixed, exportable name (see "Tag the source" below)
  ts: 1782000000000,
  durationMs: 4,
  traceId, spanId,             // ready for OTel mapping
  connectionId, userId,        // nullable for server-initiated calls
  name: 'tasks.insert',
  argsCount: 1,                // always
  // args / result: present ONLY if you explicitly opt in — a bounded preview, never raw
}

Safe by default — Accounts args are never captured

Arguments and results are not captured by default (only argsCount). And the official Accounts methods are on a hard-coded denylist, so even if you enable capture globally, you can never log a password, a resume token, or a 2FA code:

// Global capture is a bounded, JSON-safe PREVIEW, never raw objects:
Instrumentation.configure({ captureMethodArgs: 'preview' });
//   string/number/bool → included (long strings truncated)
//   object → "[Object]"   array → "[Array(4)]"   Date → ISO   ObjectID → string

// Hard-coded denylist (args AND result suppressed, regardless of config):
//   login · createUser · changePassword · resetPassword · verifyEmail ·
//   enableUser2fa · requestLoginTokenForUser · configureLoginService
// → passwords, digests, resume tokens, OAuth secrets, 2FA codes never leak.

When you do want a payload for a specific method, you declare exactly what’s safe:

Instrumentation.configureMethod('payment.send', {
  captureArgs(args)     { return { amount: args[0]?.amount, currency: args[0]?.currency }; },
  captureResult(result) { return { paymentId: result.paymentId, status: result.status }; },
});

Tag where events come from

Running several apps into one logger/APM? Prefix the source. type stays the canonical contract; eventName is the prefixed, exportable name:

Instrumentation.configure({ eventPrefix: 'admin-app' });
//   event → { type: 'method.end', eventName: 'admin-app.method.end' }

Instrumentation.configure({ eventPrefix: 'meteor' });
//   event → { type: 'method.end', eventName: 'meteor.method.end' }

Instrumentation.on('method.end', …) always matches the canonical type — the prefix is only for export/logging and never complicates subscription.

Context propagation — works with no listener attached

currentContext() gives you the current trace inside any method/publication — even with nothing subscribed — so you can propagate a trace id into anything (a queue, an external HTTP call):

Meteor.methods({
  async 'payment.send'(payload) {
    const ctx = Instrumentation.currentContext();
    // → { traceId, spanId, userId, connectionId, kind: 'method' | 'publication', name }

    await SomeQueue.enqueue('payment.capture', {
      id: payload.id,
      traceId: ctx.traceId,
      parentSpanId: ctx.spanId,
    });
  },
});

Why it’s safe

  • Read-only. Events are read-only snapshots. Listeners can’t change args, results, errors, or control flow. Not a mutable hook system (explicitly not Django signals).
  • Best-effort, non-blocking. Listeners run in try/catch; a throwing or rejecting listener can never break the method/publication and never causes an unhandledRejection. The emitter doesn’t await listeners — a slow listener doesn’t block the request.
  • Lazy / cheap. Payloads are built only when a listener exists for that event; zero overhead when the package isn’t installed.
  • Secure by default. Args off by default; bounded preview never raw; Accounts denylist as above.

Why it can be built quickly and without risk

The barriers are already lifted:

  • Context = zero core change. Meteor 3 already keeps per-invocation context in a shared AsyncLocalStorage (DDP._CurrentMethodInvocation / _CurrentPublicationInvocation). currentContext() just reads it and caches a traceId/spanId on the invocation via a private Symbol. Survives await by construction.
  • Emission = a small, localized diff in livedata_server.js: a few emit calls at the lifecycle points, each guarded by if (Package['instrumentation']) — the exact pattern already shipped for ddp-rate-limiter. Zero overhead when off.
  • DDP connections = zero core change (the public Meteor.onConnection / connection.onClose are already transport-agnostic — SockJS, uWS, raw WS).
  • Validated with a runtime spike (Meteor 3.4.1): the traceId read by the handler equals the one on method.start and method.end, on both the DDP and server-side Meteor.callAsync paths, and survives await.
  • Prerequisite already shipped: the spike found Server.applyAsync built its MethodInvocation without the method name (the DDP path passes it). That parity fix is up as a standalone, internal-only PR with green ddp-server tests → Pass method name to server-side MethodInvocation by dupontbertrand · Pull Request #14478 · meteor/meteor · GitHub

So the remaining work is a focused V1 PR: a new instrumentation package (the emitter, the public API, the Symbol-based context, the redaction defaults) plus the small guarded emit additions in livedata_server.js.

Scope

V1: methods, publications, DDP connections — server-side. The events above, currentContext(), safe-default redaction, Package[...]-guarded emission. No new runtime dependency.

Out of V1 (deliberately): no logger, no OpenTelemetry, no jobs coupling, no metrics backend, no error tracking, no full tracing, no Mongo, no client/Tracker/Blaze.

Future, independent follow-ups (V1 depends on none of them): an OpenTelemetry adapter, a logger adapter, a Sentry adapter, a devtools panel (a consumer), and (if those land officially) job.* / migration.* / storage.upload.* etc. events on the same bus

What I’m looking for here

I’ll open the V1 PR, two quick asks:

  1. What would you build on it? Logging, OpenTelemetry, Sentry, request tracing, a dev panel: if you’ve done any of these on Meteor you’ve patched these internals by hand. Tell me what you’d want, so the event set covers it.
  2. Do the event names and payload fields look right? Once they ship they’re a public contract (renaming an event later is a breaking change) so now’s the moment to push back.

Core team: the comments above already say you want generic hooks here, so the open question isn’t whether, it’s how — is the if (Package['instrumentation']) emit pattern (same as ddp-rate-limiter) the shape you’d want? Point me at it and I’ll send the V1 PR. (Full design + spike write-up ready to share) :+1:

EDIT : PR : feat(instrumentation): read-only lifecycle seam for methods, publications & connections by dupontbertrand · Pull Request #14483 · meteor/meteor · GitHub

it will pretty useful for opentelemetry PR

we should have a config via settings or env, because you can have the pkg installed but enable/disable it, beyond that, I missed a globla eventPrefixs config, you can use it for explicity separate app/process/container for example.