APM for Node.js and Express APIs Without Installing a Heavyweight Agent
EngineeringAugust 24, 202610 min read

APM for Node.js and Express APIs Without Installing a Heavyweight Agent

Skip the 200MB agent binaries. Here's how to get P95 latency, distributed tracing, and error correlation for your Express API using OpenTelemetry auto-instrumentation and a sub-5KB frontend script.

Three weeks ago, our CI pipeline started timing out on Fridays. Not randomly — Fridays specifically. Builds that normally finished in four minutes were hitting the fifteen-minute ceiling and dying. The logs? Useless. The metrics dashboard showed CPU and memory looking normal. And I spent two Fridays in a row staring at npm install output like an idiot before someone on the team mentioned that New Relic's agent was rebuilding native binaries on every CI run because we'd pinned Node to latest and it kept bumping minor versions.

The agent weighed in at 180MB installed. Three seconds added to cold starts on our staging pods. Platform-specific .node binaries invalidating our layer cache twice a week. I was furious at myself for not catching it sooner.

So I ripped it out.

Replaced it with OpenTelemetry auto-instrumentation and JustAnalytics as the backend. Install went from 180MB to about 8MB. Cold starts dropped from 3.2s to 1.1s. Same P95 latency graphs, same distributed traces, same error correlation — without the agent binary gymnastics.

Here's the working setup.

What you'll have at the end

A production-ready Express API with:

  • Automatic distributed tracing for every HTTP request
  • P95/P99 latency breakdowns by endpoint
  • Error tracking with stack traces linked to the trace that triggered them
  • Outbound HTTP calls (to databases, external APIs) included in the trace waterfall
  • An under-5KB frontend script handling the browser half (if you've got a client)

No native binaries. No DD_API_KEY environment variable. No agent restart when you bump Node versions. (Honestly, that last one is the real win.)

Prerequisites

  • Node.js 18+ (LTS recommended; tested on 18.19 and 20.11)
  • An Express app — this guide assumes v4.x but works on v5 beta too
  • A JustAnalytics account (the Free tier covers 100K events/month, enough for most staging and small prod workloads)
  • Basic familiarity with middleware and environment variables

Step 1: Install the OpenTelemetry packages

You need four packages. The core SDK, the OTLP exporter, the auto-instrumentation bundle, and the resource detector.

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

Total install size: about 8MB. Compare that to dd-trace at 180MB or newrelic at 120MB. It's not even close.

If you're on a monorepo with strict dependency budgets, these are all peer-dep friendly. No native modules to compile. No postinstall scripts downloading platform binaries.

I have spent actual hours debugging why node-gyp failed to build sharp in a container that didn't have Python. This is not that nightmare.

Step 2: Create the tracing bootstrap file

OpenTelemetry needs to initialize before your app loads, so the instrumentation can wrap Express, HTTP, and your database drivers. Create a file called tracing.js (or tracing.ts if you're transpiling):

// tracing.js — load this BEFORE your app
const { NodeSDK } = require("@opentelemetry/sdk-node");
const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node");
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-http");
const { Resource } = require("@opentelemetry/resources");

const sdk = new NodeSDK({
  resource: new Resource({
    "service.name": process.env.OTEL_SERVICE_NAME || "express-api",
    "service.version": process.env.npm_package_version || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "https://otlp.justanalytics.app/v1/traces",
    headers: {
      Authorization: `Bearer ${process.env.JA_API_KEY}`,
    },
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false }, // noisy
    }),
  ],
});

sdk.start();
console.log("Tracing initialized");

process.on("SIGTERM", () => {
  sdk.shutdown()
    .then(() => console.log("Tracing shut down"))
    .catch((err) => console.error("Tracing shutdown error", err))
    .finally(() => process.exit(0));
});

A few notes. First, I disable the fs instrumentation because it generates a span for every file read — including every require() call — and it floods your trace view with noise. You don't need to trace your config file reads. Trust me on this.

Second, the graceful shutdown handler matters more than it looks. Kill the process without it and the last batch of spans never gets exported. You lose the traces for whatever caused the crash. Ask me how I know. (I found out at 2am during an outage.)

Step 3: Update your start command

The tracing file needs to load before Express. Use Node's --require flag:

{
  "scripts": {
    "start": "node --require ./tracing.js ./src/index.js",
    "dev": "nodemon --require ./tracing.js ./src/index.js"
  }
}

If you're using ts-node or tsx, same pattern:

{
  "scripts": {
    "dev": "tsx --require ./tracing.ts ./src/index.ts"
  }
}

Run npm start and you should see "Tracing initialized" before your normal startup output. If you don't, double-check the --require path.

Step 4: Set your environment variables

# .env or your deployment config
JA_API_KEY=your-justanalytics-api-key
OTEL_SERVICE_NAME=my-express-api
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.justanalytics.app/v1/traces
NODE_ENV=production

The API key comes from your JustAnalytics dashboard under Settings > API Keys. The Free tier (100K events/month) still gives you full APM — the limit is volume, not features. Which, candidly, is refreshing after dealing with vendors who gate basic functionality behind enterprise tiers.

Step 5: Verify traces are arriving

Hit one of your endpoints:

curl http://localhost:3000/api/users

