loq_shelf

Structured request logging middleware for Shelf, powered by loq.

Drop-in replacement for logRequests() with OpenTelemetry-aligned fields, zone-propagated request context, secure-by-default redaction, and configurable log levels.

Quick start

import 'package:shelf/shelf.dart';
import 'package:loq_shelf/loq_shelf.dart';

final handler = Pipeline()
    .addMiddleware(loqMiddleware())
    .addHandler(router);

Every request log carries the standard OTel HTTP-server fields, and any log emitted inside the handler inherits them via zone context:

Response handleUser(Request request) {
  // Inherits http.request.method, url.path, requestId, etc.
  Logger('db').info('query executed', fields: {'table': 'users'});
  return Response.ok('ok');
}

Sample output (ConsoleHandler):

12:34:56.789 [INFO ] http: request started | http.request.method=GET, url.path=/api/users, url.scheme=http, server.address=localhost, network.protocol.version=1.1, requestId=1
12:34:56.790 [INFO ] db: query executed | http.request.method=GET, url.path=/api/users, requestId=1, table=users
12:34:56.791 [INFO ] http: request completed | http.request.method=GET, url.path=/api/users, requestId=1, http.response.status_code=200, duration_ms=2

Default fields

Each log event (start / completion / error) is built from a defaults map that includes everything the middleware would contribute. The corresponding *Fields callback (if supplied) receives this map and returns the final field set.

Request log defaults

Bound via withLogContext so downstream logs inherit them:

Field Source
http.request.method request.method
url.path request.requestedUri.path
url.scheme request.requestedUri.scheme
server.address request.requestedUri.host
server.port when explicit
network.protocol.version request.protocolVersion
client.address clientIpResolver, defaults to shelf_io connection info
user_agent.original User-Agent header (when present)
http.request.body.size Content-Length (when set)
http.route routeResolver (when supplied)
requestId requestIdResolver, defaults to X-Request-Id header or counter
http.request.header.<lower> for each name in captureRequestHeaders (sensitive values masked)
url.query raw query string when captureQueryParams: true (sensitive values masked)

Completion log defaults

Field Source
http.response.status_code response.statusCode
duration_ms elapsed time in milliseconds
http.response.body.size response Content-Length (when set)
http.response.header.content-type response Content-Type (when set)
http.response.header.<lower> for each name in captureResponseHeaders
slow true when slowRequestThreshold is exceeded

Error log defaults

Field Source
duration_ms elapsed time in milliseconds
error.type error.runtimeType.toString()
error.message error.toString()
slow true when slowRequestThreshold is exceeded

Loq's Logger always adds error (the caught Object) and stackTrace to the error log on a layer below errorFields: — replacing all fields with errorFields: (_, __, ___, ____) => {} will still include them.

duration_ms uses snake_case (industry convention across Datadog, Elastic, Logstash, etc.) rather than loq's usual camelCase.

Dart Frog

Works via fromShelfMiddleware():

// _middleware.dart
import 'package:dart_frog/dart_frog.dart';
import 'package:loq_shelf/loq_shelf.dart';

Handler middleware(Handler handler) {
  return handler.use(fromShelfMiddleware(loqMiddleware()));
}

Configuration

Route templates

Without this, dashboards explode on per-path cardinality (every /users/42 becomes its own bucket). Supply the matched template from your router:

loqMiddleware(
  routeResolver: (req) => req.context['shelf_router/route'] as String?,
)

Client IP from X-Forwarded-For

By default client.address comes from the immediate socket. Behind a proxy or CDN, override with your own resolver — and validate the trusted proxy chain before trusting the header:

loqMiddleware(
  clientIpResolver: (req) {
    final xff = req.headers['x-forwarded-for'];
    if (xff != null && _socketIsTrusted(req)) {
      return xff.split(',').first.trim();
    }
    return null;
  },
)

Level resolution

levelResolver is the only level hook. It receives the response (or null on error), elapsed duration, and any caught error. Return null to fall back to the default mapping (5xx → error, 4xx → warn, else info; error for the error path). slowRequestThreshold's warn-bump still stacks on top:

loqMiddleware(
  levelResolver: (response, elapsed, error) {
    if (error is TimeoutException) return Level.warn;
    if (response != null && response.statusCode >= 400) return Level.error;
    return null;  // fall back to defaults
  },
)

Skip noisy endpoints

loqMiddleware(
  skip: (req) {
    final p = req.requestedUri.path;
    return p == '/healthz' || p == '/metrics';
  },
)

Slow request threshold

Adds slow: true and ensures the completion level is at least warn (preserving error/fatal):

loqMiddleware(
  slowRequestThreshold: const Duration(milliseconds: 500),
)

Capture additional headers

Allowlist of headers to bind. Request headers propagate via zone context; response headers attach to the completion log only.

loqMiddleware(
  captureRequestHeaders: ['user-agent', 'cf-ray', 'authorization'],
  captureResponseHeaders: ['cache-status', 'x-served-by'],
)

Lookup is case-insensitive. Output field names follow the OTel convention http.request.header.<lowercase> and http.response.header.<lowercase> regardless of how you cased the name in the allowlist. Missing headers are dropped.

Capture query parameters

Opt-in. Adds url.query as the raw query string with sensitive values masked. Preserves order, repeated keys, and URL encoding.

loqMiddleware(captureQueryParams: true)
// /search?q=cats&page=2 → url.query: "q=cats&page=2"
// /search?q=cats&api_key=secret → url.query: "q=cats&api_key=***"

If you need structured access to specific keys downstream, pull them out via fields: instead:

loqMiddleware(
  fields: (req, defaults) => {
    ...defaults,
    'tenant_id': req.requestedUri.queryParameters['tenant_id'],
  },
)

Redaction

