A practical guide to adding production-ready monitoring to any JavaScript/TypeScript backend in under 5 minutes.
The Problem
You've deployed your API. Users are hitting it. But when something goes wrong:
- Which endpoint is slow?
- What's the error rate trend?
- Which clients are causing the most load?
- Did the last deploy break anything?
Without monitoring, you're flying blind. With Sentrinel, you get answers in seconds.
What You Get
- Request metrics: counts, latency percentiles (p50/p95/p99), Apdex scores
- Error tracking: automatic fingerprinting, stack traces, regression detection
- Endpoint discovery: routes are detected automatically โ no config needed
- Deploy markers: see exactly when a release affected performance
- Consumer analysis: which clients/apps are using your API
- Distributed tracing: connect mobile โ API โ database in one timeline
Elysia (Bun) โ First-Class Support
Elysia gets the full plugin experience.
Install
bun add "@sentrinel/plugin@github:Zaga-ltd/sentinel_packages"
Setup
import { Elysia } from "elysia";
import { sentrinelPlugin } from "@sentrinel/plugin";
new Elysia()
.use(sentrinelPlugin({
serverUrl: "https://api.sentrinel.dev",
appName: "my-api",
env: "prod",
apiKey: process.env.SENTRINEL_API_KEY,
}))
.get("/users", () => getUsers())
.listen(3000);
That's it. Every request is now monitored.
Configuration Options
sentrinelPlugin({
// Required
serverUrl: "https://api.sentrinel.dev",
appName: "my-api",
// Environment (matches API key)
env: "prod", // "prod" | "staging" | "dev"
// API key (required in production)
apiKey: process.env.SENTRINEL_API_KEY,
// Deploy markers
version: process.env.GIT_SHA, // appears on all charts
// Sampling (controls log volume, metrics always exact)
requestLogging: {
enabled: true,
sampleRate: 0.1, // log 10% of successful requests
slowRequestThresholdMs: 2000, // always log slow requests
},
// Capture console output
logCapture: {
enabled: true,
minLevel: "warn",
},
// Exclude paths (health checks, metrics)
excludePaths: ["/health", "/metrics"],
})
Consumer Identification
Identify which client is making requests:
sentrinelPlugin({
// ... other options
consumerIdentifier: "x-api-client", // header name
// or
consumerIdentifier: (ctx) => ctx.request.headers.get("x-app-version"),
})
Express (Node.js)
npm install "@sentrinel/plugin@github:Zaga-ltd/sentinel_packages"
import express from "express";
import { sentrinelExpressMiddleware } from "@sentrinel/plugin/express";
const app = express();
app.use(sentrinelExpressMiddleware({
serverUrl: process.env.SENTRINEL_URL!,
appName: "my-express-api",
env: "prod",
apiKey: process.env.SENTRINEL_API_KEY,
}));
app.get("/users", (req, res) => {
res.json(getUsers());
});
app.listen(3000);
Next.js (App Router)
Add to your middleware.ts:
import { sentrinelNextMiddleware } from "@sentrinel/plugin/next";
export const middleware = sentrinelNextMiddleware({
serverUrl: process.env.SENTRINEL_URL!,
appName: "my-next-app",
env: process.env.VERCEL_ENV ?? "prod",
apiKey: process.env.SENTRINEL_API_KEY,
});
export const config = {
matcher: "/api/:path*",
};
OpenTelemetry (Any Framework)
Already using OTel? Skip the plugin entirely:
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.sentrinel.dev
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=snt_live_your_key_here
Any OTel SDK or Collector works. GenAI spans are priced automatically.
Bun Native
For raw Bun.serve without Elysia:
import { sentrinelBunMiddleware } from "@sentrinel/plugin/bun";
Bun.serve({
port: 3000,
fetch: sentrinelBunMiddleware({
serverUrl: "https://api.sentrinel.dev",
appName: "my-bun-app",
env: "prod",
apiKey: process.env.SENTRINEL_API_KEY,
}, async (req) => {
return new Response("Hello");
}),
});
Structured Logging
The plugin includes a structured logger that correlates logs to requests:
import { getLogger } from "@sentrinel/plugin";
const log = getLogger(["payment", "checkout"]);
log.info("Payment processed", {
orderId: order.id,
amount: order.total,
provider: "stripe",
});
log.error("Payment failed", {
orderId: order.id,
error: err.message,
});
Logs appear in the dashboard, correlated to the request that triggered them.
Error Tracking
Thrown errors are automatically captured with stack traces. The plugin groups errors by fingerprint โ the same bug reported 1,000 times appears as one issue with an occurrence count.
// This error is automatically captured
app.get("/risky", () => {
throw new Error("Something went wrong");
});
For caught errors:
try {
await riskyOperation();
} catch (err) {
logger.error({ err }, "Operation failed");
throw err;
}
Deploy Markers
Set version to your Git SHA or semver:
sentrinelPlugin({
// ...
version: process.env.GIT_SHA,
})
Every chart now shows vertical deploy markers. "The p95 doubled" becomes "the p95 doubled at 14:20, which is when a3f9c21 went out."
Environment Variables
SENTRINEL_SERVER_URL=https://api.sentrinel.dev
SENTRINEL_API_KEY=snt_live_xxxxx
How App Identity Works
The plugin sends appName + env with every payload. The API auto-provisions the app on first receipt โ no manual registration needed.
The key decides the app: data always lands in the app the key was issued for, whatever the payload says, so a key cannot write into anyone else's app.
Environment is enforced: a staging key cannot write to prod.
Multiple Services
An app is the whole project, and each service is a module of it. Report them all into one app, so a request can be followed from the gateway through every service it touched as one trace:
// auth-service
sentrinelPlugin({ appName: "lending", module: "auth", ... })
// payment-service
sentrinelPlugin({ appName: "lending", module: "payments", ... })
// gateway
sentrinelPlugin({ appName: "lending", module: "gateway", ... })
Without module, each part is named after its API key.
Each appears as a separate card in the dashboard.
Overhead
- Request path: in-memory counter update. No I/O, no awaiting Sentrinel.
- Memory: bounded buffers, oldest data dropped when full.
- Network: one batched POST per flush interval.
- When Sentrinel is down: batch dropped, single warning logged. Your app continues.
Full configuration โ collect everything
The quick-start above gives you metrics and basic monitoring. To collect every signal โ request logs with payloads, console output, structured logging, distributed tracing, consumer tracking, deploy markers, and data masking โ use this complete configuration:
import { Elysia } from "elysia";
import { sentrinelPlugin } from "@sentrinel/plugin";
const app = new Elysia()
.use(sentrinelPlugin({
// Core
serverUrl: "http://localhost:3001",
appName: "my-api",
env: "prod",
apiKey: process.env.SENTRINEL_API_KEY,
version: process.env.GIT_SHA,
debug: false,
flushInterval: 30000,
// Consumer tracking โ identifies which client made each request
consumerIdentifier: (ctx) =>
ctx.request.headers.get("x-consumer-id") ?? "unknown",
// Exclude health checks and internal routes from monitoring
excludePaths: ["/health", "/metrics", /^\/internal\//],
// Request logging โ full request/response detail
requestLogging: {
enabled: true, // turns on request log rows
sampleRate: 1.0, // keep 100% of fast successes
slowRequestThresholdMs: 500, // always capture slow requests
logRequestHeaders: true, // include request headers
logRequestBody: true, // include request body
logResponseBody: true, // include response body
maxBodySize: 65536, // 64KB max body capture
// Mask sensitive data before it leaves your process
maskHeaders: [/^authorization$/i, /^cookie$/i, /^x-api-key$/i],
maskQueryParams: ["token", "secret", "key"],
maskBodyFields: [/^password$/i, /^token$/i, /^credit_card$/i, /^cvv$/i],
},
// Console log capture โ correlates console.* to the request
logCapture: {
enabled: true,
minLevel: "debug", // "debug" = capture everything
maxPerRequest: 100,
maxMessageLength: 2000,
},
// Structured logging โ getLogger() with categories and attributes
logging: {
minLevel: "debug",
echo: true, // mirror to stdout (useful in dev)
},
}))
.get("/", () => "hello")
.listen(3000);
What each piece gives you
| Config block | What it enables | Dashboard page |
|---|---|---|
requestLogging.enabled |
Individual request/response log rows | Request Logs, Traffic endpoint detail |
requestLogging.logRequestBody |
Request payloads | Request Logs โ Payloads tab |
requestLogging.logResponseBody |
Response payloads | Request Logs โ Payloads tab |
logCapture.enabled |
Console output correlated to requests | Logs, Request Logs โ Logs tab |
logging.* |
Structured logs via getLogger() |
Logs (filterable by category, attributes) |
consumerIdentifier |
Per-client metrics | Consumers |
version |
Deploy markers on all charts | All time-series charts |
maskHeaders |
Redacts secrets before they leave | โ (security) |
Adding distributed tracing
Wrap key operations to see where time goes inside each request:
import { traceSpan, traced, sentrinelFetch } from "@sentrinel/plugin";
// Manual span โ see its duration and self-time in the waterfall
const user = await traceSpan("db.findUser", async (span) => {
span.setAttribute("db.system", "postgresql");
return db.users.findById(id);
});
// Auto-trace a function
const getProducts = traced("db.getProducts", () => db.products.find());
// Cross-service calls propagate the trace automatically
const res = await sentrinelFetch("https://payments.internal/v1/charges");
Using the structured logger
import { getLogger, withContext, addRequestContext } from "@sentrinel/plugin";
const log = getLogger(["api", "checkout"]);
// Context is inherited โ every log inside carries these fields
withContext({ userId: "u_123", tier: "enterprise" }, async () => {
log.info("Payment authorized", { orderId: "o_456", amount: 99.99 });
// Attach business context to the request row itself
addRequestContext({ orderId: "o_456", cartTotal: 99.99 });
});
Other frameworks โ same full config
Express:
import { sentrinelExpressMiddleware } from "@sentrinel/plugin/express";
app.use(sentrinelExpressMiddleware({
serverUrl: "http://localhost:3001",
appName: "my-express-api",
env: "prod",
apiKey: process.env.SENTRINEL_API_KEY,
consumerIdentifier: "x-consumer-id",
requestLogging: { enabled: true, sampleRate: 1.0, slowRequestThresholdMs: 500,
logRequestHeaders: true, logRequestBody: true, logResponseBody: true,
maskHeaders: [/^authorization$/i], maskBodyFields: [/^password$/i] },
logCapture: { enabled: true, minLevel: "debug" },
logging: { minLevel: "debug", echo: false },
}));
Next.js:
import { sentrinelNextMiddleware } from "@sentrinel/plugin/next";
export const middleware = sentrinelNextMiddleware({
serverUrl: process.env.SENTRINEL_URL!,
appName: "my-next-app",
env: process.env.VERCEL_ENV ?? "prod",
apiKey: process.env.SENTRINEL_API_KEY,
requestLogging: { enabled: true, sampleRate: 1.0, slowRequestThresholdMs: 500,
logRequestHeaders: true, logRequestBody: true, logResponseBody: true },
logCapture: { enabled: true, minLevel: "info" },
logging: { minLevel: "debug", echo: false },
});
OpenTelemetry (any language):
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.sentrinel.dev
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=snt_live_your_key_here
For the complete reference covering every option, every signal, and every dashboard page โ see FULL_CONFIG.md.
What You See in the Dashboard
After integrating:
| Page | What It Shows |
|---|---|
| Apps | Overview cards for each monitored service |
| Traffic | Request volume, endpoints, response times |
| Errors | Error rates, stack traces, regression detection |
| Performance | Latency percentiles, Apdex, slowest endpoints |
| Consumers | Which clients/apps are using your API |
| Traces | End-to-end distributed traces |
| Resources | CPU and memory usage |
| Alerts | Threshold-based alerts with notifications |
Getting Started
- Create an account at sentrinel.dev
- Create an app and API key in the dashboard
- Install the plugin for your framework
- Deploy
Data appears within seconds.
Since this was written
The plugin has grown a few things this guide predates. All of them are on by default or one import away.
Custom metrics. Tracing says why a call was slow; it could not say what it cost. count, gauge and histogram fold in memory and leave the process as one row per series per flush, so recording a metric per token is a normal thing to do:
import { count, gauge, histogram } from "@sentrinel/plugin";
count("llm.tokens", usage.input_tokens, { model: "deepseek-chat", direction: "input" });
gauge("queue.depth", await queue.size());
histogram("checkout.cart_value", order.total, { plan: user.plan });
They chart on the Metrics page, split by any label. The story behind them is in "What did we spend?".
Where the request came from. Every request record now carries the resolved client IP (through your proxy's forwarding headers), the country when a CDN supplies one, and the host that served it โ so "is this one client hammering us" and "which replica is slow" are filters rather than investigations.
Every signal knows its user. The consumerIdentifier you return is stamped on the request, and on every log line, error, span and event produced while serving it โ stored on each row, not resolved by join, so a user's trail stays complete as request logs age out. Why that matters.
One key per integration. The plugin's key is a server key, and it is what every key issued before kinds existed already was. Mobile SDKs, the database collector, OTLP exporters and AI agents now get their own kinds, each able to reach only its own surface. The reasoning.
Your coding agent can read this. An MCP server and a CLI hand Claude Code, Codex and friends the issues, logs and traces the plugin collects, on an agent key that can see one app and write nothing. How to set it up.
Ingest fails over. If the API's telemetry store is down, batches are held in a durable log and drained when it returns; if the log is down, they go straight to the store. Neither direction needs a setting. Five days without a broker.