+1 (726) 227-3745

Tracing a Slow MEAN App: OpenTelemetry for Express 5, Mongoose 8 and Angular 22

Most "the app is slow" tickets we get from MEAN clients arrive with no evidence attached. The dashboard takes six seconds, sometimes. Nobody knows whether the time is spent in Angular, in Express middleware, in a Mongoose query, or waiting on a third-party call. This tutorial fixes that: you will instrument an Express 5 / Mongoose 8 API and an Angular client with OpenTelemetry, get one connected trace per user action, and then use MongoDB's own profiler and explain() to prove which query is the problem.

Everything here uses versions current as of August 2026: Node.js 24 LTS, OpenTelemetry JS SDK 2.x, Express 5, Mongoose 8, MongoDB 8.0, Angular 22.

What we are building

  1. Auto-instrumentation for the API, so every HTTP request produces a span tree covering Express routes and MongoDB commands.
  2. A local trace backend (Jaeger in Docker) so you can look at traces without paying for anything.
  3. Browser instrumentation in Angular that propagates a trace context header, so a slow page maps to the exact server-side spans.
  4. A short workflow for turning a slow span into a fixed index.

Step 1: a place to send traces

docker run -d --name jaeger \
  -p 16686:16686 -p 4318:4318 \
  jaegertracing/all-in-one:latest

Port 4318 is the OTLP/HTTP receiver; 16686 is the UI. In production you would point the same OTLP exporter at Grafana Tempo, Honeycomb, Datadog, or an OpenTelemetry Collector. Nothing below is vendor-specific.

Step 2: instrument the Express 5 API

cd api
npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Create api/src/telemetry.js. This file must run before Express and Mongoose are imported, because auto-instrumentation patches those modules as they load.

import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'mean-api',
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? 'dev',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
      ? `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`
      : 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // Health checks and static assets are noise.
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) =>
          req.url === '/healthz' || req.url?.startsWith('/assets/'),
      },
      // Off by default in our setups: very chatty, little value.
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-mongoose': {
        dbStatementSerializer: (method, info) =>
          JSON.stringify({ method, collection: info?.collection }),
      },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});

Note the dbStatementSerializer. By default the Mongoose instrumentation can record filter values on the span, and filter values in a MEAN app routinely contain emails, tokens, or customer identifiers. We serialize only the method and collection name. If you need filter shapes for debugging, redact the values rather than shipping raw documents to your vendor.

Load it with --import so it runs before your own entrypoint:

{
  "scripts": {
    "start": "node --import ./src/telemetry.js src/server.js",
    "dev": "node --watch --import ./src/telemetry.js src/server.js"
  }
}

Start the API and hit any endpoint. In Jaeger (http://localhost:16686) pick service mean-api and you already have spans like GET /api/tasks with a child mongoose.Task.find.

Step 3: spans that describe your business, not just your framework

Auto-instrumentation shows framework boundaries. It cannot tell you that the slow part of a request was "recompute the billing rollup". Add a helper, api/src/trace.js:

import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('mean-api');

export function withSpan(name, attributes, fn) {
  return tracer.startActiveSpan(name, { attributes }, async (span) => {
    try {
      return await fn(span);
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end();
    }
  });
}

Use it around the parts of a handler you actually care about, and attach cardinality-safe attributes:

import { withSpan } from './trace.js';

app.get('/api/reports/summary', requireAuth, async (req, res) => {
  const range = req.query.range ?? '30d';

  const rows = await withSpan('report.summary', { 'report.range': range }, async (span) => {
    const result = await Task.aggregate([
      { $match: { owner: new Types.ObjectId(req.user.sub) } },
      { $group: { _id: '$done', count: { $sum: 1 } } },
    ]);
    span.setAttribute('report.row_count', result.length);
    return result;
  });

  res.json(rows);
});

Two rules we enforce in code review. Never put user ids, emails, or free-text search strings into span attributes; they explode cardinality and leak data. And attach the tenant or plan as an attribute (tenant.tier: 'enterprise') so you can ask "is this slow only for large accounts?" — which, in our experience, is the answer about a third of the time.

Step 4: connect the browser to the server

A server trace that starts at the Express handler still leaves you guessing about the four seconds before it. Instrument Angular:

cd ../client
npm install @opentelemetry/sdk-trace-web \
  @opentelemetry/context-zone-peer-dep \
  @opentelemetry/instrumentation-fetch \
  @opentelemetry/instrumentation-xml-http-request \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/instrumentation \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

client/src/telemetry.ts:

import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';

const API_ORIGIN = /http:\/\/localhost:3000\/.*/;

export function initTelemetry() {
  const provider = new WebTracerProvider({
    resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'mean-client' }),
    spanProcessors: [
      new BatchSpanProcessor(new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' })),
    ],
  });

  provider.register();

  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({ propagateTraceHeaderCorsUrls: [API_ORIGIN] }),
      new XMLHttpRequestInstrumentation({ propagateTraceHeaderCorsUrls: [API_ORIGIN] }),
    ],
  });
}

