The Sentrinel plugin

@sentrinel/plugin is the agent that runs inside your application. It measures every request, buffers in memory, and flushes batches to the Sentrinel API. It adds no blocking work to the request path: metrics are aggregated in-process and sent on a timer.

Adapters ship for Elysia (first-class), Express, Next.js, and Flutter. Anything that speaks OpenTelemetry can skip the plugin entirely and export OTLP/HTTP straight to /v1/traces.


Not a JavaScript backend? Django and FastAPI have their own packages โ€” DJANGO.md and FASTAPI.md, one middleware each and no runtime dependencies. Flutter, Kotlin and Swift are in MOBILE.md, and anything that speaks OpenTelemetry can export straight to /v1/traces without an SDK at all.

Install

bun add "@sentrinel/plugin@github:Zaga-ltd/sentinel_packages"

Minimum viable setup

import { Elysia } from "elysia";
import { sentrinelPlugin } from "@sentrinel/plugin";

new Elysia()
  .use(sentrinelPlugin({
    serverUrl: "https://api.sentrinel.example.com",
    appName: "checkout-api",
    env: "prod",
    apiKey: process.env.SENTRINEL_API_KEY,
  }))
  .get("/orders", () => listOrders())
  .listen(3000);

That alone gives you request counts, error rates, p50/p95/p99 latency, Apdex, endpoint discovery, host CPU/memory, and deploy markers.

Want everything enabled? See FULL_CONFIG.md for the complete reference covering every option, every signal, and every dashboard page โ€” backend and Flutter side by side.

The API key must match the app name and environment it was issued for.

And it must be the right kind. Every key is bound to one integration, chosen when it is issued (API Keys โ†’ Generate โ†’ What is this key for?):

Kind Give it to Can reach
Server this plugin every ingest surface โ€” what every key could do before kinds existed (the default)
Mobile app the Flutter / Kotlin / Swift SDK requests, logs, errors, events, sessions, metrics, traces
Database collector pg-collector /api/ingest/db/* only
OpenTelemetry exporter an OTLP exporter's headers /v1/traces only
AI agent the MCP server and CLI reads one app; writes nothing

The point is the leak. A key inside a phone's bundle, on a database host, or in an agent's config file each leak differently, and a key that could do everything would turn any one of those into all of them. A key used on the wrong surface gets a 403 that names the kind it is and the kind that surface wants. Keys issued before kinds existed are server keys and keep working unchanged. This is the tenant boundary โ€” a leaked key for staging cannot write into prod. A mismatch is a 403, and the plugin says so once in your logs:

[sentrinel] telemetry rejected (403). Check apiKey and env โ€” the key must be
issued for this environment.

It reports failures rather than swallowing them. Silence means it is working.

One app per project, one module per part

An app in Sentrinel is the whole project โ€” the backend, the phone app, the web client, the database, an AI agent. Each part reports into the same app as a module of it, so a request that starts on the phone can be followed into the backend and the query it caused, as one trace.

Give each part its own key (a server key for the backend, a mobile key for the phone), all issued for the one app. The key's name is the module unless you set module:

// backend
sentrinelPlugin({ appName: "fieldops", apiKey: process.env.SENTRINEL_KEY, module: "backend" })
// phone
SentrinelOptions(appName: 'fieldops', apiKey: key, module: 'mobile')

The API Keys page lists the parts of the selected app and when each last reported. Filter requests, logs, errors and traces to one part with ?module=backend.


Options

Option Type Default What it does
serverUrl string โ€” Required. Your Sentrinel API base URL.
appName string โ€” Required. The project. The key decides which app the data lands in; a name that differs from it is recorded as the module.
module string the key's name Which part of the project this is โ€” backend, checkout, worker. Set it when several services share one key.
env string "dev" Must match the key's environment.
apiKey string โ€” Required in production (SENTRINEL_REQUIRE_INGEST_KEY).
flushInterval number 30000 Milliseconds between batches.
version string โ€” Git SHA or semver. A change is recorded as a deploy and annotated on every chart.
consumerIdentifier string | (ctx) => string โ€” Which API client made the call. A string is read as a header name.
excludePaths (string | RegExp)[] [] Paths to ignore entirely โ€” health checks, metrics scrapes.
requestLogging object disabled Per-request log rows (below).
logging object disabled Structured logging via getLogger() (below).
logCapture object disabled Console output correlated to requests (below).
debug boolean false Verbose plugin logging.

version is worth setting

sentrinelPlugin({ /* โ€ฆ */ version: process.env.GIT_SHA })

Every chart then gets deploy markers, so "the p95 doubled" becomes "the p95 doubled at 14:20, which is when a3f9c21 went out."


Request logging

