Strapi monitoring comes down to four things: a request log for every call to your content API and admin panel, response times per route (with an eye on populate), error rates by status class, and a record of who is hammering your public endpoints. Strapi 5 gives you a Winston-based logger and a Koa middleware stack to build this on. This guide sets up Strapi logging with a custom request-log middleware, shows where performance problems usually come from, and covers rate limiting on public content APIs.

Versions: the configuration follows the Strapi 5 documentation as of November 2026 (current release 5.62.0) on Node.js 22. The request-log middleware below was run against a mocked Koa context on Node 22.23; its output is pasted where it appears.

What to monitor in a Strapi 5 app

One Strapi process serves several kinds of caller, and each fails in its own way.

  • The content API under /api/*: public or token-protected reads from your frontend, apps and anyone else who finds it. Watch volume, latency per route, 4xx and 5xx rates, and the populate and pagination parameters callers send.
  • The admin panel and its APIs (/admin, /content-manager, /upload and friends): low volume, authenticated, and the place where a slow save or a failed upload shows up as an editor complaint.
  • Authentication routes from the Users and Permissions plugin (/api/auth/local, /api/auth/forgot-password): the first target for credential stuffing.
  • Outbound calls your lifecycle hooks and custom services make to other APIs (webhooks, search indexers, image services).

Keep those surfaces apart in your logs: a spike of 401s on /api/auth/local and a slow /content-manager save are different problems, and averaging them together hides both.

Strapi logging: the built-in logger

Strapi 5 ships a logger built on Winston and exposes it as strapi.log, with the usual levels (error, warn, info, http, debug, silly). Two defaults are worth knowing.

First, the strapi::logger middleware is the first entry in config/middlewares.js and is what prints a line per request. The middlewares configuration docs describe it simply as the middleware "used to log requests."

Second, the default level changed in v5. In Strapi 4 it was silly, so everything printed. In Strapi 5 the server log level defaults to http, which hides debug and silly. If a v4 habit of strapi.log.debug() suddenly prints nothing after an upgrade, that is why.

To change the format or add transports, create config/logger.js. Strapi merges what you export with its defaults at startup. This is the shape the docs use, switched to JSON so a log shipper can parse it:

// config/logger.js 'use strict'; const { winston, formats: { levelFilter } } = require('@strapi/logger'); module.exports = { transports: [ new winston.transports.Console({ level: 'http', format: winston.format.combine( levelFilter('http'), winston.format.timestamp(), winston.format.json() ), }), ], }; 

levelFilter('http') keeps only http lines on this transport. Drop it if you want warnings and errors on the same stream, which is what most teams want in production. Pretty-printed output is fine on a laptop; in production, one JSON object per line is what Loki, CloudWatch, Datadog or any other aggregator expects.

Strapi request logging with a custom middleware

The built-in request line is short. It tells you method, path, status and time, but not which surface was hit, who made the call, or what they asked Strapi to populate. A small global middleware fills that in.

Create src/middlewares/request-log.js:

// src/middlewares/request-log.js 'use strict'; const ADMIN_PREFIXES = ['/admin', '/content-manager', '/content-type-builder', '/upload', '/i18n', '/users-permissions']; function surface(path) { if (path.startsWith('/api/')) return 'content-api'; if (ADMIN_PREFIXES.some((p) => path === p || path.startsWith(p + '/'))) return 'admin'; return 'other'; } module.exports = (config, { strapi }) => { const slowMs = config.slowMs ?? 500; return async (ctx, next) => { const start = process.hrtime.bigint(); try { await next(); } finally { const durationMs = Number(process.hrtime.bigint() - start) / 1e6; const entry = { surface: surface(ctx.path), method: ctx.method, path: ctx.path, status: ctx.status, durationMs: Math.round(durationMs), ip: ctx.request.ip, userId: ctx.state.user?.id ?? null, populate: ctx.query?.populate ? JSON.stringify(ctx.query.populate).slice(0, 120) : undefined, }; const line = JSON.stringify(entry); if (ctx.status >= 500) strapi.log.error(line); else if (durationMs > slowMs || ctx.status >= 400) strapi.log.warn(line); else strapi.log.http(line); } }; }; 

Application middlewares in src/middlewares are referenced as global::<name>. Order matters, because Strapi's middlewares form a Koa onion. Put this one right after strapi::logger and before strapi::errors, so that by the time await next() returns, Strapi's error handler has already turned a thrown error into the status the client receives:

// config/middlewares.js module.exports = [ 'strapi::logger', { name: 'global::request-log', config: { slowMs: 500 } }, 'strapi::errors', 'strapi::security', 'strapi::cors', 'strapi::poweredBy', 'strapi::query', 'strapi::body', 'strapi::session', 'strapi::favicon', 'strapi::public', ]; 

ctx.state.user is set later, during routing, but the middleware reads it after next() returns, so authenticated calls carry the user id. Here is the output from four simulated requests (a populated list read, a slow authenticated read with a 50 ms threshold, a forbidden admin save and a controller that throws), with documentation IPs:

http {"surface":"content-api","method":"GET","path":"/api/articles","status":200,"durationMs":0,"ip":"203.0.113.7","userId":null,"populate":"{\"author\":{\"fields\":[\"name\"]},\"cover\":true}"} warn {"surface":"content-api","method":"GET","path":"/api/articles/abc123","status":200,"durationMs":80,"ip":"203.0.113.7","userId":7} warn {"surface":"admin","method":"POST","path":"/content-manager/collection-types/api::article.article","status":403,"durationMs":0,"ip":"203.0.113.7","userId":null} error {"surface":"content-api","method":"GET","path":"/api/broken","status":500,"durationMs":0,"ip":"203.0.113.7","userId":null} 

Don't log request bodies here by default: Content Manager saves carry whole entries and auth routes carry passwords. If you need bodies, mask the sensitive fields first (our guide to redacting sensitive data in Node.js logs covers patterns that work). And path holds document IDs, so group by route pattern when you build dashboards, or /api/articles/:id becomes ten thousand separate rows.

Strapi performance monitoring: populate, pagination and slow queries

Most slow Strapi endpoints are slow because of what the request asked for, which is why the middleware above logs populate.

By default the REST API returns no relations, media, components or dynamic zones. The populate and select docs say it "does not populate any relations, media fields, components, or dynamic zones" unless you pass populate. populate=* goes one level deep. Anything deeper is explicit, and every level adds joins.

That is also why Strapi's own support team says "populate deep" plugins are not recommended for production use: they generate unbounded queries with large nested joins, perform badly with dynamic zones and components, and offer no guardrails. Their suggested alternatives are explicit population, route-level middleware, keeping depth to about three levels, and caching.

A checklist for a slow content-API route:

  1. Look at the logged populate value for the slow requests. If a frontend is sending a deep object on a list endpoint, fix the query or move it into a route middleware that sets a fixed populate for that route.
  2. Check pagination. rest.defaultLimit is 25 and rest.maxLimit has no default cap, according to the API configuration docs; new projects get maxLimit: 100 in the generated file. If yours was removed or raised, one request can ask for every entry.
  3. Turn off counts where you don't show them. rest.withCount defaults to true, which runs a count query on each list request; callers can pass pagination[withCount]=false.
  4. Compare durations by route and by populate shape over time. A route that got slower after a content model change (a new component in a dynamic zone, say) shows up here before anyone files a ticket.

A guarded config/api.js looks like this:

// config/api.js module.exports = { rest: { defaultLimit: 25, maxLimit: 100, withCount: true, }, }; 

Rate limiting and abuse on public Strapi content APIs

A Strapi content API with public find permissions is an open read endpoint. Scrapers will page through it, and anything with a login will see password guessing.

Strapi's built-in limiter lives in the Users and Permissions plugin and applies to its authentication and registration endpoints. Its defaults are 10 requests per 60,000 ms, and you can tune them in config/plugins.js:

// config/plugins.js module.exports = { 'users-permissions': { config: { ratelimit: { enabled: true, interval: 60000, max: 10, }, }, }, }; 

Upgrade if you are on an older 5.x. GHSA-7mqx-wwh4-f9fw (CVE-2025-64526) describes how, before 5.45.0, the limiter keyed partly on the email field of the request body, even on routes like /auth/local and /auth/reset-password. Sending a different email on each request produced a fresh rate-limit key and defeated the per-IP throttle. GitHub rates it Moderate (CVSS 4.0 score 6.9). Versions up to 5.44.0 of @strapi/plugin-users-permissions are affected; 5.45.0 fixes it. Your request log will show the pattern as many POST /api/auth/local lines from one IP with no 429s among them.

Make sure the IP is real. Behind a load balancer, every request arrives from the proxy's address unless you set proxy.koa in config/server.js. Strapi 5.52.0 and later also take ipHeader and maxIpsCount; the server configuration docs note that maxIpsCount: 0 (the default) means unlimited, which is unsafe behind a proxy. Set it to the number of proxies you run:

// config/server.js module.exports = ({ env }) => ({ host: env('HOST', '0.0.0.0'), port: env.int('PORT', 1337), proxy: { koa: env.bool('IS_PROXIED', true), maxIpsCount: env.int('MAX_IPS_COUNT', 1), }, }); 

The built-in limiter does not throttle your /api content routes. For those, rate limit at the edge (your CDN or load balancer), add a Koa rate-limit middleware in front of /api, or use a firewall that runs in the app. Whichever you choose, the request log is how you tune it: count requests per IP per route per minute for a normal week, then set the limit above that. Our write-up on credential stuffing detection shows what the login pattern looks like in logs.

Strapi monitoring options compared

Option Good at What you set up
strapi.log and config/logger.js Free, already there, JSON output with a Winston transport Format, transports, somewhere to send the logs
Custom middleware plus a log stack (Loki, ELK, CloudWatch) Full control of fields; cheap at small volume The middleware above, a shipper, dashboards and alerts
OpenTelemetry (self-run) Vendor-neutral traces, including database spans SDK preload, collector, a backend to store traces
Sentry (@strapi/plugin-sentry) Error tracking with stack traces and alerts on new issues A DSN and the plugin's config
Datadog or New Relic APM APM across many services and languages in one place An agent, a tracer preload, a plan that fits your host count
ApiTraffic (@apitraffic/strapi) Request log with bodies, APM spans and an in-app firewall in one Strapi plugin Plugin, a middleware entry, two environment variables

If you already run one of these for other services, extending it to Strapi is often the right call. The middleware and config in this post work with any of them.

Doing this with ApiTraffic

If you'd rather not assemble the pieces, the @apitraffic/strapi plugin covers request logging, performance and the firewall for Strapi 5 on Node.js 20 or later. Strapi runs on Koa, so the plugin is ApiTraffic's Koa integration with Strapi's configuration around it.

Install it, add plugin::apitraffic.apitraffic to the end of config/middlewares.js, and set API_TRAFFIC_TOKEN and API_TRAFFIC_BUCKET in .env:

npm install @apitraffic/strapi 

It records every request that reaches the middleware, including ones whose controller throws and ones the client abandons (recorded as 499), with the request and response bodies as Strapi parsed and set them. Routes are grouped with their pattern, so GET /api/articles/42 lands under /api/articles/{id}. Outbound calls your Strapi code makes with http, https or fetch are captured too. Redaction rules mask sensitive fields before they leave your server.

The firewall runs in the same middleware, ahead of Strapi's router, and is switched on from your ApiTraffic account, so IP lists, rate limits and IP reputation apply to /api routes without a deploy. Because Strapi's middlewares are global, it also sees the admin APIs; the docs suggest starting a policy in Monitor mode and adding an xss exclusion for ^/content-manager/ if rich-text saves get flagged. For database spans, install @apitraffic/apm and preload @apitraffic/strapi/register in your start script. The Strapi setup guide has the details.

FAQ

How do I log API requests in Strapi 5?

The strapi::logger middleware logs one line per request at the http level. For more detail (user id, surface, populate, slow-request warnings), add a global middleware in src/middlewares and register it as global::<name> right after strapi::logger in config/middlewares.js.

Why don't my strapi.log.debug() calls show up in Strapi 5?

Strapi 5 changed the default server log level from silly to http, so debug and silly are hidden. Raise the level in your logger configuration (the v5 migration notes show where) if you need debug output.

How do I monitor Strapi performance?

Log the duration of every request by route and watch for the populate and pagination parameters on slow ones. Most slow Strapi routes come from deep population or very large page sizes, so cap rest.maxLimit in config/api.js and keep population explicit.

Does Strapi have rate limiting built in?

Yes, for the Users and Permissions authentication and registration endpoints: 10 requests per minute by default, configurable in config/plugins.js. Content API routes under /api are not throttled by it, so use your edge, a Koa rate-limit middleware or an application firewall for those, and upgrade to 5.45.0 or later for the CVE-2025-64526 fix.

How do I get the real client IP in Strapi behind a proxy?

Set proxy.koa: true in config/server.js so Strapi trusts forwarded headers, and on 5.52.0 or later set maxIpsCount to the number of proxies in front of the app. Without it, logs and rate limits see only your load balancer's address.

For the framework-agnostic version of this, see our guides to Node.js API monitoring and Node.js API logging. Strapi is a Koa app underneath, so the Koa monitoring and logging guide applies too.

See what's hitting your API: apitraffic.io