DEV Community

Cover image for How to Track Usage Metrics in a Multi-Tenant SaaS Application (Node.js, Redis & Postgres)
BotSailor
BotSailor

Posted on

How to Track Usage Metrics in a Multi-Tenant SaaS Application (Node.js, Redis & Postgres)

How to Track Usage Metrics in a Multi-Tenant SaaS Application (Node.js, Redis & Postgres)

Sooner or later every SaaS team gets the same three questions: "How much is tenant X actually using?", "Can we enforce plan limits?", and "Why did this customer's bill change?"

If you have no usage metering by then, the answers come from log greps and guesswork. If you built it hastily, they come from numbers nobody trusts.

This is especially painful in automation products, where usage is bursty. One tenant's workflow can trigger 50,000 runs overnight while another triggers 12 all month.

In this tutorial you'll build a per-tenant usage tracking pipeline that is:

  • Accurate: every event is attributed to exactly one tenant
  • Cheap on the hot path: it adds microseconds to each request, not milliseconds
  • Queryable: you can power dashboards, quotas, and usage-based billing from the same data

Table of Contents

Prerequisites

You'll get the most out of this guide if you have:

  • [ ] Node.js 18+ and basic Express knowledge
  • [ ] PostgreSQL 14+ (any managed instance works)
  • [ ] Redis 6+
  • [ ] A multi-tenant app where every request can be resolved to a tenant_id (via API key, JWT claim, or subdomain)

Note: The patterns here are language-agnostic. Node.js is used for the examples, but the same design works in Go, Python, or Java.

Step 1: Define what you meter

The most common mistake is metering everything. Start with a short list of billable or limit-relevant units. For an automation product, that might be:

Metric key What it counts Typical use
workflow.run Completed workflow executions Plan limits, billing
message.sent Outbound messages across channels Billing, abuse detection
api.request Authenticated API calls Rate limits, analytics
ai.tokens LLM tokens consumed Cost pass-through
seat.active Active users in the last 30 days Seat-based pricing

Two rules keep this list healthy:

  1. Every metric has an owner and a purpose. If nobody uses it for billing, limits, or product decisions, don't collect it.
  2. Metric keys are an API. Once customers see them on invoices or dashboards, renaming them is a breaking change.

Step 2: Design a tenant-safe event schema

Every usage event should be a small, immutable record. The schema below works for almost any product:

CREATE TABLE usage_events (
  id            BIGSERIAL PRIMARY KEY,
  tenant_id     UUID        NOT NULL,
  metric        TEXT        NOT NULL,
  quantity      BIGINT      NOT NULL DEFAULT 1,
  idempotency_key TEXT      NOT NULL,
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  metadata      JSONB       NOT NULL DEFAULT '{}'::jsonb,
  UNIQUE (tenant_id, idempotency_key)
);

CREATE INDEX idx_usage_events_tenant_metric_time
  ON usage_events (tenant_id, metric, occurred_at DESC);
Enter fullscreen mode Exit fullscreen mode

Why it looks this way:

  • tenant_id is NOT NULL and leads every index. Every query is tenant-scoped by default.
  • idempotency_key + a unique constraint gives you safe retries. Queues redeliver messages, and without this you will double-count.
  • quantity is a number, not an implied 1. Tokens, bytes, and batch sends all need it.
  • metadata is JSONB for non-billing context such as channel, workflow ID, and region.

For row-level isolation, add Postgres Row-Level Security so a bug in one query can't leak another tenant's data:

ALTER TABLE usage_events ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON usage_events
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
Enter fullscreen mode Exit fullscreen mode

Then set app.current_tenant at the start of each request or transaction.

Step 3: Capture usage with API middleware

The goal is to record usage without slowing down the request. Resolve the tenant once, attach it to the request, and emit the event after the response is sent.

// middleware/usage.js
import { randomUUID } from "node:crypto";
import { recordUsage } from "../usage/recorder.js";