Then open the JustAnalytics APM dashboard. Within 10-15 seconds (the default batch interval), you should see a trace appear. Click into it and you'll see:

  • The HTTP handler span (GET /api/users)
  • Middleware spans (auth, body parsing, logging)
  • Outbound calls to databases or external APIs as child spans
  • Total latency and breakdown by component

Not showing up? Check these three things: (1) the API key is correct and doesn't have a trailing newline from copy-paste — this got me once, (2) your firewall allows outbound HTTPS to otlp.justanalytics.app, and (3) you actually hit an endpoint after tracing initialized.

Adding the frontend half

The backend tracing covers your API. If you've got a React/Next.js/Vue frontend, you probably want the browser half too — client-side errors, Web Vitals, session replay, and the ability to link a user's click to the API trace it triggered.

Add the JustAnalytics script to your HTML:

<script
  src="https://cdn.justanalytics.app/script.js"
  data-site="your-site-id"
  defer
></script>

That's under 5KB gzipped. Handles pageviews, Web Vitals, client errors, and (if you enable it) session replay with privacy masking.

The script automatically generates a x-trace-id header on outbound fetch requests. JustAnalytics correlates that with your backend traces. So when a user reports "the checkout button didn't work," you can find their session, see the client-side error, click through to the API trace, and see that the Stripe call timed out at P99.

I've debugged more production issues in thirty seconds with that flow than I ever did with separate tools and manual correlation. And I'm not exaggerating — the old way was me grepping logs across three dashboards while the Slack pings kept coming.

Common errors and how to fix them

"Cannot find module '@opentelemetry/sdk-node'" — You ran the app without --require ./tracing.js, so Node tried to load tracing.js as the main entry point and failed. Check your start script.

Traces show up but are missing database spans — The auto-instrumentation bundles most database drivers (pg, mysql2, mongodb, redis), but they need to be installed in your project. Using Prisma? You'll need @opentelemetry/instrumentation-prisma separately. It's not in the default bundle and I wish someone had told me that earlier.

"401 Unauthorized" in the console on startup — Your API key is wrong or missing. Double-check the environment variable name (JA_API_KEY) and that the value doesn't have quotes around it in your .env file.

P95 latency looks suspiciously low — You're probably only seeing successful fast requests. Failed requests that timeout get dropped before the exporter batches them. Add explicit error handling in your tracing shutdown to catch these.

Cold starts are still slow — Make sure you're not accidentally loading both the OpenTelemetry SDK and a legacy agent like dd-trace. They conflict badly. Node will take forever to initialize both.

Next steps

Now that traces are flowing, here's what's worth setting up next:

  • Error tracking integration — JustAnalytics links errors to the trace that triggered them. The error tracking guide covers the setup (same pattern works for Express).
  • Custom spans for business logic — Auto-instrumentation handles HTTP, but if you've got a CPU-heavy function or a custom algorithm, wrap it manually. See the P99 latency debugging post.
  • Alerts on latency regressions — Set up an alert for P95 on your critical endpoints. The SLO error budget alerts guide covers this if you want to get formal.

For teams consolidating their observability stack — replacing the GA4 + Sentry + Datadog + Pingdom + LogRocket mess with one platform — JustAnalytics handles analytics, error tracking, APM, session replay, and uptime in one under-5KB script. The engineering overhead of context-switching between five dashboards at 3am is real. I've done it. It's miserable. Consolidating into one is the actual fix.

We've detailed the migration path in our consolidation checklist and covered the true monthly cost of running the split stack for teams making the budget case.

If you're evaluating APM alternatives, ClickzProtect pairs well for paid acquisition — you can spot when bot traffic is hitting your API by correlating click fraud patterns with backend latency spikes.

Frequently Asked Questions

Does OpenTelemetry auto-instrumentation slow down my Express app?

Barely. In benchmarks on a typical CRUD API, OpenTelemetry auto-instrumentation adds 1-3ms of overhead per request — well within noise for most applications. The bigger concern is memory: the instrumentation packages add about 15-20MB to your Node.js process heap, which matters more on memory-constrained containers. If you're running 256MB pods, budget for that. If you're on 512MB or higher, you won't notice.

Can I use this setup with Fastify or Koa instead of Express?

Yes. OpenTelemetry has auto-instrumentation packages for both — @opentelemetry/instrumentation-fastify and @opentelemetry/instrumentation-koa. The tracing setup file is almost identical; swap the package import and you're done. The JustAnalytics OTLP endpoint doesn't care which framework generated the spans.

How does this compare to running the Datadog or New Relic agent?

The Datadog Node.js agent (dd-trace) adds roughly 40-60MB to your process and requires native module compilation on install. New Relic's agent is similar. Both work well but have heavier startup overhead — typically 200-400ms added to cold starts, which matters for serverless or frequently-scaling pods. The OpenTelemetry approach here uses pure JS instrumentation, installs faster, and has lighter cold-start impact. The trade-off: the commercial agents include more auto-magic (profiling, security scanning). If you just want APM, OpenTelemetry is enough.

Do I need to instrument every route manually?

No. The auto-instrumentation packages hook into Express's middleware pipeline automatically. Every route handler, middleware function, and outbound HTTP call gets wrapped without you writing any tracing code. You only write manual spans for things the auto-instrumentation can't see — like a custom algorithm you want to measure or a non-HTTP side effect.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier covers 100K events/month. Pro is $49/month ($39/month billed annually).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts