Skip to content

Fizm00/Mongoosleuth

Repository files navigation

Mongoosleuth Banner Typing SVG
npm version zero dependencies TypeScript ready ESM and CJS Node.js >=18 license
GitHub stars GitHub issues GitHub last commit

A zero-runtime-dependency Node.js + TypeScript library that catches N+1 query patterns in Mongoose applications before they reach production.

Inspired by Ruby on Rails' Bullet gem β€” built for an ecosystem that never had one.


Table of contents


The problem

// Looks completely fine. Isn't.
const posts = await Post.find();

for (const post of posts) {
  // One extra query. Per post. Every single time this route is hit.
  post.author = await User.findById(post.authorId);
}

Fifty posts. Fifty-one queries. The response still comes back. Nobody notices β€” until the collection grows, the route gets popular, and the database starts spending more time on this one endpoint than on everything else combined.

This is the N+1 query problem, and in the Ruby on Rails world it has had a well-known solution for over a decade: the Bullet gem watches your queries in development and tells you exactly where you went wrong. Mongoose has never had an equivalent. Mongoosleuth is that equivalent.

β–² Back to top

What Mongoosleuth does about it

It watches the same code, says nothing while everything is fine, and the moment that loop fires the same query shape more times than your threshold allows in a single request, it tells you exactly where to look:

[mongoosleuth] N+1 detected
  model: User
  query: findById(<value>)
  called 50 times in routes/posts.ts:14
  fix: use .populate('author') or User.find({ _id: { $in: [...] } })

No raw values are ever logged β€” only field names and value types β€” so this is safe to leave running against a database full of real, sensitive, user data.

β–² Back to top

Installation

npm install mongoosleuth

mongoose is a peer dependency, not a regular dependency β€” make sure it is already installed in your project.

β–² Back to top

Compatibility & Requirements

  • Node.js: >= 18.0.0
  • Mongoose: ^7.0.0 || ^8.0.0
  • Zero runtime dependencies: Will not clutter your node_modules or increase bundle sizes.

β–² Back to top

Usage

Express / Fastify / Koa middleware

import mongoose from 'mongoose';
import { Mongoosleuth } from 'mongoosleuth';

const sleuth = new Mongoosleuth({
  enabled: process.env.NODE_ENV !== 'production',
  threshold: 3,
});

// Patch Mongoose's query execution once, at startup.
sleuth.attach(mongoose);

// Open a tracking scope for every incoming request.
app.use(sleuth.middleware());

Manual scope (background jobs, scripts, anything without HTTP)

await sleuth.run(async () => {
  const users = await User.find({ status: 'active' });

  for (const user of users) {
    // Tracked and analyzed exactly like a request β€” fires the same warning
    // if this turns out to be a loop instead of a batched query.
    const profile = await Profile.findOne({ userId: user.id });
  }
});

Ignore options

You can exclude specific collections or operations from N+1 analysis to avoid tracking logs on expected bulk operations:

const sleuth = new Mongoosleuth({
  ignore: [
    { model: 'AuditLog' },                      // Ignore all queries on AuditLog collection
    { operation: 'updateOne' },                 // Ignore all updates on any collection
    { model: 'Session', operation: 'findOne' }   // Ignore specific collection-operation pair
  ]
});

Pluggable Custom Reporters

Mongoosleuth comes built-in with ConsoleReporter (default) and JsonReporter (for NDJSON logging pipelines). You can also easily implement the Reporter interface to ship warnings to Slack, Sentry, or S3:

import { Mongoosleuth, JsonReporter, Reporter, Finding } from 'mongoosleuth';

class SlackReporter implements Reporter {
  report(findings: Finding[]): void {
    for (const finding of findings) {
      // Post warning to Slack hook
      fetch('https://hooks.slack.com/services/...', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          text: `🚨 N+1 Query detected on *${finding.model}* (${finding.count} calls) at ${finding.callSite}`
        })
      });
    }
  }
}

const sleuth = new Mongoosleuth({
  reporters: [
    new SlackReporter(),
    new JsonReporter(line => process.stderr.write(line + '\n'))
  ]
});

β–² Back to top

Configuration Reference