export function trackApiUsage(req, res, next) {
  // tenant is resolved earlier by your auth middleware
  const tenantId = req.tenant?.id;
  if (!tenantId) return next();

  res.on("finish", () => {
    // Only count successful or client-billable requests
    if (res.statusCode >= 500) return;

    recordUsage({
      tenantId,
      metric: "api.request",
      quantity: 1,
      idempotencyKey: req.headers["x-request-id"] ?? randomUUID(),
      metadata: { route: req.route?.path, method: req.method },
    });
  });

  next();
}
Enter fullscreen mode Exit fullscreen mode

For non-HTTP work like background jobs and workflow runs, call the same recordUsage function from the worker:

// workers/runWorkflow.js
await executeWorkflow(job.data);

await recordUsage({
  tenantId: job.data.tenantId,
  metric: "workflow.run",
  quantity: 1,
  // job.id is stable across retries, which makes it a perfect idempotency key
  idempotencyKey: `workflow.run:${job.id}`,
  metadata: { workflowId: job.data.workflowId },
});
Enter fullscreen mode Exit fullscreen mode

Tip: Derive idempotency keys from something that is stable across retries (a job ID, a message ID), never from a timestamp or a fresh random value.

Step 4: Count in Redis, persist in Postgres

Writing one Postgres row per event works until it doesn't. A better pattern is a hot counter in Redis plus a durable event log:

  • Redis answers "how much has this tenant used this month?" in under a millisecond, which is what quota checks need.
  • Postgres is the source of truth for audits, invoices, and recomputation.
// usage/recorder.js
import Redis from "ioredis";
import { usageQueue } from "./queue.js";

const redis = new Redis(process.env.REDIS_URL);

const periodKey = (tenantId, metric, date = new Date()) => {
  const period = date.toISOString().slice(0, 7); // e.g. "2026-10"
  return `usage:${tenantId}:${metric}:${period}`;
};

export async function recordUsage(event) {
  const { tenantId, metric, quantity = 1 } = event;

  // 1. Hot path: atomic increment (microseconds)
  const key = periodKey(tenantId, metric);
  await redis.incrby(key, quantity);
  await redis.expire(key, 60 * 60 * 24 * 40); // keep ~40 days

  // 2. Durable path: hand off to a queue for batched persistence
  await usageQueue.add("persist", event, {
    attempts: 5,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: true,
  });
}
Enter fullscreen mode Exit fullscreen mode

The consumer batches inserts and relies on the unique constraint for deduplication:

// usage/persist.js
import { pool } from "../db.js";

export async function persistBatch(events) {
  const values = [];
  const placeholders = events.map((e, i) => {
    const o = i * 6;
    values.push(
      e.tenantId, e.metric, e.quantity,
      e.idempotencyKey, e.occurredAt ?? new Date(), JSON.stringify(e.metadata ?? {})
    );
    return `($${o + 1}, $${o + 2}, $${o + 3}, $${o + 4}, $${o + 5}, $${o + 6})`;
  });

  await pool.query(
    `INSERT INTO usage_events
       (tenant_id, metric, quantity, idempotency_key, occurred_at, metadata)
     VALUES ${placeholders.join(",")}
     ON CONFLICT (tenant_id, idempotency_key) DO NOTHING`,
    values
  );
}
Enter fullscreen mode Exit fullscreen mode

Keep Redis and Postgres honest

Redis counters can drift if a process crashes between the INCRBY and the queue write. Run a nightly reconciliation job that recomputes totals from Postgres and overwrites the Redis counters:

SELECT tenant_id, metric, SUM(quantity) AS total
FROM usage_events
WHERE occurred_at >= date_trunc('month', now())
GROUP BY tenant_id, metric;
Enter fullscreen mode Exit fullscreen mode

Postgres wins every disagreement. Redis is a fast cache, not a ledger.

Step 5: Expose usage through an API

