Complete Guide — nestjs-metrics

Generate metrics and trends from TypeORM entities, with a fluent API and NestJS integration.

Generate metrics (aggregated values) and trends (time-series ready for
charts) from TypeORM entities, with a fluent API and NestJS integration.


Table of Contents


Installation

npm install nestjs-metrics

Peer dependencies (must already be in your project):

  • @nestjs/common ^10 || ^11
  • typeorm ^0.3
  • nestjs-metrics-core (installed automatically)

Module Registration

forRoot — global configuration

Registers MetricsService as a global provider with default locale and
timezone applied to all queries.

import { MetricsModule } from 'nestjs-metrics/nestjs';

@Module({
  imports: [
    MetricsModule.forRoot({
      locale: 'en',
      timezone: 'UTC',
    }),
  ],
})
export class AppModule {}

forFeature — per-module override

Allows overriding global options within a specific module. Options from
forFeature are merged on top of forRoot:

@Module({
  imports: [MetricsModule.forFeature({ locale: 'fr' })],
  providers: [ReportsService],
})
export class ReportsModule {}

MetricsModuleOptions

interface MetricsModuleOptions {
  locale?: string;   // BCP-47 tag, e.g. 'en', 'pt-BR', 'fr'
  timezone?: string; // IANA timezone, e.g. 'UTC', 'America/New_York'
}
⚠️

The schema is validated with Zod. Invalid locales (e.g. '') throw

ValidationError.


The MetricsService

Injectable with scope based on where it was registered:

import { MetricsService } from 'nestjs-metrics/nestjs';

@Injectable()
export class OrdersService {
  constructor(private readonly metrics: MetricsService) {}
}

.query() method

Opens a MetricsBuilder over a TypeORM SelectQueryBuilder:

this.metrics
  .query(orderRepo.createQueryBuilder('orders'))
  .countByMonth('id')
  .trends();

Option precedence (locale/timezone)

call-site > forFeature > forRoot > default ('en', 'UTC')

// forRoot locale = 'en'
// forFeature locale = 'fr'  (inside ReportsModule)
// call-site locale = 'de'   → wins
this.metrics
  .query(ordersQuery, { locale: 'de' })
  .countByMonth()
  .trends();

Entry Points

All produce the same result for the same query.

1. Via MetricsService (NestJS)

this.metrics
  .query(orderRepo.createQueryBuilder('orders'))
  .sum('amount')
  .metrics();

2. Via Metrics.query() (static, without NestJS)

import { Metrics } from 'nestjs-metrics';
// or import { Metrics } from 'nestjs-metrics-core';

const result = await Metrics
  .query(orderRepo.createQueryBuilder('orders'))
  .count()
  .metrics();

3. Via metricsFor(repo)

import { metricsFor } from 'nestjs-metrics';

const result = await metricsFor(orderRepo)
  .sumByMonth('amount')
  .trends();

4. Via withMetrics(repo)

Extends the repository with a .metrics() method:

import { withMetrics } from 'nestjs-metrics';

const repo = withMetrics(orderRepo);
const result = await repo
  .metrics()
  .countByMonth()
  .trends();

5. Via MetricsBuilder.queryExecutor() (raw SQL, without TypeORM)

import { MetricsBuilder } from 'nestjs-metrics-core';

const ds: DataSource = {
  dialect: 'postgres',
  execute: (sql, params) => pool.query(sql, params).then(r => r.rows),
};

const result = await MetricsBuilder
  .queryExecutor(ds, { table: 'orders', dateColumn: 'created_at' })
  .sumByMonth('amount')
  .trends();

Aggregators

MethodSQLDescriptionDefault column
.count()COUNTNumber of rows'id'
.countDistinct()COUNT DISTINCTDistinct values in a column'id'
.sum()SUMSum of a numeric column(required)
.average()AVGAverage of a numeric column(required)
.max()MAXLargest value in the column(required)
.min()MINSmallest value in the column(required)
// Simple count (default column 'id')
await Metrics.query(qb).count().metrics();

// Distinct count
await Metrics.query(qb).countDistinct('customer_id').metrics();

// Sum of a specific column
await Metrics.query(qb).sum('amount').metrics();

// Average
await Metrics.query(qb).average('amount').metrics();

// Max / Min
await Metrics.query(qb).max('amount').metrics();
await Metrics.query(qb).min('amount').metrics();

