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.


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.

The API key must match the app name and environment it was issued for. 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, appName and env โ€” the key
must belong to this app and environment.

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


Options

Option Type Default What it does
serverUrl string โ€” Required. Your Sentrinel API base URL.
appName string โ€” Required. Must match the app the key belongs to.
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.

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],

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

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.