Aggregated metrics answer how much and how fast. Request logs answer what exactly happened on this one call.

requestLogging: {
  enabled: true,
  sampleRate: 0.1,              // keep 10% of successful requests
  slowRequestThresholdMs: 2000, // โ€ฆbut always keep slow ones
  logRequestHeaders: true,
  logRequestBody: true,
  logResponseBody: true,
  maxBodySize: 64 * 1024,
  maskHeaders: [/^authorization$/i, /^cookie$/i],
  maskBodyFields: [/^password$/i, /^cardNumber$/i, /^cvv$/i],
  maskQueryParams: [/^token$/i],
}

Three properties are worth understanding, because they are what make sampling safe to turn on:

  1. Errors and slow requests are never sampled out. sampleRate applies only to successful, fast requests โ€” the ones you were never going to read.
  2. Metrics stay exact. Counters are computed in-process before sampling, so a 1% sample rate still yields correct request counts and error rates. Only the individual log rows are thinned.
  3. Every row records the rate it was captured at, so counts derived from raw logs can be extrapolated honestly rather than silently under-reporting.

Where the call came from

Every request row also records who called and which host answered, with no configuration:

Field Source Notes
clientIp CF-Connecting-IP, True-Client-IP, X-Real-IP, Fly-Client-IP, X-Client-IP, then X-Forwarded-For The leftmost entry of an X-Forwarded-For chain is the caller; everything after it was appended by a hop.
country CF-IPCountry, X-Vercel-IP-Country, X-Geo-Country, Fastly-Client-Country-Code, CloudFront-Viewer-Country ISO-3166 alpha-2.
host The Host header Which vhost โ€” or which replica behind one name โ€” served the call.

This is resolved inside your process, because it has to be. By the time a payload reaches Sentrinel the only address left is your own server's; the caller's address exists only where the request landed.

Two consequences worth knowing before you go looking for the data:

Both are filterable on the request-logs API:

GET /api/requests?country=TZ
GET /api/requests?host=api.example.com

Masking

Masking runs in your process, before anything leaves it. A masked field never reaches the network, let alone Sentrinel's disk. Patterns match field names, not values, and maskBodyFields descends into nested objects.

Start from this and add your own:

maskHeaders: [/^authorization$/i, /^cookie$/i, /^x-api-key$/i],
maskBodyFields: [/password/i, /secret/i, /token/i, /^cardNumber$/i, /^cvv$/i, /^ssn$/i],

Custom metrics

Tracing tells you how long something took. This tells you what it cost.

import { count, gauge, histogram } from "@sentrinel/plugin";

count("llm.tokens", usage.input_tokens, { model: "deepseek-chat", direction: "input" });
count("revenue.usd", order.total, { plan: order.plan });
gauge("queue.depth", await queue.size());
histogram("llm.cost_per_call_usd", costOf(usage), { model });

They appear on the Metrics page, where you pick a series, choose how to aggregate it, and split it by any label you recorded.

Which one to reach for

Call Combines as Use it for
count(name, value, labels) Sum Tokens, revenue, retries, items sold
gauge(name, value, labels) Last reading Queue depth, connections, cache size
histogram(name, value, labels) Percentiles Cost per call, batch size, time in a queue

count() defaults to 1, so count("signups") is a valid increment.

Safe to call in a loop

An increment mutates a map, not a network buffer. The SDK folds every call into one row per (name, labels) per flush window, so a counter called ten thousand times a second costs one row every thirty seconds, not ten thousand rows a second. Recording a metric per token is a normal thing to do.

Every aggregate travels on every row โ€” sum, last, count, min, max, p50, p95, p99 โ€” regardless of which function you called. That means you can ask a counter "what was the largest single increment" later without re-instrumenting for it.

Labels are dimensions, not identifiers

count("llm.tokens", n, { model, tier });        // good โ€” a handful of values
count("llm.tokens", n, { userId: user.id });    // bad โ€” a series per user

A label per user is a cardinality bomb. The SDK caps a process at 2,000 live series and drops new ones past that rather than growing without bound โ€” a monitoring SDK must not become the outage it was installed to report. It also caps 12 label keys and 128 characters per value.

Label order does not matter: {a, b} and {b, a} are the same series, so two call sites that disagree about ordering still chart as one line.

NaN and Infinity are refused rather than summed โ€” one poisoned value would make every chart drawn from that sum useless.

Requirements

Custom metrics need TELEMETRY_STORE=clickhouse. Ingest answers 501 on the Postgres store rather than accepting and dropping them, so an integration that looks connected is never quietly empty. Retention is its own knob, RETENTION_CUSTOM_METRICS_DAYS, defaulting to 400 days โ€” pre-aggregated rows are small, and a spend series is only useful with months behind it.


Structured logging