The column parameter is validated as a safe SQL identifier. Special characters
or SQL injection attempts throw InvalidIdentifierException.


Periods

Define how data is grouped over time. Used with .trends() or with .metrics()
(for temporal scoping without grouping).

MethodBucketLabels (trends)
.byHour(count?)HourHH:00 (24-hour)
.byDay(count?)DayDay of the week name
.byWeek(count?)ISO WeekWeek N
.byMonth(count?)MonthMonth name
.byYear(count?)YearYear number
// Group by month
await Metrics.query(qb).count().byMonth().trends();
// → { labels: ['January', 'February', ...], data: [10, 15, ...] }

See "Window Semantics" below for the meaning of the count parameter.


Window Semantics

The count parameter in period methods controls the time window:

countBehavior
0Entire period (e.g. the whole year, no window filter)
1Only the current unit (e.g. this month)
>1Last N units up to the reference (e.g. last 3 months)
// Whole year (default)
await Metrics.query(qb).count().byMonth().forYear(2026).metrics();
// → total for the year

// Only June 2026
await Metrics.query(qb).count().byMonth(1).forYear(2026).forMonth(6).metrics();

// Last 3 months up to June 2026
await Metrics.query(qb).count().byMonth(3).forYear(2026).forMonth(6).metrics();
// → window = months 3..6 (March to June)

Year window examples

// Only 2024
await m().count().byYear(1).forYear(2024).metrics();

// Last 3 years [2021..2024]
await m().count().byYear(3).forYear(2024).metrics();

Date Ranges (between / from)

Replace the period with an explicit date interval. Labels in .trends() are the
dates themselves (ISO format).

.between(start, end)

// Days within January 2026
await Metrics.query(qb)
  .count()
  .between('2026-01-01', '2026-01-31')
  .trends();
// → { labels: ['2026-01-10', '2026-01-12'], data: [2, 1] }

.from(date)

Shorthand for between(date, today()).

await Metrics.query(qb).count().from('2026-06-01').metrics();

Range shorthands

.countBetween(['2026-01-01', '2026-12-31'])
.sumBetween(['2026-01-01', '2026-12-31'], 'amount')
.averageBetween(['2026-01-01', '2026-12-31'], 'amount')
.maxBetween(['2026-01-01', '2026-12-31'], 'amount')
.minBetween(['2026-01-01', '2026-12-31'], 'amount')

.countFrom('2020-01-01')
.sumFrom('2020-01-01', 'amount')
.averageFrom('2020-01-01', 'amount')
.maxFrom('2020-01-01', 'amount')
.minFrom('2020-01-01', 'amount')

Range Granularity (groupBy*)

When using .between()/.from(), the default bucket is day. Use groupBy*
to change it:

// By month
await m().count().between('2026-01-01', '2026-03-31').groupByMonth().trends();
// → { labels: ['2026-01', '2026-02', '2026-03'], data: [2, 1, 1] }

// By year
await m().count().between('2026-01-01', '2026-12-31').groupByYear().trends();

// By ISO week
await m().count().between('2026-03-01', '2026-03-15').groupByWeek().trends();
// → { labels: ['2026-W10', '2026-W11'], ... }

// By day (explicit, equivalent to the default)
await m().count().between('2026-01-01', '2026-01-31').groupByDay().trends();

// By hour
await m().count().between('2026-01-15 00:00', '2026-01-15 23:59').groupByHour().trends();
// → { labels: ['00:00', '01:00', ...], data: [...] }

Temporal Reference (forDay / forWeek / forMonth / forYear)

Pins that define the reference point for periods. The default is "now"
(current date/time).

// Specific day
await Metrics.query(qb)
  .count().byDay(1)
  .forYear(2026).forMonth(6).forDay(2)
  .metrics();

// Specific ISO week
await Metrics.query(qb)
  .count().byWeek(1)
  .forYear(2026).forMonth(3).forWeek(11)
  .metrics();

// Specific month
await Metrics.query(qb)
  .count().byMonth(1)
  .forYear(2026).forMonth(6)
  .metrics();

// Specific year
await Metrics.query(qb)
  .count().byMonth()
  .forYear(2026)
  .trends();

// Specific hour (0-23)
await Metrics.query(qb)
  .count().byHour(1)
  .forYear(2026).forMonth(6).forDay(15).forHour(14)
  .metrics();

