# API analytics

Measure your own HTTP API: requests, status codes, latency percentiles and errors for every endpoint, plus the clients and customers calling it. Add the middleware to your server, and the numbers show up on the site's **API** tab.

## What it measures

| Metric     | Details                                                                                              |
| ---------- | ---------------------------------------------------------------------------------------------------- |
| Requests   | Totals per endpoint, status code, client and consumer, compared with the previous period             |
| Status mix | 2xx, 3xx, 4xx and 5xx over time, with 4xx and 5xx rates                                              |
| Latency    | p50, p95 and p99 per endpoint, estimated from log-scale buckets to within about 4%                   |
| Errors     | 4xx and 5xx grouped per endpoint and status, with rate, last seen and recent samples                 |
| Clients    | The calling library or browser from `User-Agent`, such as `curl`, `python-requests`, `okhttp`, `chrome` or `googlebot` |
| Consumers  | An optional id you attach to each request, such as an account id                                     |

Endpoints are grouped by method and route template, such as `GET /users/:id`, so every user id lands in the same row. See [Route templates](#route-templates).

## Create an API key

A key belongs to one site and can only send that site's API data.

For a standalone API, go to [/app](/app), click **Add site**, choose **API** and enter the API's hostname, such as `api.example.com`. API sites can create keys right away, without a tracking script or verification. To add API analytics to a website you already track, use that site instead; websites must be verified before they can create keys. [API-only sites](/docs/sites#api-only-sites) covers the differences.

1. Open the site's settings and go to the **API** tab. New API sites open there.
2. Click **Create key**. The label is optional.
3. Copy the key right away: it is shown once. Keys look like `tt_` followed by 48 hex characters.

A site can have up to 10 active keys. A revoked key stops working within a minute. To create and revoke keys over HTTP, see [Keys](/docs/api-requests#keys).

## Install the middleware

```bash JavaScript
npm install totallytics
```

```bash Go
go get github.com/bitgate/totallytics-go
```

Set the key as `TOTALLYTICS_API_KEY`: a secret on Cloudflare Workers (`npx wrangler secret put TOTALLYTICS_API_KEY`), or an environment variable on Node.js, Bun, Deno (with `--allow-env`) and Go. Without a key, the middleware does nothing. On Hono and Express, register it before your routes so it sees every request.

```ts Hono
import { Hono } from "hono";
import { totallytics } from "totallytics/hono";

const app = new Hono();
app.use("*", totallytics());

app.get("/users/:id", (c) => c.json({ id: c.req.param("id") }));

export default app;
```

```ts Workers
import { withTotallytics } from "totallytics/workers";

export default withTotallytics({
  async fetch(request, env, ctx) {
    return new Response("hello");
  },
} satisfies ExportedHandler<Env>);
```

```ts Express
import express from "express";
import { totallytics, totallyticsErrors } from "totallytics/express";

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

app.get("/users/:id", (req, res) => {
  res.json({ id: req.params.id });
});

app.use(totallyticsErrors());

app.listen(3000);
```

```ts Fastify
import Fastify from "fastify";
import { totallytics } from "totallytics/fastify";

const app = Fastify();
app.register(totallytics());

app.get("/users/:id", async (request) => request.params);

await app.listen({ port: 3000 });
```

```ts Next.js
// app/users/[id]/route.ts
import { withTotallytics } from "totallytics/next";

async function getUser(
  request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  return Response.json({ id });
}

export const GET = withTotallytics(getUser);
```

```go Go
package main

import (
	"net/http"

	"github.com/bitgate/totallytics-go"
)

func main() {
	tt := totallytics.New(totallytics.Options{})

	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(r.PathValue("id")))
	})

	http.ListenAndServe(":8080", tt.Middleware(mux))
}
```

| Framework          | Route reported                                                                                                                     |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| Hono               | The matched pattern, such as `/users/:id`, including sub-apps and `basePath`. When no route answers, the wildcard that ran, such as `/*` |
| Cloudflare Workers | The raw path, which Totallytics turns into a template. Other handlers, such as `scheduled` and `queue`, pass through untouched     |
| Express            | `req.baseUrl + req.route.path`, or the raw path when no route matched                                                              |
| Fastify            | The matched route, such as `/users/:id`, including plugin prefixes, or `/*` when no route matched                                  |
| Next.js            | Rebuilt from `params`: `/users/42` becomes `/users/:id`, and a `[...slug]` catch-all becomes `*`                                   |
| Go                 | The matched `ServeMux` pattern, such as `/users/:id` for `GET /users/{id}`, or the raw path for subtree patterns like `/static/` and unmatched requests |

On Express, `totallyticsErrors()` is optional: placed before your own error handler, it attaches `err.message` to error samples and passes the error on. Requests aborted before headers were sent are recorded as `499`.

The package has no dependencies and runs on Node.js 18+, Bun, Deno and Workers. Requests are aggregated in memory and sent in small background batches. The middleware never throws into your code and never delays a response.

- Node.js, Bun and Deno send every 10 seconds, plus a best-effort flush on `beforeExit` and `SIGTERM`. On serverless runtimes that freeze between requests, call `await middleware.flush()` yourself.
- Workers send once per isolate, 5 seconds after the first request, kept alive with `waitUntil`. Full batches go out right away.
- Failed sends are retried with the identical batch, up to 3 attempts in total, and Totallytics counts it once.

Durations run from the middleware until your handler returns a response; streamed bodies are not included. Deployed Workers only advance their clock on I/O, so purely CPU-bound handlers report about 0 ms.

### Options

| Option            | Default                              | Description                                                                 |
| ----------------- | ------------------------------------ | --------------------------------------------------------------------------- |
| `apiKey`          | `TOTALLYTICS_API_KEY`                | String, or a function: `(c)` on Hono, `(env)` on Workers, `()` on Express, Fastify and Next.js |
| `consumer`        | none                                 | Returns an opaque id for the caller; see [Consumers](#consumers)            |
| `route`           | detected                             | Returns your own route template                                             |
| `ignore`          | none                                 | Returns `true` to skip a request, such as a health check                    |
| `errorSamples`    | `true`                               | Send individual 4xx and 5xx requests, up to 50 5xx and 20 4xx per flush     |
| `flushIntervalMs` | `10000`                              | Send interval on Node.js, Bun and Deno                                      |
| `flushDelayMs`    | `5000`                               | Delay before the Workers send, up to `20000`. On Next.js, the minimum gap between sends, default `1000` |
| `maxBatchRows`    | `1000`                               | Metric rows per request, up to `5000`                                       |
| `endpoint`        | `https://totallytics.com/api/ingest` | Ingest URL                                                                  |
| `debug`           | `false`                              | Log diagnostics with `console.warn`                                         |

`consumer`, `route` and `ignore` receive `(c)` on Hono, `(request, env)` on Workers, `(req, res)` on Express and `(request, reply)` on Fastify. On Next.js, `consumer` and `ignore` receive `(request, response)`, and `route` takes a string or `(request)`.

```ts
app.use(
  "*",
  totallytics({
    consumer: (c) => c.get("account")?.id,
    ignore: (c) => c.req.path === "/health",
  }),
);
```

### Fastify

The plugin supports Fastify 4 and 5. Register it on the root instance, and its hooks cover every route in any registration order, including prefixed plugins. Durations run until the response is sent. Thrown errors are attached to error samples, requests aborted before headers were sent are recorded as `499`, and `app.close()` sends whatever is still buffered.

### Next.js

Wrap each App Router route handler in `withTotallytics`, on Next.js 14.2 and newer, in the Node.js or edge runtime. Next.js has no runtime API for the matched route, so the template is rebuilt from `params`. `notFound()` and `redirect()` are recorded with the status Next.js sends, other errors as `500`, and all of them are rethrown. On Next.js 15.1 and newer, batches go out after the response with `after()`, at most once per `flushDelayMs`. Older versions send in the background, kept alive with `waitUntil` on Vercel and best effort on other serverless hosts.

- Cached and static responses, and URLs that match no route, never reach a handler, so they are not recorded.
- Next.js 14 and 15.0 have no `after()`, so GET and HEAD requests are recorded without a user agent.
- Pages Router API routes are not supported.
- Pass `route` when a param value can equal a static segment after it: in `app/teams/[team]/settings`, a team named `settings` is recorded as `/teams/settings/:team`.

### Go

The Go module needs Go 1.22 or newer and has no dependencies. On Go 1.22, `tt.Middleware` has to wrap the `*http.ServeMux` itself; from Go 1.23 it can sit further out, as long as the middleware in between passes the request on unchanged. chi, gin and echo have their own modules, `github.com/bitgate/totallytics-go/chi`, `/gin` and `/echo`, which report the router's route pattern: register `totallyticschi.Middleware(tt)`, `totallyticsgin.Middleware(tt)` or `totallyticsecho.Middleware(tt)` before your routes. Panics are recorded as `500` and re-panicked, so your recovery middleware still handles them.

`totallytics.Options` takes `APIKey`, `Endpoint`, `Consumer`, `Route`, `Ignore`, `MaxBatchRows` and `FlushInterval`, which work like the options above, plus a `Logger` for diagnostics. Batches go out every 10 seconds. Call `tt.Shutdown(ctx)` after `server.Shutdown` so the last batch is sent, and `tt.Flush(ctx)` before a serverless invocation returns. The [Go README](https://github.com/bitgate/totallytics-go#readme) has a quickstart for each router.

### Other frameworks

On other Node.js frameworks, record each finished request with the core client:

```ts
import { Totallytics } from "totallytics";

const tt = new Totallytics();

tt.record({ method, path, route, status, durationMs, userAgent, consumer });
```

From other languages, send batches to [`POST /api/ingest`](/docs/api-requests#send-request-metrics) yourself.

## Route templates

Totallytics groups requests by route template, not by raw path. With raw paths, `/users/1` and `/users/2` become separate endpoints: thousands of rows with a few requests each, and no useful percentiles.

Hono, Express and Fastify report the route pattern that matched, and Next.js rebuilds it from `params`. The Workers adapter sends the raw path, and Totallytics templates it: numeric, UUID, long hex and random-token segments become `:id`, dates become `:date` and email addresses `:email`. Query strings are dropped, paths deeper than 12 segments end in `*`, and templates are cut at 256 characters.

| Sent                                                   | Stored as           |
| ------------------------------------------------------ | ------------------- |
| `/users/123/orders`                                    | `/users/:id/orders` |
| `/files/3f2a9c1e-8b4d-4c1a-9e2f-7a6b5c4d3e2f`          | `/files/:id`        |
| `/reports/2026-09-28`                                  | `/reports/:date`    |
| `/search?q=shoes`                                      | `/search`           |
| `/wp-login.php`, answered with `404`                   | `/*`                |

Scanners and typos hit paths that don't exist all day. A `404` whose route still has no parameter or wildcard after templating is stored as `/*`, so that noise stays in one row instead of flooding your endpoint list. A `404` on a real template, such as `/users/:id`, keeps its route, and error samples keep the raw path.

Pass `route` to send your own template, for example to split up a catch-all handler.

## Consumers

Pass `consumer` to see who calls your API and who runs into errors: requests, 5xx rate and p95 latency per consumer. Return an opaque id, such as an internal account id, never an email address or API key. Ids are cut at 128 characters. Requests without one show up as **Unidentified**. In Go, set `Options.Consumer` or call `totallytics.SetConsumer(r, id)` from your auth middleware.

## What is never sent

The middleware never sends request or response bodies, headers other than `User-Agent`, query strings or IP addresses. Error samples do contain the raw path and the error message. If either can hold personal data, set `errorSamples: false` in JavaScript; the Go SDK always sends them.

## The API tab

Open a site and choose **API**. Only the site owner sees this tab, also on public sites. API sites open on it.

- **Summary**: requests, 5xx rate, p50 and p95 latency, each compared with the previous period of the same length.
- **Requests** chart, stacked by 2xx, 3xx, 4xx and 5xx, and a **Latency** chart with p50 and p95. Ranges up to 4 days use hourly points, longer ranges daily ones.
- **Endpoints**, **Status codes**, **Clients** and **Consumers** tables. Endpoints show requests, 5xx rate, p50, p95 and p99.

Click any name in these tables to filter the whole tab to it: summary, charts, the other tables and errors. Filters from different tables combine; clicking another row in the same table swaps the filter, and clicking the active row removes it. Active filters show as chips that you remove one by one or with **Clear all**. They are stored in the URL, so a filtered view survives a reload.

The **Errors** panel groups every 4xx and 5xx response by status and endpoint, with its count, its rate (the share of that endpoint's requests that returned this status) and when it was last seen. Expand a row for the latest samples: time, raw path, client, consumer, duration and error message. **Filter to this error** narrows the whole tab to that status and endpoint.

To get an email when an API starts failing, slows down or goes quiet, set up [API alerts](/docs/email-reports#api-alerts).

## Retention and limits

| Data            | Kept for  |
| --------------- | --------- |
| Per-minute rows | 35 days   |
| Hourly rollups  | 25 months |
| Error samples   | 14 days   |

Stats only count requests timestamped after the site was registered. Range edges more than 34 days back come from the hourly rollups and round to whole hours.

| Limit                    | Value                                   |
| ------------------------ | --------------------------------------- |
| Active keys per site     | 10                                      |
| Ingest request body      | 4 MiB                                   |
| Rows per ingest request  | 5,000 metric rows and 200 error samples |
| Row timestamps           | Up to 7 days old or 5 minutes ahead     |
| Route template           | 256 characters, 12 segments             |
| Consumer id              | 128 characters                          |
| Filters per stats request | 12, each value up to 256 characters    |
