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:
- Errors and slow requests are never sampled out.
sampleRateapplies only to successful, fast requests โ the ones you were never going to read. - 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.
- 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:
- the request row carries the trace id and your
addRequestContext()fields - every log record carries the request id, trace id and the span it was written inside
- the trace carries the request id, so the waterfall links back
- an error occurrence carries both, which is what lets an issue open the exact request that produced it
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
minLeveldrops records before they are buffered โ nothing is sent.- Attributes are bounded at ingest: 64 fields, 2,000 characters each. Objects and arrays are stored as JSON text. The value is in many small queryable fields, not embedded documents.
- Logging is buffered and flushed on the normal interval. It never blocks a request, and if Sentrinel is unreachable the batch is dropped with one warning rather than backing up your process.
echo: truealso prints each record to stdout. Off by default โ your app owns its console output.
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:
- A request that logged an error is never sampled out, even if it returned
200 and
sampleRatewould otherwise have dropped it. The requests where your code complained are the ones you want to keep. - The Request logs page gains a log-level filter: "show me requests that emitted an error-level line" finds problems that never surfaced as a 5xx.
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
- Request path: an in-memory counter update. No I/O, no awaiting Sentrinel.
- Network: one batched POST per
flushIntervalper data type. - Memory: bounded by
maxPerRequestfor logs; metric buffers are per endpoint, not per request, so they don't grow with traffic. - When Sentrinel is down: the batch is dropped and your app carries on. The plugin never retries in a way that could back up your process, and it warns once rather than every flush.
- When you're over quota: the API answers
429and the plugin drops the batch; you'll see one warning in your logs.
Monitoring should never be the reason your service is down.