Express
Create an instrumentation.ts file:
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:
tsx --import ./instrumentation.ts src/index.tsTracing Route Handlers
Section titled “Tracing Route Handlers”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 spansconst 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});Request-Scoped Logging
Section titled “Request-Scoped Logging”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);});Attributes From Middleware
Section titled “Attributes From Middleware”@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.
Keeping per-middleware timing
Section titled “Keeping per-middleware timing”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.
What Gets Traced
Section titled “What Gets Traced”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
Examples
Section titled “Examples”example-http: Express with Pino logger, manualtrace()in routes, error tracking.