Combined Shorthands

Shortcuts combining aggregator + period in a single call:

ShorthandEquivalent
.countByDay(col, n).count(col).byDay(n)
.countByWeek(col, n).count(col).byWeek(n)
.countByMonth(col, n).count(col).byMonth(n)
.countByYear(col, n).count(col).byYear(n)
.sumByDay(col, n).sum(col).byDay(n)
.sumByWeek(col, n).sum(col).byWeek(n)
.sumByMonth(col, n).sum(col).byMonth(n)
.sumByYear(col, n).sum(col).byYear(n)
.averageByDay(col, n).average(col).byDay(n)
.averageByWeek(col, n).average(col).byWeek(n)
.averageByMonth(col, n).average(col).byMonth(n)
.averageByYear(col, n).average(col).byYear(n)
.maxByDay(col, n).max(col).byDay(n)
.maxByWeek(col, n).max(col).byWeek(n)
.maxByMonth(col, n).max(col).byMonth(n)
.maxByYear(col, n).max(col).byYear(n)
.minByDay(col, n).min(col).byDay(n)
.minByWeek(col, n).min(col).byWeek(n)
.minByMonth(col, n).min(col).byMonth(n)
.minByYear(col, n).min(col).byYear(n)
await Metrics.query(qb).countByMonth('id', 6).forYear(2026).trends();
await Metrics.query(qb).sumByYear('amount', 5).trends();

Custom Date Column (dateColumn)

By default the builder uses created_at as the date column. To use a different one:

await Metrics.query(qb)
  .count()
  .dateColumn('updated_at')
  .byMonth()
  .forYear(2026)
  .trends();
// → Groups by updated_at instead of created_at

Categorical Grouping (labelColumn)

Groups the series by a categorical column instead of by period. The temporal
filter still applies — use forYear/between etc. for scope.

// Total orders grouped by status (in 2026)
await Metrics.query(qb)
  .count()
  .labelColumn('status')
  .forYear(2026)
  .trends();
// → { labels: ['delivered', 'pending', 'cancelled'], data: [10, 5, 2] }

Combined with period + forYear:

await Metrics.query(qb)
  .sumByYear('amount', 1)
  .forYear(2026)
  .labelColumn('status')
  .trends();
// → { labels: ['paid', 'pending', 'refunded'], data: [750, 75, 75] }

The period filter still applies. To group by status within a year,
use sumByYear('amount', 1).forYear(YYYY).labelColumn('status').

Switching the Table (table())

For metrics over joins:

await Metrics.query(ordersJoinCustomers(dataSource))
  .count()
  .table('customers')
  .labelColumn('name')
  .trends();
// → { labels: ['Acme', 'Globex'], data: [2, 1] }

Terminal Methods

.metrics()

Returns a single aggregated numeric value.

const total = await Metrics.query(qb).sum('amount').metrics();
// → number (or 0 if no rows match)

.trends()

Returns time-series data ready for charting.

const { labels, data } = await Metrics.query(qb).countByMonth().trends();
// → TrendsResult: { labels: (string | number)[], data: number[] }

With groupData() returns GroupedTrendsResult:

const { labels, data } = await Metrics.query(qb)
  .countByMonth('status')
  .groupData(['pending', 'delivered'])
  .trends();
// → GroupedTrendsResult: { labels: [...], data: { total: [...], pending: [...], delivered: [...] } }

.metricsWithVariations()

Returns the current value plus the variation against a previous period.

interface VariationResult {
  count: number;
  variation: {
    type: 'increase' | 'decrease' | 'none';
    value: number | string; // string when inPercent=true
  };
}
// Variation vs previous year
const r = await Metrics.query(qb)
  .count().byYear(1).forYear(2026)
  .metricsWithVariations(1, Period.YEAR);
// → { count: 5, variation: { type: 'increase', value: 3 } }

// As percentage
const r = await Metrics.query(qb)
  .count().byYear(1).forYear(2026)
  .metricsWithVariations(1, Period.YEAR, true);
// → { count: 6, variation: { type: 'increase', value: '50%' } }

previousCount must be > 0. previousPeriod must be one of:
Period.DAY | Period.WEEK | Period.MONTH | Period.YEAR.

.trendsWithComparison()

Returns two aligned series — the current period and a comparison period shifted back —
ready for side-by-side chart overlays.

