MetrikStack
Sources

Node SDK

Express, Hono and Next.js middleware for server-side pageviews.

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

Install

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.example.com', // your ingest host, pass it explicitly
  trustProxy: true, // behind a CDN / reverse proxy
  onError: (err) => console.warn('analytics:', err),
});
OptionDefaultNotes
tokennoneRequired. Sent as Authorization: Bearer <token>.
endpointhttps://api.example.comPlaceholder default; always pass your own ingest base URL.
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 doesn't look like an asset (.js, .css, .png, .jpg, .svg, .ico, .woff2, .map, .json, .xml, .txt, … or the /_next/, /static/, /assets/ prefixes).

Batches are POST {endpoint}/v1/batch with { "events": [...] }, at most 1000 events per request. A failed batch is retried once after retryDelayMs and then dropped and reported to onError: analytics must never grow unbounded in memory or delay 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 await next()s, then reads c.res for the status. On runtimes that expose one (Cloudflare Workers, Deno Deploy) the flush is handed to c.executionCtx.waitUntil(); elsewhere the event is simply enqueued.

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 enqueues the event (status defaults to 200, since middleware runs before the route does). The SDK never imports next/server; it works with the standard Request / Headers types, so it also fits any other Web-standard runtime.

Errors

analytics.captureException(), errorHandler() (Express), honoOnError() (Hono) and installGlobalHandlers() report exceptions alongside pageviews, joined by request_id when a request is passed. See Errors for the full API, scrubbing rules, and what is and isn't 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 must count once. The SDK generates a UUIDv7 per HTML response and exposes 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.

UUIDv7 is used rather than a random id because it's time-ordered, so pageviews from several sources can be matched. analytics.requestId() exposes the generator if you need to stamp an id yourself.

On this page