Angular's HttpClient uses XMLHttpRequest in the browser by default, so instrument both. Call initTelemetry() before bootstrapping, in main.ts:

import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { initTelemetry } from './telemetry';

initTelemetry();
bootstrapApplication(App, appConfig).catch(console.error);

Two things break here for almost everyone:

  • CORS. propagateTraceHeaderCorsUrls makes the browser send a traceparent header, which is not a simple header, so the API must allow it. In Express: app.use(cors({ origin: 'http://localhost:4200', allowedHeaders: ['Content-Type', 'Authorization', 'traceparent'] })). Without this every API call fails preflight.
  • Zoneless Angular. If you run provideZonelessChangeDetection() (the default we use on Angular 20+), do not add the ZoneContextManager. Zone-based context propagation is pointless without zone.js; the fetch/XHR instrumentations still produce correct spans on their own.

If you also serve the app with Angular SSR, add the Node SDK to server.ts the same way as the API and give it its own service.name — otherwise server-rendered requests appear as an unexplained gap.

Now a click in the UI produces one trace: browser span → traceparent → Express span → Mongoose span. That single view ends most "is it the frontend or the backend?" arguments in about a minute.

Step 5: from a slow span to a fixed query

Traces tell you which query is slow. MongoDB tells you why. When a mongoose.X.find span dominates a request, switch tools.

Turn on the profiler for slow operations only:

db.setProfilingLevel(1, { slowms: 100, sampleRate: 1.0 });
db.system.profile.find({ millis: { $gt: 100 } })
  .sort({ ts: -1 }).limit(5)
  .projection({ ns: 1, millis: 1, planSummary: 1, command: 1 });

planSummary: "COLLSCAN" is the smoking gun. Confirm with explain:

db.tasks.find({ owner: ObjectId('...'), done: false })
  .sort({ createdAt: -1 })
  .explain('executionStats');

Read three numbers: totalDocsExamined, nReturned, and executionTimeMillis. If you examined 400,000 documents to return 20, you need an index that matches the query's equality → sort → range order:

db.tasks.createIndex(
  { owner: 1, done: 1, createdAt: -1 },
  { name: 'owner_done_createdAt', background: true }
);

In Mongoose, declare it on the schema so it is part of your code, not tribal knowledge:

taskSchema.index({ owner: 1, done: 1, createdAt: -1 });

Re-run explain. You want IXSCAN, totalDocsExamined close to nReturned, and no SORT stage (an in-memory sort over 100 MB fails outright). Then reload the page and check the trace: the Mongoose span should have collapsed, and if the request is still slow, the trace now points at whatever the actual second problem is.

Other patterns this workflow surfaces constantly in MEAN codebases:

  • N+1 queries. Fifty sibling mongoose.User.findOne spans inside one request. Fix with populate() on a single query, or an aggregation with $lookup.
  • Missing .lean(). Hydrating thousands of Mongoose documents for a read-only list burns CPU in the event loop, not in Mongo. The span looks fast; the request does not.
  • Sequential awaits. Three independent 200 ms queries running in series show up as a visible staircase in the trace. Promise.all turns 600 ms into 200 ms.
  • Event-loop blocking. If gaps between spans are large but no span is slow, something synchronous (JSON stringify of a huge payload, bcrypt with too many rounds, a synchronous crypto call) is stalling the loop. Add @opentelemetry/host-metrics or check event loop lag before you blame the database.

Step 6: keep it affordable in production

  • Sample. Head-based sampling via OTEL_TRACES_SAMPLER=parentbased_traceidratio and OTEL_TRACES_SAMPLER_ARG=0.1 keeps 10% of traces. Run an OpenTelemetry Collector with tail sampling if you want to keep 100% of errors and slow requests and 1% of the rest.
  • Configure by environment, not code. OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, and OTEL_RESOURCE_ATTRIBUTES=deployment.environment=staging mean one build works everywhere.
  • Shut down cleanly. Without the SIGTERM handler above, the last batch of spans dies with the container — exactly the spans from the request that killed it.
  • Watch the browser bundle. Web instrumentation adds real weight. Load it lazily, or only for a sampled slice of sessions, and measure the cost before shipping it to every visitor.
  • Set an SLO. A trace backend nobody looks at is a bill. Pick one number ("p95 of GET /api/dashboard under 800 ms"), alert on it, and let traces be the tool you open when the alert fires.

Where this fits

Instrumentation is not a performance fix; it is the end of guessing. Every engagement we start with performance tuning begins with roughly the setup above, because a week of tracing usually finds two missing indexes, one N+1, and one synchronous call that never should have been on the request path.

If your MEAN application is slow and nobody can say where, get in touch — we can have traces in front of your team well before the next round of guesses. Related reading: Zero-downtime MongoDB 6.0 to 8.0 upgrades and Express 4 to Express 5 migration.