interface TrendsComparisonResult {
  labels: (string | number)[];
  current: number[];
  previous: number[];
}
import { Period } from 'nestjs-metrics';

// Current year vs previous year, month by month
const r = await Metrics.query(qb)
  .countByMonth()
  .forYear(2026)
  .trendsWithComparison(1, Period.YEAR);
// → { labels: ['January', ...], current: [10, ...], previous: [7, ...] }

// As percentage of each series total
const r = await Metrics.query(qb)
  .sumByMonth('amount')
  .forYear(2026)
  .trendsWithComparison(1, Period.YEAR, true);
// → { labels: [...], current: [45, ...], previous: [38, ...] }

previousCount must be > 0. previousPeriod must be one of:
Period.DAY | Period.WEEK | Period.MONTH | Period.YEAR | Period.HOUR.


Cumulative Trends (cumulative)

cumulative() transforms a .trends() series into a running total — each bucket
becomes the sum of all values up to that point.

await Metrics.query(qb)
  .countByMonth()
  .forYear(2026)
  .cumulative()
  .trends();
// → { labels: ['January', 'February', 'March'], data: [10, 25, 32] }
//   (10, 10+15, 10+15+7)

Compatible with fillMissingData and groupData:

await Metrics.query(qb)
  .sumByMonth('amount')
  .forYear(2026)
  .fillMissingData()
  .cumulative()
  .trends();

Fill Missing Data (fillMissingData)

By default, .trends() only returns buckets that have data.
fillMissingData() fills the gaps with a default value.

await Metrics.query(qb)
  .count().byMonth().forYear(2026)
  .fillMissingData()
  .trends();
// → { labels: ['January', 'February', 'March'], data: [1, 0, 1] }

Behavior by mode

ModeStrategy
Period (byMonth etc.)Fills between the smallest and largest bucket present
Range (between/from)Enumerates the entire range
Categorical (labelColumn)Auto-discovers distinct labels or uses an explicit list

Custom fill value

.fillMissingData(99)
// → data: [1, 99, 1]

Explicit labels (categorical mode)

await Metrics.query(qb)
  .count()
  .labelColumn('status')
  .fillMissingData(0, ['pending', 'delivered', 'cancelled'])
  .trends();
// → { labels: ['pending', 'delivered', 'cancelled'], data: [2, 1, 0] }

Multiple Series (groupData)

Splits the aggregator column into one series per value — ideal for stacked charts.
Each series uses CASE WHEN column = value THEN 1 ELSE 0 END.

await Metrics.query(qb)
  .countByMonth('status')
  .groupData(['pending', 'delivered', 'cancelled'])
  .forYear(2026)
  .trends();
// → GroupedTrendsResult
// labels: ['January', 'March']
// data.total:     [3, 2]
// data.pending:   [2, 1]
// data.delivered: [1, 0]
// data.cancelled: [0, 1]

With fillMissingData

await Metrics.query(qb)
  .countByMonth('status')
  .groupData(['pending', 'delivered'])
  .forYear(2026)
  .fillMissingData()
  .trends();
// → data.total: [2, 0, 1], data.pending: [1, 0, 1], data.delivered: [1, 0, 0]
// labels: ['January', 'February', 'March']

Auto-discover labels

When labels is omitted (or an empty array), the builder queries the database for
all distinct values in the column and uses them as series names automatically.

await Metrics.query(qb)
  .countByMonth('status')
  .groupData()            // ← no labels — auto-discovered at query time
  .forYear(2026)
  .trends();
// → data keys match whatever distinct statuses exist in the table

Custom aggregator

.groupData(['pending', 'delivered'], Aggregate.SUM)

Percentages (inPercent)

trends(true) converts each value to a percentage of the series total.

const r = await Metrics.query(qb)
  .count().byMonth().forYear(2026)
  .trends(true);
// → { labels: ['January', 'March'], data: [75, 25] }

Compatible with fillMissingData:

await m().count().byMonth().forYear(2026).fillMissingData().trends(true);
// → { labels: ['January', 'February', 'March'], data: [75, 0, 25] }

Timezone

By default the timezone is 'UTC'. Configure an IANA timezone for bucketing
in local time.

// Global scope (forRoot)
MetricsModule.forRoot({ timezone: 'America/New_York' });

// Per query (call-site)
Metrics.query(qb, { timezone: 'America/New_York' });

Example: row near midnight

