loq_shelf 0.1.0
loq_shelf: ^0.1.0 copied to clipboard
Structured request logging middleware for Shelf, powered by loq. Drop-in replacement for logRequests() with structured fields, zone context, and configurable log levels.
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_msuses 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.