View Configuration Options (MongoosleuthOptions)
Option Type Default Description
enabled boolean process.env.NODE_ENV !== 'production' Toggle Mongoosleuth N+1 pattern detection. When set to false, it acts as a zero-overhead pass-through.
threshold number 3 Number of identical query shapes executed from the same call site within a single scope before triggering a warning. Must be >= 1.
captureStackTrace boolean true When true, automatically identifies the exact file and line number (call site) of queries. Set to false for absolute zero-overhead performance (logs will group call sites as 'unknown').
ignore Array<{ model?: string; operation?: string }> [] Excludes queries matching specific model names or operations from being tracked.
reporters Reporter[] [new ConsoleReporter()] Pluggable output handlers that receive identified N+1 query findings.

β–² Back to top

How it works

flowchart TD
    A[Incoming request] --> B[Tracking scope opens<br/>AsyncLocalStorage]
    B --> C[Query interceptor<br/>logs every Mongoose query]
    C --> D[Pattern analyzer<br/>flags repeated query patterns]
    D --> E[Reporter<br/>warns in the console]
Loading

Each request gets its own isolated tracking scope. Every query fired inside that scope is fingerprinted and logged. When the request finishes, the analyzer checks whether any fingerprint repeated, from the same line of code, often enough to be a loop rather than a coincidence β€” and if so, the reporter prints it.

β–² Back to top

Technical details: the fingerprinting system

At the core of the detector is a deterministic fingerprinting function (src/fingerprint.ts) that identifies the shape of a query without ever touching the actual data inside it.

1. Normalization. Every primitive value inside a query filter is replaced with a tag representing its type, never its value:

Value type Normalized to
string <string>
number <number>
boolean <boolean>
Date <Date>
ObjectId <ObjectId>
RegExp <RegExp>
null <null>
undefined <undefined>

MongoDB query operators such as $gt, $in, or $regex are preserved as part of the shape β€” they change what the query means, so they are never collapsed away. Arrays are normalized using the shape of their first element only; an empty array is tagged <empty array>.

2. Deterministic key sorting. Object keys are sorted alphabetically at every nesting level before the fingerprint is built, so field order in your code never affects detection:

{ name: "John", age: 30 } β†’ { age: <number>, name: <string> }
{ age: 30, name: "John" } β†’ { age: <number>, name: <string> }

Same fingerprint either way β€” which is exactly the point.

β–² Back to top

FAQ

Is it safe to run in production?

Yes, absolutely. When enabled is set to false (which is the default outside development/staging environments), all of Mongoosleuth's internal hooks are bypassed and act as zero-overhead no-op pass-throughs. Additionally, Mongoosleuth never captures or logs raw query values (PII data is fully protected by substituting types), ensuring complete data privacy.

Will this affect my application's performance?

In development (enabled: true), capturing stack traces to locate the exact query line (callSite) has a minor CPU cost. If you wish to run high-throughput load tests or minimize trace collection overhead, you can set captureStackTrace: false to disable call-site tracing and group queries under a single 'unknown' location.

Why not just use built-in database query logging?

Database profilers and query loggers record individual raw queries independently without context of where they originated. Mongoosleuth isolates query shapes per request scope and groups them by their exact code line location. This allows it to distinguish between normal independent query executions and repeated, identical queries fired from a single loop context (N+1 query pattern).

β–² Back to top

Development scripts

Command What it does
npm run build Builds dual ESM (.mjs) and CommonJS (.js) output, plus .d.ts/.d.mts
npm test Runs the unit and integration suite (Vitest + mongodb-memory-server)
npm run lint Checks code style and TypeScript linter compliance
npm run format Formats the codebase with Prettier

β–² Back to top

Non-negotiable principles

Zero runtime dependencies. mongoose is a peer dependency, never a dependency.

Privacy first. Raw query values are never logged β€” only field names and value type tags.

No shared state. Every request is isolated through AsyncLocalStorage; two concurrent requests never see each other's query logs.

Dual module output. Full support for ESM and CommonJS consumers, with accurate .d.ts definitions for both.

β–² Back to top

Contributing & Issues

We welcome contributions in the form of bug reports, feature suggestions, or pull requests. Please report any issues you encounter via the GitHub Issues page.

β–² Back to top

Star History

Star History Chart

β–² Back to top

Roadmap

  • Unused eager loading detection β€” flagging a .populate() call whose result was never actually read, the mirror image of the N+1 problem.

β–² Back to top

License

MIT

Mongoosleuth Footer

About

A zero-runtime-dependency Node.js + TypeScript library that detects and warns about N+1 query patterns in Mongoose applications.

Topics

Resources

License

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors