โ† All posts
GUIDE August 2, 2026 ยท 9 min read

Monitoring your API with Sentrinel: Elysia, Express, and beyond

SR
Sentrinel Team
Product & engineering

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:

Without monitoring, you're flying blind. With Sentrinel, you get answers in seconds.

What You Get

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


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

  1. Create an account at sentrinel.dev
  2. Create an app and API key in the dashboard
  3. Install the plugin for your framework
  4. 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.

Next Steps