NestJS Profiler
Packages

nest-profiler-mongoose

Profile Mongoose queries and aggregations in the MongoDB panel.

@eleven-labs/nest-profiler-mongoose

@eleven-labs/nest-profiler-mongoose captures every Mongoose query and aggregation executed during a profiled execution and displays them in a dedicated MongoDB panel.

MongoDB panel — Mongoose queries and aggregations with operation badge, collection, duration and result count

Installation

pnpm add @eleven-labs/nest-profiler-mongoose

Peer dependencies: mongoose ^9.0.0, @nestjs/mongoose ^11.0.0

Setup

reviews.module.ts
import { ConditionalModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { MongooseCollectorModule } from '@eleven-labs/nest-profiler-mongoose';

const isProfilerEnabled = (env: NodeJS.ProcessEnv) => env['PROFILER_ENABLED'] === 'true';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Review.name, schema: ReviewSchema }]),
    ConditionalModule.registerWhen(
      MongooseCollectorModule.forRoot({
        slowThreshold: 100,
        nPlusOneThreshold: 2,
        slowSeverity: 'warning',
      }), // slow/N+1 tagging + severity
      isProfilerEnabled,
    ),
  ],
})
export class AppModule {}

MongooseModule.forRoot() (or forRootAsync) must be registered in AppModule before using MongooseCollectorModule.

Enabling / disabling — gate the collector with ConditionalModule.registerWhen(..., isProfilerEnabled) as shown, so it loads only when PROFILER_ENABLED is on. Wire the core ProfilerModule once at the root — the recommended setup bundles the root-level profiler modules into a single ProfilingModule behind a ConditionalModule gate (see Enabling and disabling the profiler and the example app). A top-level enabled option is also supported as an alternative.

What it collects

For each Mongoose query or aggregation executed during a request:

FieldDescription
collectionMongoDB collection name (e.g. reviews)
operationMongoose operation (e.g. find, aggregate)
filterQuery filter object (if applicable)
documentsDocuments written by save / insertMany
operationsBulk operations passed to bulkWrite
durationExecution time in ms
startedAtUnix timestamp
countDocuments returned (reads) or affected (writes)
resultDocuments the operation resolved to — only when captureResult is on
errorError message if the query failed
streamingtrue for streaming reads (Query.cursor() / Aggregate.cursor())
connectionConnection endpoint host:port (no credentials)
databaseTarget database name
fingerprintcollection + operation + argument shape, for N+1 grouping
tagsPerformance tags applied by the core rule engine

filter, pipeline, documents and operations are the operation's arguments: they are always captured (no captureResult needed), passed through the shared redaction, and shown in the panel with a Copy query button that yields a runnable mongosh command. Written documents go through their toJSON() projection, and unlike result they are never size-capped — a bulk operation is naturally deeper than the default maxDepth.

count is derived from what the operation resolved to: the array length for find / distinct / aggregate, the number itself for countDocuments / estimatedDocumentCount, 1 or 0 for single-document reads (findOne, findById, findOneAnd*), and the write acknowledgement for update* / delete* / replace* (deletedCount, or modifiedCount plus upsertedCount so an upsert counts as affected). When the shape is not recognized the field stays unset and the panel omits it rather than showing a wrong figure. Streamed row counts are not captured.

Slow queries, N+1 patterns and silent zero-count delete/updates (the zero-rows tag) are flagged by the core rule engine and shown as coloured pills (and filterable on the list page). See Performance tags.

Capturing results

The documents an operation returned are not captured by default — a result set carries the very data the query read. Enable captureResult to display them in a collapsible Result block under each row:

MongooseCollectorModule.forRoot({
  captureResult: true, // off by default
  resultLimits: { maxItems: 20, maxDepth: 4, maxStringLength: 512 },
});

Captured documents are flattened through their toJSON() projection, capped by resultLimits (maxItems bounds both the documents kept and the keys kept per document, the rest collapsing to a … +N more marker) and passed through the shared redaction, so a field named password, token, secret… is masked before it reaches the profile. They are still written to profile storage: keep this off outside local development.

Toolbar badge

The toolbar badge shows: {n}q (e.g., 4q). When slow queries are present: 4q (1 slow).

How it works

At module initialization, the collector patches mongoose.Query.prototype.exec and mongoose.Aggregate.prototype.exec on the Mongoose instance retrieved from connection.base. This captures all queries regardless of when schemas were registered, and is fully transparent — Mongoose behavior is unchanged.

Writesdocument.save(), Model.insertMany() and Model.bulkWrite() bypass Query.exec(), so they are patched as well, along with Model.prototype.$save — the frozen alias of save that Model.create() and Model.insertOne() call internally, and that patching save alone would miss (Model.bulkSave() routes through bulkWrite). Each write records the payload it was called with — the saved / inserted documents under documents, the bulk operations under operations — snapshotted before the write runs, since Mongoose mutates what it writes.

Streaming readsQuery.cursor() and Aggregate.cursor() bypass exec(), so they are patched too. The read is recorded (with streaming: true) at cursor creation, so it is captured whatever the consumption pattern. Its duration is finalized from the cursor's terminal close/end/error events when they fire — which they do for flowing / pipe() / explicit close() consumption, but not for for await or eachAsync() on a Mongoose cursor (they emit no terminal event); those keep duration: 0 and are labelled not timed (stream) in the panel's Duration column. Measuring their duration would require wrapping the row iterator, a per-document cost we avoid. Streamed row counts are not captured.

Schema panel

MongooseSchemaCollectorModule adds a global Schemas / Mongoose view to the profiler home page, listing every registered model with its fields (type, required, _id, default), references (ref → target model) and indexes (name, columns, unique). Unlike the per-request MongoDB panel, this is static process-level data introspected once at startup — so it renders on the home page, under the sidebar's Schemas heading, not inside a profile.

app.module.ts
import { MongooseSchemaCollectorModule } from '@eleven-labs/nest-profiler-mongoose';

ConditionalModule.registerWhen(MongooseSchemaCollectorModule.forRoot(), isProfilerEnabled),

Pass connectionName to introspect a named connection (omit it for the default), and enabled: false to disable per environment. The panel reads each model's schema.paths and schema.indexes() and never touches data; path defaults are passed through the profiler's redactString, so a default embedding a secret is masked. The panel no-ops (does not appear) when no Mongoose connection is wired.

Powered & maintained by

On this page