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 ![]()
Core already asked for this
Two comments sit right at the method and subscription seams in ddp-server:
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 anunhandledRejection. The emitter doesn’tawaitlisteners — 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 atraceId/spanIdon the invocation via a privateSymbol. Survivesawaitby construction. - Emission = a small, localized diff in
livedata_server.js: a few emit calls at the lifecycle points, each guarded byif (Package['instrumentation'])— the exact pattern already shipped forddp-rate-limiter. Zero overhead when off. - DDP connections = zero core change (the public
Meteor.onConnection/connection.onCloseare already transport-agnostic — SockJS, uWS, raw WS). - Validated with a runtime spike (Meteor 3.4.1): the
traceIdread by the handler equals the one onmethod.startandmethod.end, on both the DDP and server-sideMeteor.callAsyncpaths, and survivesawait. - Prerequisite already shipped: the spike found
Server.applyAsyncbuilt itsMethodInvocationwithout the methodname(the DDP path passes it). That parity fix is up as a standalone, internal-only PR with greenddp-servertests → 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:
- 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.
- 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) ![]()