axon_core 0.1.0
axon_core: ^0.1.0 copied to clipboard
Core logging engine for the Axon ecosystem. Provides type-safe snapshots and compiled format parsing for high-performance HTTP logging.
axon_core #
Zero-dependency HTTP logging engine for Dart 3.
axon_core is the framework-agnostic foundation of the Axon
ecosystem. It provides the token
engine, format compiler, snapshot types, and serializers. It does not log anything by itself,
framework adapters (axon_shelf, axon_dart_frog) consume it to produce actual middleware.
Installation #
# pubspec.yaml
dependencies:
axon_core: ^0.1.0
If you are using Shelf, depend on
axon_shelfinstead, it re-exports the publicaxon_coreAPI automatically.
Architecture #
RequestSnapshot + ResponseSnapshot
↓
LogContext
↓
CompiledFormat ← compiled once at startup from a format string
├── buildRecord() → LogRecord (structured fields)
└── render() → String (text output via TextSerializer)
↓
LogSerializer → String output to your sink
The format string is compiled once at server startup: on every request, only a linear iteration over pre-compiled segments runs. No regex, no dynamic dispatch.
Snapshots #
Immutable captures of the HTTP cycle. Created by adapter, never mutated.
final request = RequestSnapshot(
timestamp: DateTime.now(),
method: 'GET',
uri: Uri.parse('http://localhost/api/users'),
headers: MapHeaderView({'authorization': 'Bearer token123'}),
remoteAddress: '203.0.113.42',
);
final response = ResponseSnapshot(
statusCode: 200,
latency: Duration(milliseconds: 12),
contentLength: 1024,
headers: MapHeaderView({'content-type': 'application/json'}),
);
RequestSnapshot and ResponseSnapshot are final class, they cannot be subclassed. Adapters construct them from framework-specific types.
Token Engine #
Built-in tokens #
| Token | Key | Output type | Notes |
|---|---|---|---|
:method |
method |
String |
GET, POST, … |
:url |
url |
String |
Full URI with query |
:path |
path |
String |
Path only |
:query |
query |
String? |
Null if absent |
:status |
status |
int |
HTTP status code |
:content-length |
contentLength |
int? |
From response headers |
:response-time |
responseTime |
int |
Integer ms (default) |
:response-time.ms |
responseTime |
double |
Sub-ms precision |
:response-time.us |
responseTime |
int |
Microseconds |
:response-time.s |
responseTime |
double |
Seconds |
:date |
date |
String |
RFC 1123 (default) |
:date.clf |
date |
String |
10/Oct/2000:13:55:36 +0000 |
:date.iso |
date |
String |
ISO 8601 UTC |
:date.web |
date |
String |
RFC 1123 |
:http-version |
httpVersion |
String |
HTTP/1.1 (default) |
:remote-user |
remoteUser |
String? |
Via UserExtractor |
:req.header.<n> |
req.<n> |
String? |
Any request header |
:res.header.<n> |
res.<n> |
String? |
Any response header |
Format compiler #
final registry = TokenRegistry();
registerBuiltInTokens(registry);
// Compiled once: fail-fast on unknown tokens
final format = FormatCompiler(registry).compile(
':method :url :status :response-time ms',
);
// Render on every request: O(segments) linear scan
final ctx = LogContext(request: req, response: res);
final line = format.render(ctx);
// => "GET /api/users 200 12 ms"
Unknown tokens throw FormatCompilationError at compile time, not silently at runtime:
FormatCompiler(registry).compile(':method :typo');
// throws FormatCompilationError:
// Unknown token ":typo" at position 8
// in format: ":method :typo"
Set strict: false for dynamic formats from config files, unknown tokens render as -.
Custom tokens #
// Simple token
registry.define('request-id', (ctx) => TokenOutput(
key: 'requestId',
value: ctx.request.headers['x-request-id'],
));
// Parametric token: attribute parsed from format string
// Usage: :response-time.ms, :response-time.us
registry.defineParametric('response-time', (attribute) {
return (ctx) {
final µs = ctx.response?.latency.inMicroseconds;
return switch (attribute) {
'ms' => TokenOutput(key: 'responseTime', value: (µs ?? 0) / 1000.0),
'us' => TokenOutput(key: 'responseTime', value: µs),
_ => TokenOutput(key: 'responseTime', value: (µs ?? 0) ~/ 1000),
};
};
});
TokenOutput.value is typed as Object?, numeric types (int, double) are preserved in JSON output. TextSerializer calls .toString() only when building the text line.
Predefined formats #
LogFormats.dev // ':method :url :status :response-time ms'
LogFormats.common // ':remote-addr - :remote-user [:date.clf] ":method :url" :status :content-length'
LogFormats.combined // CLF + referrer + user-agent
LogFormats.short // ':method :path :status :response-time ms'
LogFormats.tiny // ':method :url :status'
Serializers #
TextSerializer #
final serializer = TextSerializer(compiledFormat: format);
serializer.serialize(record);
// => "GET /api/users 200 12 ms"
JsonSerializer #
Numeric types preserved, no string coercion:
final serializer = JsonSerializer(
includeTimestamp: true, // adds 'ts' field (ISO 8601 UTC)
extraFields: {'service': 'api', 'env': 'production'},
);
serializer.serialize(record);
// => {"ts":"2026-04-03T10:00:00.000Z","method":"GET","status":200,"responseTime":12,"service":"api"}
status is int, responseTime is int or double depending on the token attribute. ELK, Datadog, and Loki receive correctly typed fields without any mapping configuration.
ColoredTextSerializer #
Colorizes :status and :response-time using ANSI codes. Color is applied at serialization time. TokenOutput values remain clean (no ANSI in JSON).
final serializer = ColoredTextSerializer(
compiledFormat: format,
enabled: TtyDetector.isColorEnabled(), // auto-detect TTY
);
Status colors match Morgan's dev format:
- 2xx → green
- 3xx → cyan
- 4xx → yellow
- 5xx → red
- < 100ms → green, < 500ms → yellow, ≥ 500ms → red
Header masking #
// Decorator pattern: lazy masking at lookup time, zero upfront copy
final masked = MaskedHeaderView(
MapHeaderView(rawHeaders),
[MaskingRules.bearerToken, MaskingRules.cookie],
);
masked['authorization']; // "Bearer [REDACTED]"
masked['cookie']; // "[REDACTED]"
Built-in rules: MaskingRules.authorization, MaskingRules.cookie, MaskingRules.bearerToken.
Custom rule:
MaskingRule redactApiKey = (key, value) =>
key == 'x-api-key' ? '[REDACTED]' : null;
Return null to leave the value unchanged. Rules are applied in order — first non-null result wins.
Skip predicate #
// Only log errors
SkipPredicates.successfulRequests // skip status < 400
// Suppress health check noise
SkipPredicates.paths({'/health', '/ready', '/ping'})
// Skip fast responses
SkipPredicates.fasterThan(Duration(milliseconds: 100))
// Custom
SkipPredicate myPredicate = (ctx) =>
ctx.request.method == 'OPTIONS';
// Compose
SkipPredicate combined = (ctx) =>
SkipPredicates.successfulRequests(ctx) ||
SkipPredicates.paths({'/health'})(ctx);
Skip predicates receive the full LogContext: both request and response are available.
LogRecord & custom pipelines #
For advanced use cases — building your own pipeline outside of the adapters:
final registry = TokenRegistry();
registerBuiltInTokens(registry);
final format = FormatCompiler(registry)
.compile(':method :url :status :response-time ms');
final ctx = LogContext(request: requestSnapshot, response: responseSnapshot);
// Option A: text output
final line = format.render(ctx);
// Option B: structured record (selective field resolution)
final record = format.buildRecord(ctx);
// record.fields == {method: GET, url: /api/users, status: 200, responseTime: 12}
// Only tokens present in the format string are resolved, pay-as-you-go.
// Option C: enrich before serializing
final builder = LogRecordBuilder(format);
final enriched = builder.buildWithExtra(ctx, {
'traceId': 'abc-123',
'region': 'eu-west-1',
});
License #
MIT — see LICENSE.