nestjs-metrics-core
v0.5.2
Published
ORM-agnostic metrics & trends engine. Build chart-ready time-series from TypeORM, Prisma, Drizzle or any SQL driver via a fluent API.
Maintainers
Readme
nestjs-metrics-core
The ORM-agnostic metrics & trends engine and its fluent API. Build chart-ready time-series from TypeORM, Prisma, Drizzle or any SQL driver, with the same API across PostgreSQL, MySQL/MariaDB and SQLite.
import { Metrics } from 'nestjs-metrics-core';
await Metrics.query(orderRepo.createQueryBuilder('orders'))
.sumByMonth('amount')
.forYear(2026)
.fillMissingData()
.trends();
// → { labels: ['January', ...], data: [...] }Features
- Dual-mode engine —
Metrics.query(qb)over TypeORM, orMetrics.queryExecutor(...)over any driver. - Database portability — one API emits the right SQL for Postgres, MySQL and SQLite.
- Locale & timezone aware — translated labels and DST-correct bucketing.
- Chart-ready output —
trends()returns{ labels, data }directly usable by chart libraries. - Output helpers —
fillMissingData,groupData,metricsWithVariations, percentage trends. - Typed errors — every error exposes a stable
codefor programmatic handling. - Identifier safety — built-in allowlist + driver escaping protects against SQL identifier injection.
Installation
npm install nestjs-metrics-coretypeorm is an optional peer — only needed for the Metrics.query path.
The terminals (metrics(), trends(), metricsWithVariations()) are async.
Entry points
TypeORM query builder
import { Metrics, metricsFor, withMetrics } from 'nestjs-metrics-core';
await Metrics.query(orderRepo.createQueryBuilder('orders'))
.sum('amount')
.byMonth()
.forYear(2026)
.trends();
await metricsFor(orderRepo).count().byYear().metrics(); // repository helper
await withMetrics(orderRepo).metrics().countByMonth().trends(); // extend the repoAny driver (executor mode)
import { Metrics, type DataSource } from 'nestjs-metrics-core';
const dataSource: DataSource = {
dialect: 'postgres',
execute: (sql, params) => pool.query(sql, params).then((r) => r.rows),
};
await Metrics.queryExecutor(dataSource, { table: 'orders', dateColumn: 'created_at' })
.sumByMonth('amount')
.forYear(2026)
.fillMissingData()
.trends();API overview
Aggregates · Periods · Reference point
.count(column = 'id') .sum(column) .average(column) .max(column) .min(column)
.byDay(count = 0) .byWeek(count = 0) .byMonth(count = 0) .byYear(count = 0)
.forDay(d) .forWeek(w /* ISO week */) .forMonth(m) .forYear(y)count = 0 → the whole period · count = 1 → a single unit · count > 1 → the
last-n window.
Date ranges · Targeting
.between(start, end /* 'YYYY-MM-DD' */) .from(date)
.groupByDay() | .groupByWeek() | .groupByMonth() | .groupByYear()
.dateColumn(column) .table(name) .labelColumn(column)Outputs · Modifiers
.metrics() // → number
.trends(inPercent = false) // → { labels, data }
.metricsWithVariations(prevCount, prevPeriod, inPercent = false)
.fillMissingData(value = 0, labels = [])
.groupData(labels, aggregate = Aggregate.SUM) // multi-series → { total, [label]: [] }Combined shorthands
.countByMonth(column?, count?) .sumByYear(column, count?) .averageByWeek(column, count?)
.countBetween([start, end], column?) .sumFrom(date, column) // …all by-period/Between/From shorthandsLocale & timezone
Metrics.query(qb, { locale: 'pt-BR', timezone: 'America/Sao_Paulo' });Labels are translated via the locale (default en). A non-UTC timezone
converts the date column before bucketing (DST-correct) on Postgres/MySQL; on
SQLite, timezone conversion is supported via the TypeORM path but not the
executor mode (which is UTC-only and throws on a non-UTC timezone).
Scoping with where / whereIn
Every query the builder runs can be AND-scoped with structured, parameter-bound filters — the hook for multi-tenant / visibility gates:
// A gate wraps the builder and injects the caller's visible ids:
const scoped = Metrics.queryExecutor(db, { table: 'donations', dateColumn: 'created_at' })
.whereIn('member_id', visibleIds) // array → IN (…), every value bound
.where('status', 'confirmed') // scalar → equality
.where('amount', { gte: 0 }); // object → range (gte/lte/gt/lt); null → IS NULL
await scoped.sumByMonth('amount', 12).fillMissingData().trends();Guarantees: values only ever travel as bound parameters; column names are
validated and driver-escaped; an empty whereIn list matches nothing
(fail closed). Also available in TypeORM mode and as
queryExecutor(ds, { …, where: { member_id: visibleIds } }).
Bring your own SQL: fromRows
When your query layer must own 100% of the SQL (Kysely, raw pg, an architecture rule that every read goes through a scoped query), hand the builder the rows and let it do the hard part — period bucketing, gap fill, timezone-correct month boundaries, labels, variations:
const rows = await scopedQuery.selectFrom('donations').selectAll().execute();
await Metrics.fromRows(rows, { dateColumn: 'created_at' }, { timezone: 'America/Sao_Paulo' })
.sumByMonth('amount', 12)
.fillMissingData()
.trends(); // identical output to the SQL modes, verified by an equivalence suitedateColumn accepts Date, ISO strings or epoch milliseconds. The full
fluent API works (count/countDistinct/sum/average/max/min, every period
including byHour, between, labelColumn, groupData (including
auto-discovered labels), cumulative, metricsWithVariations,
trendsWithComparison, where/whereIn).
An unparseable date throws InvalidRowDateException naming the row index;
.table() throws UnsupportedInRowsModeException. Caching is not available in
rows mode — passing cache: { enabled: true } to fromRows() throws
ConfigurationError rather than silently ignoring it, since in-memory rows have
no stable query identity to key a cache entry on.
Caching
import { MemoryCacheStore } from 'nestjs-metrics-core';
const cache = new MemoryCacheStore();
await Metrics.query(qb, { cache: { enabled: true, ttl: 60 } }, cache)
.count()
.metrics();Implement the CacheStore interface to plug in Redis or any backend.
Errors
Typed exceptions: InvalidAggregateException, InvalidPeriodException,
InvalidDateFormatException, InvalidVariationsCountException,
InvalidIdentifierException, InvalidTimezoneException,
SqliteTimezoneUnsupportedException, InvalidRowDateException,
UnsupportedInRowsModeException. Identifiers are validated and escaped —
keep them developer-controlled, not user input.
See the error codes reference for the full table.
Adapters
- NestJS —
nestjs-metrics:MetricsModule+ injectableMetricsService, with module-wide locale/timezone/cache defaults. - Prisma, Drizzle & Kysely —
nextjs-metrics:prismaMetrics/drizzleMetrics/kyselyMetrics, each on an isolated subpath. Despite the name, it is not Next.js-specific — it works in any Node runtime, and the adapters are just thinDataSourcebuilders over the core executor mode.
Adapters are optional convenience. Any driver already works through
Metrics.queryExecutor(dataSource, …), and Metrics.fromRows(rows, …) needs no
driver at all.
License
MIT
