Skip to content

Mongoose

autotel-mongoose instruments Mongoose 8+ with stable semantic conventions, query text that carries no values, a statement hash to group by, and optional query plans.

Terminal window
npm install autotel autotel-mongoose mongoose
import mongoose from 'mongoose';
import { init } from 'autotel';
import { instrumentMongoose } from 'autotel-mongoose';
init({ service: 'my-app', endpoint: 'http://localhost:4318' });
instrumentMongoose(mongoose, { dbName: 'myapp' });
const User = mongoose.model(
'User',
new mongoose.Schema({ name: String, email: String }),
);
await User.findOne({ email: 'alice@example.com' }).exec();
// Span: "findOne users"
// db.query.text: {"condition":{"email":"?"},"options":{}}
// db.statement.hash: the same for every findOne on users by email

instrumentMongoose() is safe to call more than once. Mongoose’s Model and Query prototypes are shared by every new mongoose.Mongoose(), so a second call recognises what the first installed and leaves it alone: one span per operation either way.

Methods Mongoose implements with other methods produce one span, not one per delegation: findById traces as findById, without a nested findOne for the same round trip. A query a hook issues is a separate round trip and keeps its own span.

db.query.text is the query’s shape: field names and operators, with every value replaced by ?. Nothing user-supplied reaches the span, so there is nothing for a redactor to miss, and the same query with different values has the same text. An insertMany records one entry per distinct document shape, so batches of any size are one statement.

db.statement.hash is set on every operation, from the operation, the collection and that shape, even when dbStatementSerializer is false or captures values. Group a trace’s spans by it to find an N+1: one hash, many spans. The find_repeated_queries tool in autotel-mcp does that for you.

To capture values anyway, pass a serializer. statementRedactor (the 'default' preset unless you say otherwise) still masks emails, phone numbers, card numbers and SSNs in what it returns:

instrumentMongoose(mongoose, {
dbStatementSerializer: (_operation, payload) => JSON.stringify(payload),
});
instrumentMongoose(mongoose, { explain: 'analyze' });
await Order.find({ status: 'open', total: { $gte: 10 } })
.sort({ createdAt: -1 })
.exec();
// db.plan.node: SORT
// db.plan.full_scan: true
// db.plan.rows_examined: 60
// db.plan.rows_returned: 16
// db.plan.index_suggestion: db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })

'analyze' runs explain('executionStats'), which reports documents examined and time; 'plan' runs explain('queryPlanner'), which does not run the query. Explain never applies a write, so updates and deletes are explained safely. It is sent to the driver as a raw command, so your schema’s hooks run once per query, never again for the explain.

The explain runs after the query settles: your code gets its result without waiting, and the span still ends when the query did, so its duration is the query’s own. It is one more round trip per query, so turn it on in development, in CI, or for a sample of traffic.

The attributes are the ones MongoDB documents, shared with Drizzle’s Postgres plans.

Off by default. Enable it and the hooks your schemas register get their own spans, named after the operation that ran: mongoose.users.pre.save.

instrumentMongoose(mongoose, { instrumentHooks: true });
userSchema.pre('save', async function () {
this.set('name', this.get('name')?.trim());
// Span: mongoose.users.pre.save
});

Mongoose’s own hooks stay out of the way, so a schema using timestamps, subdocuments or virtuals emits spans only for the hooks you wrote.

Pass a selector to choose which hooks to trace, the same shape customMethods takes:

instrumentMongoose(mongoose, {
instrumentHooks: ['save', 'validate'], // only these
});
instrumentMongoose(mongoose, {
instrumentHooks: { exclude: ['init'] }, // everything else
});

Reach for that with init, which runs once for every document a query hydrates, where save and validate run once per operation. A find returning 500 documents means 500 post.init spans.

Selection works a hook at a time whichever way you register. Both pre(['save', 'validate'], fn) and pre(/^find/, fn) are selected and named per operation, so a find gets pre.find and a findOne gets pre.findOne. Excluding a hook only stops the span; your handler still runs.

Option Default Description
dbName — Database name
peerName — MongoDB host
peerPort — MongoDB port
captureCollectionName true Include collection in span
instrumentHooks false Trace schema hooks; accepts a selector
dbStatementSerializer value-free Custom query serializer
statementRedactor 'default' PII redaction for a serializer’s output
explain false 'plan' or 'analyze': add db.plan.*

Uses stable OTel conventions:

  • db.system.name
  • db.operation.name
  • db.collection.name
  • db.namespace
  • db.query.text
  • server.address / server.port

Plus autotel’s own, shared across its database instrumentations: db.statement.hash, and db.plan.* when explain is on.