Customers (and your own dashboard) need to read usage. Pre-aggregate into daily rollups so charts stay fast as the event table grows:

CREATE TABLE usage_daily (
  tenant_id UUID NOT NULL,
  metric    TEXT NOT NULL,
  day       DATE NOT NULL,
  total     BIGINT NOT NULL,
  PRIMARY KEY (tenant_id, metric, day)
);

-- Run hourly; safe to re-run
INSERT INTO usage_daily (tenant_id, metric, day, total)
SELECT tenant_id, metric, occurred_at::date, SUM(quantity)
FROM usage_events
WHERE occurred_at >= now() - interval '2 days'
GROUP BY 1, 2, 3
ON CONFLICT (tenant_id, metric, day)
DO UPDATE SET total = EXCLUDED.total;
Enter fullscreen mode Exit fullscreen mode

Then a tenant-scoped endpoint:

// routes/usage.js
router.get("/v1/usage", async (req, res) => {
  const { metric, from, to } = req.query;

  const { rows } = await pool.query(
    `SELECT day, total
       FROM usage_daily
      WHERE tenant_id = $1 AND metric = $2 AND day BETWEEN $3 AND $4
      ORDER BY day`,
    [req.tenant.id, metric, from, to]
  );

  res.json({ metric, data: rows });
});
Enter fullscreen mode Exit fullscreen mode

Notice that tenant_id comes from req.tenant, never from a query parameter. This is the single most important rule in multi-tenant APIs.

Step 6: Enforce quotas

With a fast counter you can enforce plan limits at the edge of your API:

// middleware/quota.js
const PLAN_LIMITS = {
  free:   { "workflow.run": 1_000 },
  growth: { "workflow.run": 50_000 },
};

export function enforceQuota(metric) {
  return async (req, res, next) => {
    const limit = PLAN_LIMITS[req.tenant.plan]?.[metric];
    if (limit === undefined) return next(); // unlimited

    const used = Number(await redis.get(periodKey(req.tenant.id, metric))) || 0;

    res.set("X-Usage-Limit", String(limit));
    res.set("X-Usage-Remaining", String(Math.max(limit - used, 0)));

    if (used >= limit) {
      return res.status(429).json({
        error: "quota_exceeded",
        message: `Monthly limit for ${metric} reached.`,
      });
    }
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Two design choices worth making deliberately:

  • Soft vs. hard limits. Many products warn at 80%, notify at 100%, and block only well past the limit. Hard blocking an automation tenant mid-run can be worse than the overage.
  • Expose remaining quota in headers. Developers integrating with your API will thank you.

Common pitfalls

  1. Time zones. Store everything in UTC and decide up front what "month" means for billing. Per-tenant billing anchors cause real pain later.
  2. Double counting on retries. If your idempotency key isn't stable, your invoices will be wrong.
  3. Noisy-neighbor writes. One tenant's spike shouldn't delay everyone's metering. Use per-tenant rate limits and queue partitioning if needed.
  4. Unbounded metadata cardinality. Don't put user IDs or full URLs in labels you plan to group by.
  5. No backfill story. Keep raw events long enough to recompute totals when you fix a bug.
  6. Metering in the request path synchronously. If Postgres hiccups, your API shouldn't go down with it.

Conclusion

A reliable usage metering system doesn't need a heavyweight platform. The core ideas are:

  • Meter a small, intentional set of metrics
  • Make every event tenant-scoped and idempotent
  • Count fast in Redis, store durably in Postgres, and reconcile regularly
  • Serve dashboards from daily rollups
  • Enforce quotas with clear headers and graceful limits

We went through this exact process while building multi-channel automation at Botsailor, where per-tenant message and workflow volumes vary wildly. Getting the event schema right early saved us from a painful migration later.

Your turn: how do you handle usage metering in your SaaS: build it in-house, or use a billing platform? Share your approach in the comments.

Top comments (0)