// created_at = '2026-07-15 03:30:00' (UTC)
// In New York (-4 EDT) → 23:30 on July 14

const q = () => Metrics.query(qb, { timezone: 'America/New_York' });

await q().count().between('2026-07-14', '2026-07-14').metrics();
// → 1 (in local time it's the 14th)

await q().count().between('2026-07-15', '2026-07-15').metrics();
// → 0

Trends in local timezone

const r = await Metrics.query(qb, { timezone: 'America/New_York' })
  .count()
  .between('2026-07-13', '2026-07-16')
  .groupByDay()
  .trends();
// → labels: ['2026-07-14'], data: [1]
⚠️

SQLite does not support timezone in executor mode. Throws

SqliteTimezoneUnsupportedException.


Locale / Label Translation

Controls the language of month and day-of-week names in .trends() results.

Metrics.query(qb, { locale: 'pt-BR' })
  .count().byMonth()
  .trends();
// → labels: ['janeiro', 'fevereiro', ...]

Metrics.query(qb, { locale: 'fr' })
  .count().byMonth()
  .trends();
// → labels: ['janvier', 'février', ...]

Default value: 'en'.


Cache

Pluggable, opt-in cache system. Cache is per query plan (aggregator + column

  • filters + timezone), so different queries get different keys.

Enable cache

import { MemoryCacheStore } from 'nestjs-metrics';

const cache = new MemoryCacheStore();
const opts = { cache: { enabled: true, ttl: 60 } }; // 60 seconds

const result = await Metrics.query(qb, opts, cache)
  .count()
  .metrics();

Custom CacheStore

Implement the CacheStore interface — all methods can be sync or async:

import type { CacheStore } from 'nestjs-metrics-core';

class MyRedisStore implements CacheStore {
  async get<T>(key: string): Promise<T | undefined> { /* ... */ }
  async set<T>(key: string, value: T, ttl: number): Promise<void> { /* ... */ }
  async del(key: string): Promise<void> { /* ... */ }
  async clear(): Promise<void> { /* ... */ }
  async stats(): Promise<CacheStats> { /* ... */ }
}

cache-manager bridge

Use createCacheManagerStore() to wrap any cache-manager v5+ store:

import { createCacheManagerStore } from 'nestjs-metrics';
import { createCache } from 'cache-manager';
import { redisStore } from 'cache-manager-ioredis-yet';

const cacheManager = await createCache({ store: await redisStore({ host: 'localhost' }) });
const cache = createCacheManagerStore(cacheManager);

const result = await Metrics.query(qb, { cache: { enabled: true, ttl: 60 } }, cache)
  .countByMonth()
  .trends();

CacheStore methods

MethodDescription
get(key)Returns value or undefined if not found (sync/async)
set(key, value, ttl)Stores with TTL in seconds (sync/async)
del(key)Removes entry (sync/async)
clear()Clears everything and resets statistics (sync/async)
stats()Returns { hits, misses, size } (sync/async)

Observability — cache logger

Pass a logger callback in cache options to receive events on every cache
hit, miss, set, or delete:

import type { CacheEvent } from 'nestjs-metrics-core';

const opts = {
  cache: {
    enabled: true,
    ttl: 60,
    logger: ({ type, key }: CacheEvent) => console.log(`cache:${type} ${key}`),
  },
};

await Metrics.query(qb, opts, cache).countByMonth().trends();
// → cache:miss metrics:...
// → cache:set  metrics:...
// → cache:hit  metrics:...  (subsequent calls)

Event types: 'hit' | 'miss' | 'set' | 'delete'.


Executor Mode (queryExecutor)

Used without TypeORM — with Prisma, Drizzle, or any SQL driver. Requires a
DataSource with dialect + execute.

DataSource

interface DataSource {
  dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql';
  execute: (sql: string, params: unknown[]) => Promise<Row[]>;
}

Basic example

import { MetricsBuilder } from 'nestjs-metrics-core';

const dataSource: DataSource = {
  dialect: 'postgres',
  execute: (sql, params) => pool.query(sql, params).then(r => r.rows),
};

const result = await MetricsBuilder
  .queryExecutor(dataSource, { table: 'orders', dateColumn: 'created_at' })
  .sumByMonth('amount')
  .forYear(2026)
  .fillMissingData()
  .trends();

ExecutorSpec

