Skip to content

Express

Create an instrumentation.ts file:

instrumentation.ts
import 'autotel/register'; // Must be first for ESM
import { init } from 'autotel';
import pino from 'pino';
const logger = pino({ name: 'my-api' });
init({
service: 'my-api',
logger,
autoInstrumentations: ['express', 'http', 'pino'],
endpoint: process.env.OTLP_ENDPOINT,
});

Run with the --import flag:

Terminal window
tsx --import ./instrumentation.ts src/index.ts
import express from 'express';
import { trace, withTracing, type TraceContext } from 'autotel';
const app = express();
// Auto-traced: HTTP spans created by auto-instrumentation
// Manual trace: business logic spans
const fetchUser = withTracing({})(
(ctx: TraceContext) => async (userId: string) => {
ctx.setAttribute('db.userId', userId);
return await db.users.findById(userId);
},
);
app.get('/users/:userId', async (req, res) => {
const user = await fetchUser(req.params.userId);
res.json(user);
});
app.get('/error', async () => {
throw new Error('Test error'); // Automatically captured
});
import { trace, withTracing, getRequestLogger } from 'autotel';
app.get('/checkout', async (req, res) => {
// If using autotel-hono or middleware that creates a span,
// getRequestLogger() works without ctx
const log = getRequestLogger();
log.set({ userId: req.body.userId });
const result = await processCheckout(req.body);
log.set({ orderId: result.id });
log.emitNow(); // One wide event per request
res.json(result);
});

@opentelemetry/instrumentation-express gives every layer a span of its own and runs the layer under it, so an attribute set from a shared router.use middleware would land on a middleware - anonymous span that ends the moment next() fires - not on the request span your backend shows as the resource.

autotel turns those leaf layer spans off by default (ignoreLayersType: ['middleware', 'request_handler']), so ctx inside a middleware already means the request:

import { ctx } from 'autotel';
app.use((req, _res, next) => {
ctx.setAttribute('user.id', req.user.id);
// Objects are flattened: client-rights.admin, client-rights.reports.view, ...
ctx.setAttributes({ 'client-rights': req.user.rights });
next();
});

The route rename (GET /users/:id) is unaffected - rpcMetadata.route is assigned before the ignore check - and a pile of noise spans goes away with it.

Ask for the layer spans back when you want to see time spent per middleware:

init({
service: 'my-api',
autoInstrumentations: {
// `[]` restores upstream behaviour; `ignoreLayers` silences named paths.
express: { ignoreLayersType: [] },
},
});

With layer spans on, use requestCtx for anything that describes the request as a whole. It writes to the request span from wherever it is called, while ctx keeps pointing at the span the code is actually in:

import { ctx, requestCtx } from 'autotel';
app.use((req, _res, next) => {
requestCtx.setAttribute('user.id', req.user.id); // on GET /users/:id
ctx.setAttribute('auth.cache_hit', cached); // on middleware - authenticate
next();
});

requestCtx falls back to the active span outside a request - a queue consumer or a cron job has no request span - and no-ops when nothing is active, so it is safe in a test run that never loaded the SDK.

With autoInstrumentations: ['express', 'http']:

  • HTTP server spans: automatic per-request spans with method, path, status
  • Express middleware: middleware execution time
  • Your trace() calls: business logic spans as children
  • example-http: Express with Pino logger, manual trace() in routes, error tracking.