axon_shelf 0.1.0
axon_shelf: ^0.1.0 copied to clipboard
Shelf middleware adapter for Axon: HTTP request logging for Dart servers. Wraps axon_core's token engine and serializers into a standard Shelf Middleware.
axon_shelf #
Shelf middleware adapter for Axon: HTTP request logging for Dart servers.
Installation #
dependencies:
axon_shelf: ^0.1.0
Quick start #
import 'package:axon_shelf/axon_shelf.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
void main() async {
final handler = Pipeline()
.addMiddleware(axonShelf())
.addHandler((req) => Response.ok('hello'));
final server = await io.serve(handler, 'localhost', 8080);
print('Listening on port ${server.port}');
}
Output (colored in a terminal, plain in pipes/log files):
GET / 200 3 ms
POST /api/users 201 12 ms
GET /api/users/99 404 1 ms
Configuration #
All options are set via AxonShelfConfig:
axonShelf(config: AxonShelfConfig(
format: LogFormats.dev, // format string: compiled once at startup
serializer: null, // null = auto (ColoredText or Text based on TTY)
sink: print, // log destination
bodyCapture: BodyCaptureConfig.disabled,
strict: true, // throw FormatCompilationError on unknown tokens
skip: null, // SkipPredicate?: suppress log lines
immediate: false, // log before handler runs
colorMode: ColorMode.auto, // auto | always | never
userExtractor: null, // UserExtractor?: for :remote-user token
))
Format strings #
// Predefined
axonShelf(config: AxonShelfConfig(format: LogFormats.dev))
axonShelf(config: AxonShelfConfig(format: LogFormats.combined))
axonShelf(config: AxonShelfConfig(format: LogFormats.common))
axonShelf(config: AxonShelfConfig(format: LogFormats.short))
axonShelf(config: AxonShelfConfig(format: LogFormats.tiny))
// Custom
axonShelf(config: AxonShelfConfig(
format: ':remote-addr :method :url :status :response-time.ms ms',
))
// => 203.0.113.42 GET /api/users 200 3.21 ms
See axon_core for the full token reference.
Serializers #
Text (default) #
Auto-selected based on TTY detection. Colored in terminals, plain in pipes.
// Force colors (CI with ANSI support)
axonShelf(config: AxonShelfConfig(colorMode: ColorMode.always))
// Force plain (log files, systemd journal)
axonShelf(config: AxonShelfConfig(colorMode: ColorMode.never))
JSON - ELK / Datadog / Loki #
Numeric types preserved: status is int, responseTime is int/double:
axonShelf(config: AxonShelfConfig(
serializer: JsonSerializer(
extraFields: {'service': 'my-api', 'env': 'production'},
),
sink: myStructuredLogger.info,
))
// => {"ts":"2026-04-03T10:00:00.000Z","method":"GET","status":200,"responseTime":3,"service":"my-api"}
Filtering with skip #
// Production: log only errors
axonShelf(config: AxonShelfConfig(
skip: SkipPredicates.successfulRequests,
))
// Suppress health check / readiness probe noise
axonShelf(config: AxonShelfConfig(
skip: SkipPredicates.paths({'/health', '/ready', '/ping'}),
))
// Skip fast successful responses
axonShelf(config: AxonShelfConfig(
skip: SkipPredicates.fasterThan(Duration(milliseconds: 100)),
))
// Custom predicate: full LogContext access
axonShelf(config: AxonShelfConfig(
skip: (ctx) =>
ctx.request.method == 'OPTIONS' ||
ctx.request.path.startsWith('/assets/'),
))
// Combine: log errors OR slow requests
axonShelf(config: AxonShelfConfig(
skip: (ctx) {
final status = ctx.response?.statusCode ?? 0;
final ms = ctx.response?.latency.inMilliseconds ?? 0;
return status < 400 && ms < 500;
},
))
Immediate mode #
Log at request arrival: before the handler runs. Useful when the server may crash before sending a response.
axonShelf(config: AxonShelfConfig(immediate: true))
Response fields (:status, :response-time, :content-length) render as - in immediate mode. The skip predicate receives a null ResponseSnapshot, predicates that inspect the response fall back to their null-safe defaults.
Color output #
colorMode |
Behavior |
|---|---|
ColorMode.auto |
Colored if stdout.hasTerminal, plain otherwise (default) |
ColorMode.always |
Always emit ANSI codes |
ColorMode.never |
Plain text only |
Status colors: 2xx green · 3xx cyan · 4xx yellow · 5xx red
Latency colors: < 100ms green · < 500ms yellow · ≥ 500ms red
Header masking #
Apply masking at the snapshot level before the token engine sees the data:
// In ShelfSnapshotFactory, wrap headers with MaskedHeaderView
final masked = MaskedHeaderView(
ShelfHeaderView.fromRequest(request),
[MaskingRules.bearerToken, MaskingRules.cookie],
);
Or use a custom masking rule:
MaskingRule redactApiKey = (key, value) =>
key == 'x-api-key' ? '[REDACTED]' : null;
Body capture #
Opt-in, non-blocking stream view, the handler always receives the full unmodified stream:
axonShelf(config: AxonShelfConfig(
bodyCapture: BodyCaptureConfig(maxRequestBodyBytes: 2048),
))
If content-length > maxRequestBodyBytes, the captured body is truncated and the remainder passes through untouched. Body capture is disabled by default: enabling it on large uploads (multipart, binary) will truncate at the limit.
Authenticated user — :remote-user #
:remote-user is auth-system agnostic. You provide a UserExtractor that receives the full LogContext:
// From request header (API key, reverse proxy injection)
axonShelf(config: AxonShelfConfig(
userExtractor: (ctx) => ctx.request.headers['x-user-id'],
))
// From Shelf context (JWT middleware, shelf_auth, custom auth)
// Import ShelfRequestSnapshotX to access the Shelf request context
import 'package:axon_shelf/axon_shelf.dart';
axonShelf(config: AxonShelfConfig(
userExtractor: (ctx) =>
ctx.request.shelfContext?['user'] as String?,
))
// From response header (auto-login or auth gateway pattern)
axonShelf(config: AxonShelfConfig(
userExtractor: (ctx) =>
ctx.response?.headers['x-authenticated-as'],
))
Auth middleware example, inject user into Shelf context:
Handler jwtMiddleware(Handler inner) => (request) async {
final token = request.headers['authorization']?.replaceFirst('Bearer ', '');
final user = token != null ? await jwtService.verify(token) : null;
return inner(
request.change(context: {'user': user?.id}),
);
};
// Wire both middlewares
Pipeline()
.addMiddleware(jwtMiddleware)
.addMiddleware(axonShelf(config: AxonShelfConfig(
format: LogFormats.combined, // includes :remote-user
userExtractor: (ctx) =>
ctx.request.shelfContext?['user'] as String?,
)))
.addHandler(myHandler);
// Output:
// 203.0.113.42 - john_doe [03/Apr/2026:10:00:00 +0000] "GET /api/orders" 200 1024
Custom tokens #
final registry = TokenRegistry();
registerBuiltInTokens(registry);
registerShelfTokens(registry); // adds :remote-addr, :referrer, :user-agent
// Register a custom token
registry.define('request-id', (ctx) => TokenOutput(
key: 'requestId',
value: ctx.request.headers['x-request-id'],
));
// Compile with the extended registry
final format = FormatCompiler(registry)
.compile(':method :url :status [:request-id] :response-time ms');
// Pass a pre-built serializer to axonShelf
axonShelf(config: AxonShelfConfig(
serializer: TextSerializer(compiledFormat: format),
))
Shelf-specific tokens #
These tokens are registered automatically by axonShelf() and are not available in axon_core:
| Token | Description | Notes |
|---|---|---|
:remote-addr |
Client IP | Prefers X-Forwarded-For |
:referrer |
Referer / Referrer header |
Both spellings supported |
:user-agent |
User-Agent header |
|
:remote-user |
Authenticated user identity | Via UserExtractor |
Error handling #
axonShelf logs even when the handler throws. A synthetic 500 is recorded, the original error is re-thrown with its stack trace preserved:
// Handler throws → log written with status 500 → error propagates
Multiple middleware instances #
Shelf supports stacking multiple axonShelf instances, useful for routing logs to different destinations:
Pipeline()
// Log all requests to structured JSON sink
.addMiddleware(axonShelf(config: AxonShelfConfig(
serializer: JsonSerializer(),
sink: datadogLogger.info,
)))
// Also log errors to console with colors
.addMiddleware(axonShelf(config: AxonShelfConfig(
skip: SkipPredicates.successfulRequests,
colorMode: ColorMode.always,
)))
.addHandler(myHandler);
License #
MIT — see LICENSE.