interface ExecutorSpec {
  table: string;           // Table name (required)
  dateColumn?: string;     // Date column (default read from builder)
  where?: WhereInput;      // Structured filters (optional)
  from?: string;           // Raw FROM fragment (for joins/subqueries)
}

Structured Filters (WhereInput)

Available in executor mode via ExecutorSpec.where. Filters are AND and
values are always passed as named parameters (no injection risk).

type WhereInput = Record<string, WhereCondition>;

type WhereCondition =
  | WhereScalar              // = value
  | WhereScalar[]            // IN (...)
  | RangeCondition;          // { gte?, lte?, gt?, lt? }

type WhereScalar = string | number | boolean | null;

interface RangeCondition {
  gte?: WhereScalar;
  lte?: WhereScalar;
  gt?: WhereScalar;
  lt?: WhereScalar;
}

Examples

// Equality
{ status: 'paid' }

// IN
{ status: ['paid', 'pending'] }

// Range
{ amount: { gte: 100 } }
{ amount: { gt: 100, lte: 300 } }

// IS NULL
{ customer_id: null }

// Multiple conditions (AND)
{ status: 'paid', amount: { gte: 200 } }

Using with queryExecutor

const result = await MetricsBuilder
  .queryExecutor(dataSource, {
    table: 'orders',
    dateColumn: 'created_at',
    where: { status: 'paid', amount: { gte: 100 } },
  })
  .sumByMonth('amount')
  .forYear(2026)
  .fillMissingData()
  .trends();

The where filters are applied along with period/range filters.


Validation / SkipValidation

Automatic validation

All inputs (builder, executor spec, module options) are validated with Zod in
the constructor. Invalid options throw ValidationError.

Metrics.query(qb, { locale: '' }); // → ValidationError
Metrics.query(qb, { timezone: 123 as never }); // → ValidationError

SkipValidation

To disable validation in performance-critical scenarios:

import { Metrics } from 'nestjs-metrics';

Metrics.skipValidation = true; // disables Zod validation on all inputs
// ... queries without validation ...
Metrics.skipValidation = false; // re-enables

Error Hierarchy

All exceptions extend MetricsError and carry a stable code
(machine-readable) and optional context.

Error
 └─ MetricsError (code + context)
     ├─ ValidationError                    VALIDATION_ERROR
     ├─ InvalidAggregateException          INVALID_AGGREGATE
     ├─ InvalidDateFormatException         INVALID_DATE_FORMAT
     ├─ InvalidIdentifierException         INVALID_IDENTIFIER
     ├─ InvalidPeriodException             INVALID_PERIOD
     ├─ InvalidVariationsCountException    INVALID_VARIATIONS_COUNT
     ├─ InvalidTimezoneException           INVALID_TIMEZONE
     ├─ SqliteTimezoneUnsupportedException SQLITE_TIMEZONE_UNSUPPORTED
     ├─ ConfigurationError                 CONFIGURATION_ERROR
     └─ QueryExecutionError               QUERY_EXECUTION_ERROR

Catching errors

import { MetricsError, QueryExecutionError } from 'nestjs-metrics';

try {
  await builder.sum('amount').metrics();
} catch (err) {
  if (err instanceof MetricsError) {
    console.error(err.code, err.context);
  }
}

Repository Helpers (metricsFor / withMetrics)

metricsFor(repo, options?)

import { metricsFor } from 'nestjs-metrics';

const repo = dataSource.getRepository(Order);
const result = await metricsFor(repo, { locale: 'en' })
  .sumByMonth('amount')
  .trends();

withMetrics(repo)

Adds a .metrics() method to the repository:

import { withMetrics } from 'nestjs-metrics';

const repo = withMetrics(dataSource.getRepository(Order));
const result = await repo
  .metrics()
  .countByMonth()
  .trends();

SQL Introspection (toSql / toTrendsSql)

toSql() and toTrendsSql() render the SQL that would be executed — useful for
debugging, logging, or building query-plan tests.

const sql = Metrics.query(qb)
  .sumByMonth('amount')
  .forYear(2026)
  .toSql();
// → SELECT SUM("amount") AS value FROM ... WHERE ...

const trendsSql = Metrics.query(qb)
  .sumByMonth('amount')
  .forYear(2026)
  .toTrendsSql();

Masking values

Pass { mask: true } to redact bound parameter values (safe for production logs):