Secure by default. Captured request headers authorization, proxy-authorization, cookie, x-api-key, x-auth-token and response set-cookie are masked to ***. When captureQueryParams: true, common token-bearing keys (token, access_token, refresh_token, api_key, apikey, key, password, secret, signature, sig) are also masked.

The header key is preserved so presence is observable in logs. Override per category — empty set disables:

loqMiddleware(
  captureRequestHeaders: ['authorization'],
  redactRequestHeaders: const {},          // disable redaction entirely
  redactResponseHeaders: const {'set-cookie', 'x-internal-token'},
  redactQueryParams: const {'access_token'},
)

Sharing the threat model with loq core

If you also want loq core's redact() processor to mask additional fields contributed by your own code, plug in the prefixed-name constants so you don't have to repeat the HTTP defaults:

import 'package:loq/loq.dart';
import 'package:loq_shelf/loq_shelf.dart';

LogConfig.configure(
  processors: [
    redact({
      ...defaultRedactedRequestHeaderFields,   // http.request.header.authorization, etc.
      ...defaultRedactedResponseHeaderFields,  // http.response.header.set-cookie
      'tenant_secret',                          // your own sensitive field
    }),
  ],
);

loq_shelf already masks captured HTTP headers inline; these constants exist so other loggers and processors can share the same threat model without rebuilding the prefix mapping. There's no equivalent constant for query params because url.query is a single string field — substring redaction is handled inline by the middleware.

Custom message strings

Useful when downstream log pipelines key off specific event names:

loqMiddleware(
  startMessage: 'http.request.start',
  completeMessage: 'http.request.end',
  errorMessage: 'http.request.error',
)

Custom logger

loqMiddleware(
  logger: Logger('api', config: LogConfig(
    handlers: [JsonHandler()],
    processors: [addTimestamp()],
  )),
)

Customizing fields

fields:, responseFields:, and errorFields: are the single transformation point for their respective logs. Each receives a defaults map containing everything the middleware would otherwise contribute — OTel core fields, captured headers, url.query, slow — and returns the final fields. Compose, filter, or fully replace; the callback's return value is final.

// Add fields on top of defaults
loqMiddleware(
  fields: (req, defaults) => {
    ...defaults,
    'tenant_id': resolveTenant(req),
  },
)

// Drop a default field
loqMiddleware(
  fields: (req, defaults) =>
      Map.of(defaults)..remove('user_agent.original'),
)

// Replace entirely (you opt out of OTel defaults)
loqMiddleware(
  fields: (req, _) => {
    'method': req.method,
    'path': req.requestedUri.path,
  },
)

// Same composition pattern for responseFields
loqMiddleware(
  responseFields: (response, elapsed, defaults) => {
    ...defaults,
    'contentLength': response.headers['content-length'],
  },
)

// Annotate error logs based on the exception type
loqMiddleware(
  errorFields: (error, stack, elapsed, defaults) => {
    ...defaults,
    'error.retryable': error is TimeoutException || error is SocketException,
    if (error is HttpException) 'error.code': error.uri?.path,
  },
)

API reference

Middleware loqMiddleware({
  // Setup
  Logger? logger,
  String Function(Request request)? requestIdResolver,
  // Behavior
  bool Function(Request request)? skip,
  bool logStart = true,
  Duration? slowRequestThreshold,
  // Field hooks
  Map<String, Object?> Function(Request request, Map<String, Object?> defaults)? fields,
  Map<String, Object?> Function(Response response, Duration elapsed, Map<String, Object?> defaults)? responseFields,
  Map<String, Object?> Function(Object error, StackTrace stack, Duration elapsed, Map<String, Object?> defaults)? errorFields,
  // Resolvers
  String? Function(Request request)? routeResolver,
  String? Function(Request request)? clientIpResolver,
  Level? Function(Response? response, Duration elapsed, Object? error)? levelResolver,
  // Capture
  List<String>? captureRequestHeaders,
  List<String>? captureResponseHeaders,
  bool captureQueryParams = false,
  // Redaction
  Set<String>? redactRequestHeaders,
  Set<String>? redactResponseHeaders,
  Set<String>? redactQueryParams,
  // Messages
  String startMessage = 'request started',
  String completeMessage = 'request completed',
  String errorMessage = 'request failed',
})
Parameter Default Description
Setup
logger Logger('http') Logger instance for middleware logs
requestIdResolver X-Request-Id header or counter Returns the request ID; always invoked when building defaults
Behavior
skip null Bypass middleware entirely when true
logStart true Emit "request started" log
slowRequestThreshold null Adds slow: true to defaults and bumps completion level to ≥ warn
Field hooks
fields identity Transforms request-log defaults
responseFields identity Transforms completion-log defaults
errorFields identity Transforms error-log defaults
Resolvers
routeResolver null Returns http.route template (e.g. /users/{id})
clientIpResolver shelf_io connection info Returns client.address
levelResolver null Overrides level for completion and error (status-family / Level.error defaults)
Capture
captureRequestHeaders null Request header allowlist; output http.request.header.<lowercase>
captureResponseHeaders null Response header allowlist; output http.response.header.<lowercase>
captureQueryParams false Add url.query (raw query string with sensitive values masked)
Redaction (null = use defaults, {} = disable)
redactRequestHeaders defaultRedactedRequestHeaders Header names whose values are masked
redactResponseHeaders defaultRedactedResponseHeaders Header names whose values are masked
redactQueryParams defaultRedactedQueryParams Query keys whose values are masked
Messages
startMessage 'request started' Start-log message
completeMessage 'request completed' Completion-log message
errorMessage 'request failed' Error-log message

License

MIT. See LICENSE.

Libraries

loq_shelf
Structured request logging middleware for Shelf, powered by loq.