Conventional logging optimises for writing โ€” a readable sentence โ€” when what you actually do with logs is query them. console.log(\user ${id} failed checkout`)` is easy to write and impossible to ask questions of: you cannot filter by user, group by failure reason, or tie it to the request that produced it.

import { getLogger, withContext, addRequestContext } from "@sentrinel/plugin";

const log = getLogger(["payment", "checkout"]);

log.info("Checkout completed", { orderId, amount, provider: "stripe" });
log.warn("Checkout rejected", { reason: "insufficient_funds", amount });

Enable it once, in the plugin:

sentrinelPlugin({
  /* โ€ฆ */
  logging: { minLevel: "info", echo: false },
})

Four things make this different from console.log:

1. The message is stable; the values are fields. "Checkout rejected" is one message you can group and count, and reason is a field you can filter. Interpolating the reason into the string would make every occurrence unique and therefore ungroupable. Placeholders ("Fetched {count} orders") are stored verbatim for the same reason โ€” the values are already in the attributes.

2. Every record is correlated automatically. Request id, trace id and span id are attached from the ambient context, so "everything logged during this request" is a lookup, not a grep across timestamps. In the dashboard, a log links to its request and a request lists its logs.

3. Context is inherited, not repeated.

withContext({ userId: user.id, tenantId: org.id, tier: user.tier }, async () => {
  await processOrder();   // every log inside carries those three fields
});

The function three levels down never heard of a user, and its logs still carry one. This is what makes high-cardinality filtering practical โ€” you attach the context once, where you have it.

4. Categories are hierarchical. getLogger(["api", "checkout"]) logs under api.checkout; filtering by api selects the whole subsystem. log.getChild("db") nests further, and log.with({ jobId }) binds attributes to every record from that logger.

Canonical wide events

Scattered log lines make you reassemble a request from fragments. The opposite approach โ€” one rich event per request โ€” answers most questions on its own:

addRequestContext({
  tier: user.tier,
  cartTotal,
  experiment: "new-pricing",
  outcome: "success",
});

Those fields land on the request row itself, next to method, path, status, latency, consumer and trace id. "Slow checkouts for enterprise customers in the new pricing experiment" becomes one query rather than a correlation exercise. Call it as many times as you like during a request; the fields merge.

Everything is linked

One request produces a set of records that name each other:

A trace is emitted for every request, not only ones that opened child spans โ€” a request with no traceSpan() calls still has a real trace, the HTTP span itself. Skipping it would leave logs and errors pointing at a trace id that resolves to nothing.

Levels, cost and limits

Logging outside a request

Background jobs, schedulers and startup all work; those records simply have no request to correlate to.

const log = getLogger("worker");
log.info("Nightly reconciliation started", { batchSize: 5000 });

If several instrumented apps share one process, a request's logs go to the app handling it; logs outside any request go to whichever app initialised first.

Console capture (application logs)

Correlates your console.* output with the request that produced it, so a request-log row can show you the exact lines that ran inside it.

logCapture: {
  enabled: true,
  minLevel: "info",       // "debug" captures everything
  maxPerRequest: 50,      // a hot loop can't flood the buffer
  maxMessageLength: 2000,
}

Two consequences worth knowing:


Distributed tracing

Spans nest automatically. Anything you wrap runs as a child of whatever is already on the stack, and the root span is the HTTP request itself.

import { traceSpan, traced, sentrinelFetch } from "@sentrinel/plugin";

// Wrap a block
const risk = await traceSpan("fraud.check", { kind: "INTERNAL" }, async (span) => {
  span.setAttribute("amount", amount);
  return scoreTransaction(amount);
});

// Or wrap a function once, at definition
const chargeCard = traced(async function chargeCard(amount: number) {
  return stripe.charges.create({ amount });
});

// Cross-service calls propagate the trace (W3C traceparent)
const res = await sentrinelFetch("https://payments.internal/v1/charges", {
  method: "POST",
  body: JSON.stringify({ amount }),
});

traceSpan accepts either shape โ€” traceSpan(name, fn) or traceSpan(name, { kind }, fn).

kind should be one of SERVER, CLIENT, DB, INTERNAL, PRODUCER, CONSUMER; the waterfall colours by it.

This produces a real tree in the dashboard:

POST /api/v1/payment/checkout   SERVER    271ms   โ† root
  checkFraudRisk                INTERNAL    0ms
  http.client POST /v1/charges  CLIENT    245ms   โ† the actual cost
  payDb.processLedgerEntry      INTERNAL   26ms

Click any span for its duration, self time, status, and attributes. Self time is usually the number that matters: a 271 ms span that spent 245 ms waiting on a child isn't the problem โ€” its child is.

Incoming traceparent headers are honoured, so a trace started upstream continues through your service instead of being split into two.


Other frameworks

Express

import { sentrinelExpressMiddleware } from "@sentrinel/plugin";

app.use(sentrinelExpressMiddleware({
  serverUrl: process.env.SENTRINEL_URL!,
  appName: "legacy-api",
  env: "prod",
  apiKey: process.env.SENTRINEL_API_KEY,
  consumerIdentifier: "x-consumer-id",   // header name
}));

Mount it before your routes so it sees every request.

Next.js

// middleware.ts
import { sentrinelNextMiddleware } from "@sentrinel/plugin";

export const middleware = sentrinelNextMiddleware({
  serverUrl: process.env.SENTRINEL_URL!,
  appName: "storefront",
  env: process.env.VERCEL_ENV ?? "prod",
  apiKey: process.env.SENTRINEL_API_KEY,
});

OpenTelemetry โ€” no plugin at all

Any OTel SDK or Collector can export straight to Sentrinel:

OTEL_EXPORTER_OTLP_ENDPOINT=https://api.sentrinel.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=snt_live_โ€ฆ

GenAI spans (gen_ai.request.model plus gen_ai.usage.*_tokens) are priced automatically and appear on the LLM cost page.

Flutter / mobile

The full SDK ships in the same public repo, at sentrinel_flutter/ โ€” the tree there is flat, so a git: dependency uses that path and not a packages/ prefix. See Flutter and Dart below.

Raw header helper

createFlutterHeaderMap() builds the headers a mobile client should send so its calls are attributed to the right consumer and joined to the server-side trace.


Cron and heartbeat monitoring

Not a plugin feature โ€” a URL. Create a monitor in the dashboard and add its check-in URL to the job:

# at the end of the job
curl -fsS https://api.sentrinel.example.com/api/checkin/<token>

# long jobs: report start, duration, or explicit failure
curl -fsS "https://api.sentrinel.example.com/api/checkin/<token>?state=start"
curl -fsS "https://api.sentrinel.example.com/api/checkin/<token>?duration=1430"
curl -fsS "https://api.sentrinel.example.com/api/checkin/<token>?state=fail"

The token is the credential, so these endpoints need no API key โ€” a cron line stays a one-liner. A sweeper marks the monitor missed when a ping doesn't arrive within its interval plus grace, and notifies the same channels alerts use.


Overhead and failure behaviour

Monitoring should never be the reason your service is down.


Flutter and Dart

A real Dart package โ€” not a header helper. It records every request your app makes, the errors and logs around them, and sends traceparent so the backend plugin continues the same trace: a tap and the server work it caused land on one timeline.

This is the pure-Dart core โ€” what a Dart CLI or server depends on:

dependencies:
  sentrinel:
    git:
      url: https://github.com/Zaga-ltd/sentinel_packages.git
      path: sentrinel_flutter

A Flutter app wants sentrinel_flutter instead, which adds what needs the framework โ€” crash handlers that survive a restart, app start and frozen frames, navigation breadcrumbs โ€” and pulls this core in with it. The dependency key and the directory differ, so copy it from MOBILE.md rather than adapting the block above.

import 'package:sentrinel/sentrinel.dart';

void main() {
  Sentrinel.init(
    serverUrl: 'https://api.sentrinel.dev',
    appName: 'mobile-app',
    apiKey: const String.fromEnvironment('SENTRINEL_API_KEY'),
    consumerIdentifier: 'ios_app',
  );
  runApp(const MyApp());
}

// every call through this client is recorded
final client = Sentrinel.httpClient();

Sentrinel.setContext({...}) attaches fields to every later record, Sentrinel.info/warn/error write structured logs, and Sentrinel.captureError takes a caught exception or your runZonedGuarded handler.

It is built not to hurt the host app: nothing blocks on the network, buffers are bounded (500 requests / 500 logs / 200 errors, oldest dropped, with a droppedRecords count), a failed batch is dropped rather than retried forever, and every call before init() is a no-op. Full detail in the package README.


Browsers

@sentrinel/plugin/browser covers the other side of the same trace: uncaught errors, failed and slow fetch calls, breadcrumbs and one session per page load, with traceparent on same-origin requests so a click and the handler it triggered share a waterfall.

It ships no API key โ€” everything in a bundle is public โ€” so the page posts to your own origin and @sentrinel/plugin/tunnel forwards with the key.

// client
initSentrinelBrowser({ endpoint: '/api/sentrinel', release: '2026.8.2' });

// server
export const POST = createSentrinelTunnel({
  serverUrl: 'https://api.sentrinel.dev',
  appName: 'admin', env: 'prod',
  apiKey: process.env.SENTRINEL_API_KEY!,
});

Full detail in BROWSER.md.


See also