const sql = builder.toSql({ mask: true });
// → ... WHERE created_at >= '[REDACTED]' AND created_at < '[REDACTED]'

Chart Helpers

Convert any TrendsResult, GroupedTrendsResult, or TrendsComparisonResult into
the format expected by popular chart libraries.

import { toChartJs, toApexCharts, toRecharts } from 'nestjs-metrics';
// or: from 'nestjs-metrics-core'

toChartJs(result, options?)

const trends = await Metrics.query(qb).countByMonth().forYear(2026).trends();
const config = toChartJs(trends, { label: 'Orders', type: 'line' });
// → { type: 'line', data: { labels: [...], datasets: [{ label: 'Orders', data: [...] }] } }
OptionTypeDefaultDescription
labelstring'value'Dataset label
typestring'line'Chart.js chart type
includeTotalbooleanfalseInclude the total series in grouped results

toApexCharts(result, options?)

const config = toApexCharts(trends, { name: 'Orders' });
// → { series: [{ name: 'Orders', data: [...] }], xaxis: { categories: [...] } }

toRecharts(result)

Returns a flat array of data points:

const data = toRecharts(trends);
// → [{ label: 'January', value: 10 }, { label: 'February', value: 15 }, ...]

With GroupedTrendsResult or TrendsComparisonResult, each point carries one key
per series:

const data = toRecharts(grouped);
// → [{ label: 'January', total: 10, pending: 6, paid: 4 }, ...]

Error Reference Table

ExceptionCodeCause
ValidationErrorVALIDATION_ERRORInvalid options (empty locale, etc.)
InvalidAggregateExceptionINVALID_AGGREGATEUnsupported aggregator
InvalidDateFormatExceptionINVALID_DATE_FORMATDate is not in YYYY-MM-DD format
InvalidIdentifierExceptionINVALID_IDENTIFIERUnsafe column/table name
InvalidPeriodExceptionINVALID_PERIODInvalid period in metricsWithVariations
InvalidVariationsCountExceptionINVALID_VARIATIONS_COUNTpreviousCount <= 0
InvalidTimezoneExceptionINVALID_TIMEZONEInvalid IANA zone
SqliteTimezoneUnsupportedExceptionSQLITE_TIMEZONE_UNSUPPORTEDNon-UTC timezone in SQLite executor
ConfigurationErrorCONFIGURATION_ERRORUnsupported driver / dialect not inferred
QueryExecutionErrorQUERY_EXECUTION_ERRORDriver error during SQL execution

Complete Example

import { Module } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MetricsModule, MetricsService } from 'nestjs-metrics/nestjs';
import { Order } from './order.entity';
import { Period } from 'nestjs-metrics';

@Module({
  imports: [
    MetricsModule.forRoot({
      locale: 'en',
      timezone: 'UTC',
    }),
  ],
})
export class ReportsModule {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: Repository<Order>,
    private readonly metrics: MetricsService,
  ) {}

  // --- Simple metrics ---

  async totalRevenue(): Promise<number> {
    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .sum('amount')
      .metrics();
  }

  async orderCountThisMonth(): Promise<number> {
    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .countByMonth(1) // current month only
      .metrics();
  }

  // --- Trends ---

  async monthlyRevenueTrend() {
    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .sumByMonth('amount', 12) // last 12 months
      .fillMissingData()
      .trends();
  }

  async ordersByStatus() {
    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .count()
      .labelColumn('status')
      .trends();
  }

  // --- Variation ---

  async revenueVariation() {
    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .sumByYear('amount', 1)
      .forYear(2026)
      .metricsWithVariations(1, Period.YEAR, true);
    // → { count: 100000, variation: { type: 'increase', value: '15.5%' } }
  }

  // --- Custom range ---

  async dailyRevenue(days: number) {
    const end = new Date().toISOString().slice(0, 10);
    const start = new Date(Date.now() - days * 86400000)
      .toISOString()
      .slice(0, 10);

    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .sum('amount')
      .between(start, end)
      .groupByDay()
      .fillMissingData()
      .trends();
  }

  // --- Multiple series ---

  async stackedStatusByMonth() {
    return this.metrics
      .query(this.orderRepo.createQueryBuilder('order'))
      .countByMonth('status', 6)
      .groupData(['pending', 'paid', 'cancelled'])
      .fillMissingData()
      .trends();
  }
}

Did this page help you?