MetrikStack
Sources

Node SDK

Server-side analytics for Node.js with middleware for Express, Hono and Next.js that records pageviews without slowing down responses.

@metrikstack/sdk-node turns the HTML pages your server sends into pageview events without blocking the response. It has no runtime dependencies (only node:crypto and the global fetch) and ships as ESM with TypeScript types, for Node 20+.

Install

Not published yet. The package is not on npm yet, so the command below does not work today. This page describes how it will work. Until then the browser script and the edge and log sources cover the same traffic.

bun add @metrikstack/sdk-node

Quick start

import { createMetrikStack } from '@metrikstack/sdk-node';

export const analytics = createMetrikStack({
  token: process.env.METRIKSTACK_TOKEN!,
  endpoint: 'https://api.metrikstack.com', // always pass the endpoint
  trustProxy: true, // behind a CDN / reverse proxy
  onError: (err) => console.warn('analytics:', err),
});
OptionDefaultNotes
tokennoneRequired. Sent as Authorization: Bearer <token>.
endpointhttps://api.example.comThe default is a placeholder, so always pass https://api.metrikstack.com.
flushIntervalMs2000Background flush interval. The timer is unref()ed.
maxBatch500Flush early once this many events are buffered.
trustProxyfalseRead the client IP from x-forwarded-for / cf-connecting-ip (and x-forwarded-proto / x-forwarded-host for the URL).
shouldTracknone(req) => boolean, applied after the built-in filtering.
fetchglobalThis.fetchOverride for tests or a proxy agent.
onErrorno-opCalled when a batch is dropped or a middleware throws.
retryDelayMs1000Delay before the single retry.

What gets tracked

Only requests that are GET or HEAD, whose Accept header contains text/html, and whose path isn't an asset (.js, .css, .png, .jpg, .svg, .ico, .woff2, .map, .json, .xml, .txt, … or the /_next/, /static/, /assets/ prefixes).

Batches are sent to POST {endpoint}/v1/batch as { "events": [...] }, at most 1000 events per request. A failed batch is retried once after retryDelayMs, then dropped and reported to onError, so the buffer can't grow without limit or slow down your app.

Express

import express from 'express';
import { analytics } from './analytics.ts';

const app = express();
app.use(analytics.express());

The middleware sets the response headers immediately and hooks res.on('finish') to enqueue the event with the final status code.

Hono

import { Hono } from 'hono';
import { analytics } from './analytics.ts';

const app = new Hono();
app.use(analytics.hono());

It awaits next(), then reads the status from c.res. On runtimes that have c.executionCtx.waitUntil() (Cloudflare Workers, Deno Deploy) the flush is handed to it; elsewhere the event is added to the queue.

Next.js (middleware.ts)

import { NextResponse } from 'next/server';
import { analytics } from './analytics.ts';

export function middleware(req: Request) {
  const res = NextResponse.next();
  analytics.next(req, res);
  return res;
}

export const config = {
  matcher: ['/((?!_next/|api/|.*\\..*).*)'],
};

analytics.next(req, res, { status }) sets both headers on the response and queues the event. The status defaults to 200, because middleware runs before the route. The SDK doesn't import next/server; it uses the standard Request / Headers types, so it also works on other runtimes built on web standards.

Errors

analytics.captureException(), errorHandler() (Express), honoOnError() (Hono) and installGlobalHandlers() report exceptions next to pageviews, linked by request_id when you pass the request. See Errors for the full API, the scrubbing rules and what is collected.

Shutdown

process.on('SIGTERM', () => void analytics.shutdown());

shutdown() flushes what's buffered and stops the timer, which is unref()ed so it never keeps a process alive on its own.

Request ids: one pageview from two sources

A page load seen by both the browser script and this SDK is counted once. The SDK generates a UUIDv7 per HTML response and sets it as X-MetrikStack-Request-Id and Server-Timing: metrikstack;desc=<id>. The browser script reads it back and sends the same id with its own event. See How merging works for the merge rules.

The id is a UUIDv7 because it is time-ordered, which helps match pageviews from several sources. analytics.requestId() gives you the generator if you need to set an id yourself.

On this page