axon_core

pub package style: meragix

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_shelf instead, it re-exports the public axon_core API 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.

Part of the Axon ecosystem by Meragix.

Libraries

axon_core
This library exports only the public API surface. Internal implementation details under src/ are not exported.