+1 (478) 654-9062

Instrumenting a Meteor App: Observability After Kadira and After Fibers

A pattern we see on nearly every assessment: the app has been in production for eight years, it has an incident history, and the only instrumentation is console.log plus whatever the hosting provider graphs. When something gets slow, the team argues about causes from memory. Nobody can answer "which method regressed, and when."

That is a fixable problem, and it is cheap relative to almost everything else on an older Meteor app's to-do list. It is also worth fixing before a Meteor 3 upgrade or a migration off Meteor, for one blunt reason: both of those change performance characteristics, and without a baseline you cannot tell an improvement from a regression. "It feels slower since the upgrade" is the single hardest complaint to act on.

Why generic monitoring under-serves Meteor

Standard Node.js monitoring assumes HTTP requests: a route, a status code, a duration. A Meteor app mostly does not work that way. Its traffic is DDP over a websocket, and its two dominant server workloads are:

  • Method calls — the RPC equivalent of a POST, invisible to anything that only counts HTTP routes.
  • Subscriptions — long-lived publications that keep observers running, consume memory in the merge box, and do work whenever data changes rather than when a user clicks.

An APM that reports "average response time: 40ms" on a Meteor app is usually describing the static asset handler while the actual latency lives in Meteor.methods and the actual cost lives in observers nobody is measuring. That mismatch is why Kadira existed in the first place, and why Meteor-aware instrumentation still pays for itself.

The four things worth measuring

In priority order, for an app starting from nothing:

1. Per-method latency and error rate. Name, p50, p95, p99, calls per minute, throw rate. Averages hide the problem; the p95 is where your users live. This one chart answers most "the app is slow" tickets outright.

2. Publication and observer counts. How many subscriptions are open, how many distinct observers exist behind them, and how many are re-running. On the apps we tune, the expensive publication is rarely the one people suspect — it is usually a small one that is open on every screen and re-polls because its query cannot use the oplog or change-stream path. You cannot find that by reading code; you find it by counting.

3. MongoDB slow queries, with the shape of the query. Enable the database profiler or read slow-query logs from your managed provider, and map each slow shape back to the publication or method that issues it. The frequent finding is a missing index on a field added in a hurry three years ago.

4. Process health. Memory over time (the merge box makes Meteor memory-hungry, and leaks look like a sawtooth that never resets), event-loop lag, CPU, and websocket connection count. Event-loop lag is the most underused signal on a Node app: when it rises, everything gets slow at once, and it points at synchronous CPU work rather than at any one query.

Notice what is not on the list: client-side render timings, log aggregation dashboards, request tracing across services. Those are all fine. They are just not where the answers are on a typical Meteor app.

What the tooling landscape actually looks like

Honest summary, because this is a small ecosystem and the history matters:

  • Kadira was the original Meteor APM — method and publication aware, and genuinely good. It was open-sourced after the company wound down. Do not start here in 2026; treat it as ancestry.
  • Monti APM is the maintained descendant of that lineage, and it is what most Meteor teams that have APM at all are running. It instruments methods, publications, and observer behavior specifically, which is the whole point. It is a hosted product with a client package you add to the app.
  • OpenTelemetry is where the wider Node world has settled, and it is the right answer if you already run tracing for other services and want Meteor in the same pane of glass. The caveat: out of the box it understands HTTP and Mongo, not DDP. You get database spans and process metrics for free; method and publication spans you wrap yourself.
  • Whatever your host already gives you — CPU, memory, restarts, logs. Free, coarse, and worth wiring into alerts even if you do nothing else.

The pragmatic combination for most clients is Meteor-aware APM for application behavior plus infrastructure metrics from the host, with alerts on three things only: error rate, p95 method latency, and memory trend. Three alerts that people actually read beat thirty that get muted in a week.

Rolling your own, if adding a vendor is not on the table

Sometimes procurement is slower than the incident schedule. You can get most of the value from a small amount of code, because Meteor's method surface is interceptable. The shape of it:

// server/instrument.js — wrap every registered method once, at startup
const originalMethods = Meteor.server.method_handlers;

Object.keys(originalMethods).forEach((name) => {
  const handler = originalMethods[name];
  originalMethods[name] = async function instrumented(...args) {
    const started = process.hrtime.bigint();
    let outcome = 'ok';
    try {
      return await handler.apply(this, args);
    } catch (err) {
      outcome = err.error ? `error:${err.error}` : 'error';
      throw err;
    } finally {
      const ms = Number(process.hrtime.bigint() - started) / 1e6;
      emitMetric('meteor.method', { name, ms, outcome, userId: this.userId });
    }
  };
});

Send emitMetric wherever you already send data — StatsD, a Prometheus counter, structured JSON lines your log platform can aggregate. Two notes that matter more than the code: the async/await form above is what you want on Meteor 3, and on Meteor 2 you should wrap with the sync form instead, or you will change the execution semantics of every method while trying to measure them. Measure first, then change behavior. Never both in one deploy.

A companion loop for event-loop lag is about ten lines (setInterval, compare expected versus actual elapsed) and has repaid itself on more than one incident call.

What the async migration does to your instrumentation

This is the part that catches teams mid-upgrade, and it cuts both ways.

Stack traces get better, then worse, then better. Fibers produced famously unhelpful traces — the frames that mattered lived on a different fiber than the error. Async/await traces are more honest, but a promise chain still loses frames across await boundaries unless async stack traces are enabled, which modern Node does much more usefully than Node 14 did. Net: expect your traces to change shape after the upgrade, and re-tune any log parsing or error-grouping rules that assumed the old format. Error dashboards silently degrading after an upgrade is a common and annoying surprise.

Request context needs a new mechanism. Anything in your codebase that stashed per-request state in a Fiber-local — a tenant id, a request id, a user for audit logging — has no Fibers to live in. The replacement is AsyncLocalStorage from Node's async_hooks, which is the supported way to carry context across await boundaries. If you built a homegrown "current user for logging" helper years ago, put it on the migration inventory alongside the collection calls. It will not error; it will just start returning undefined at the wrong moments, which is worse.

Concurrency becomes visible. Fibers serialized more work than most teams realized. Once methods genuinely interleave, throughput usually improves and your latency distribution widens — the p99 gets more interesting. That is not a regression, but it looks like one if you only ever compared averages. Another reason to have collected a baseline before the upgrade rather than after.

Your APM package must match the platform. Instrumentation libraries hook internals by definition, so an APM client is exactly the kind of dependency that needs a Meteor 3 compatible release. Check its version support alongside your other Atmosphere packages, and bring it up on Meteor 2.16 first if you can, so the tooling is already reporting when you change platforms.

A reasonable first week

If you have nothing today, this is roughly a week of work and it does not require deciding anything about your app's future:

  1. Turn on host-level metrics and alerts for memory, restarts, and CPU.
  2. Add Meteor-aware APM, or the method wrapper above, and let it record a full business week.
  3. Enable the Mongo profiler for slow queries and collect a week of shapes.
  4. Write down the p95 of your ten busiest methods, your observer count at peak, and your memory curve. One page. Date it.

That page is the baseline. It makes the next performance argument evidentiary instead of anecdotal, it tells you whether a Meteor 3 upgrade helped, and if you eventually strangle routes out to another stack, it is the only way to demonstrate that the new path is actually faster rather than merely newer. Every conversation we have about a Meteor app goes better when the numbers exist — and collecting them commits you to nothing.