The Elastic APM Node.js agent can make your Express app’s performance data appear in Kibana, but it’s not just a passive data collector; it actively instruments your code to reveal bottlenecks you didn’t even know existed.
Let’s see it in action. Imagine a simple Express app that fetches data from a database and then exposes it via an API endpoint.
// server.js
const express = require('express');
const app = express();
const port = 3000;
// Simulate database call
function getUserFromDb(userId) {
return new Promise(resolve => {
setTimeout(() => {
resolve({ id: userId, name: 'Alice' });
}, 500); // Simulate 500ms database latency
});
}
app.get('/users/:id', async (req, res) => {
const userId = req.params.id;
try {
const user = await getUserFromDb(userId);
res.json(user);
} catch (error) {
res.status(500).send('Error fetching user');
}
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Now, to instrument this with Elastic APM, you’d install the agent and configure it.
First, install the agent:
npm install @elastic/apm-agent-express
Then, in your server.js file, require and start the agent before you define your Express app:
// server.js
const apm = require('@elastic/apm-agent-express').start({
serviceName: 'my-express-app',
serverUrl: 'http://localhost:8200', // Your APM Server URL
secretToken: '', // If your APM Server requires authentication
environment: 'development',
});
const express = require('express');
const app = express();
const port = 3000;
// ... rest of your Express app code ...
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
When you run this app and hit the /users/:id endpoint, Elastic APM will automatically:
- Trace incoming HTTP requests: Each request to
/users/:idwill become a "transaction" in APM. - Instrument asynchronous operations: The
await getUserFromDb(userId)will be automatically captured as a "span" within the transaction, showing you how much time was spent waiting for the simulated database call. - Report errors: If
getUserFromDbwere to throw an error, it would be captured and linked to the transaction.
In Kibana’s APM UI, you’d see a transaction for GET /users/:id. Clicking into it would reveal a waterfall diagram showing the transaction duration, and crucially, a span labeled getUserFromDb detailing the 500ms spent in that function. This immediately tells you that your database latency is the primary bottleneck for this endpoint, not Express itself.
The core problem the Elastic APM agent solves is the "black box" nature of distributed systems and complex applications. Without instrumentation, you know your API is slow, but you have no idea where the slowness originates. Is it network latency? A slow database query? A computationally intensive piece of code? The agent breaks down the total request time into granular spans, each representing a unit of work (like a database call, an external HTTP request, or a specific function execution), allowing you to pinpoint the exact source of performance degradation.
The agent works by leveraging Node.js’s built-in async_hooks module and patching core modules like http, https, and popular libraries like pg or mongoose (if installed). When a transaction starts, it creates a trace context. As your application executes asynchronous operations, async_hooks allows the agent to propagate this context. When a patched module performs an operation (e.g., sending a query to a database), the agent intercepts the call, records the start and end times, and associates this "span" with the current transaction’s trace context. This layered approach ensures that even deeply nested asynchronous calls are accurately attributed to the originating transaction.
The most surprising thing about how the agent instruments asynchronous code is its ability to correctly correlate asynchronous operations that might appear disconnected in the code. For example, if an Express route handler initiates a series of asynchronous tasks that complete out of order, the agent, using async_hooks and trace context propagation, can still accurately reconstruct the timeline of operations within the original transaction, ensuring that all child spans are correctly associated with their parent. This means you don’t need to manually wrap every Promise or async/await call to get a complete performance picture.
Once you’ve got basic instrumentation working, the next step is to explore distributed tracing, which connects transactions across multiple microservices.