Skip to content

Repository files navigation

serverpod_logger_plus

pub package Test License: MIT

Plug-and-play structured logging for Serverpod.

Serverpod can already print JSON to stdout (sessionLogs.consoleLogFormat: json), but it's one generic schema - it doesn't speak the reserved fields GCP, Datadog, or Elastic actually look for, so you lose severity facets, error grouping, and label-based filtering on arrival.

Every log call is dual-routed ("Y-Splitter"):

  • A flattened, human-readable string is sent to Session.log, so it's still persisted to the Serverpod database and shows up in Serverpod Insights, exactly like session.log(...) today.
  • The fully structured data (message, labels, payload, exception, stack trace) is sent to a pluggable LogWriter, which prints it as JSON on stdout for your log aggregator - or as colorized output for local development.

You still get Insights, plus structured logs your cloud provider can actually query, filter, and alert on.

Install

dependencies:
  serverpod_logger_plus: ^0.1.0

Quickstart & Configuration

Configure the logger once at startup before any request accesses session.logger. You can configure your production writer, suppress noise, enable automatic request logging, and bind distributed tracing context all in one place:

import 'package:serverpod/serverpod.dart';
import 'package:serverpod_logger_plus/serverpod_logger_plus.dart';

void run(List<String> args) async {
  ServerpodLoggerPlus.configure(
    // Required: The writer used in non-development modes (staging, production, etc.)
    productionWriter: const GcpJsonLogWriter(projectId: 'my-gcp-project'),

    // Optional: Drop log calls below this threshold from the stdout writer
    minimumLevel: LogLevel.info, 

    // Optional: Emit a structured "Request completed" record on session close
    logRequests: true, 

    // Optional: Read incoming request headers (W3C, GCP, AWS, Datadog) to bind traceId/spanId
    bindTraceContext: true, 

    // Optional: Replace built-in trace parsing to support proprietary headers
    traceContextExtractor: (session) {
      final id = session.request?.headers['x-corp-trace-id']?.first;
      return id == null ? const {} : {'traceId': id};
    },
  );

  final pod = Serverpod(args, Protocol(), Endpoints());
  await pod.start();
}

Then, anywhere you have a Session:

class GreetingEndpoint extends Endpoint {
  Future<String> hello(Session session, String name) async {
    await session.logger.info('Saying hello', payload: {'name': name});
    return 'Hello, $name!';
  }
}

No manual wiring per-endpoint: session.logger is a zero-boilerplate extension getter, lazily created and memoized per Session. It automatically defaults to a local ANSI ConsoleLogWriter in development, and uses your configured productionWriter in all other run modes.

Configuration Options

Option Description
productionWriter Your primary log sink. Emits fully structured JSON on stdout to your cloud provider.
minimumLevel Suppresses low-severity noise (and ingestion cost) from your log aggregator. Serverpod's own database session log is unaffected.
logRequests Triggers a structured log containing the endpoint, method, and duration when the session closes. (Note: session.logger must be accessed during the request to fire. This does not capture HTTP errors; continue using session.logger.error in catch blocks).
bindTraceContext Enables automatic log-to-trace linking. Each built-in writer maps the extracted IDs to its provider's reserved trace fields.
traceContextExtractor An override callback for bespoke trace headers. You can return extractTraceContext(session) from inside it as a fallback.
redactKeys Label/payload keys whose values must never be logged. Case-insensitive, applied at any nesting depth, to both sinks.
redactor A callback for rules a key list can't express (e.g. masking anything shaped like a card number). Runs after redactKeys, on what survived.
redactionPlaceholder What a redacted value is replaced with. Defaults to [redacted].
flattenValueMaxLength Caps each label/payload value in the string written to Serverpod's session log. Defaults to 1024. The LogWriter still receives untruncated data.

Provider Trace Notes: GcpJsonLogWriter requires your projectId to build the reserved trace field; otherwise, it emits the ID as a standard label. Datadog expects 64-bit decimal IDs and will link successfully if the incoming trace header uses Datadog's format.

Avoiding double logging in production

Separately from this package, Serverpod's own Session.log can also write a JSON or text line straight to stdout, controlled by sessionLogs.consoleEnabled in your server config (config/<env>.yaml). Its default value is !databaseEnabled || runMode == development - i.e. off by default in staging/production as long as a database is configured, but on by default for database-less setups.

If it's on in the same run mode where you've configured a productionWriter, every log call is printed to stdout twice - once by Serverpod's own writer, once by yours. session.logger detects this and prints a one-time warning to stderr when it happens. To avoid the duplication, set sessionLogs: { consoleEnabled: false } in that environment's config (or the SERVERPOD_SESSION_CONSOLE_LOG_ENABLED env var), unless you actually want both.

Where your log data goes

Every log call is written to two places, filtered differently. payload reads like a stdout-only channel - it isn't.

Serverpod session log (session.log) Your LogWriter
What is sent one flattened string: message + labels + payload structured message, labels, payload, exception, stack trace, trace ids
Where it ends up your database's session-log table, and Serverpod Insights wherever the writer sends it (stdout JSON, console, your own sink)
Filtered by minimumLevel No Yes
Filtered by Serverpod's own log settings Yes No
Redaction applied Yes Yes

payload and labels are persisted to your database on every log call, regardless of which writer you configure. minimumLevel gates the writer only - setting minimumLevel: LogLevel.error does not stop a debug call's payload from reaching the session-log table. Use Serverpod's own sessionLogs settings for that. If a value must never be persisted, keep it out of payload/labels, or add its key to redactKeys.

Redacting sensitive data

redactKeys enforces redaction once, at configuration time, instead of relying on every call site to remember:

ServerpodLoggerPlus.configure(
  productionWriter: const GcpJsonLogWriter(),
  redactKeys: {'password', 'authorization', 'email'},
);

session.logger.info('login', payload: {'user': 'ada', 'password': 'hunter2'});
// writer:      {"message":"login","payload":{"user":"ada","password":"[redacted]"}}
// session log: login | payload: user=ada, password=[redacted]

Matching is case-insensitive and applies at any depth. A matching key holding a map has its whole subtree replaced, so redactKeys: {'auth'} collapses auth: {token: ..., refresh: ...} to one placeholder.

For rules a key list can't express, pass a redactor. It runs after redactKeys, and returning the value it was given means "leave this alone":

ServerpodLoggerPlus.configure(
  productionWriter: const GcpJsonLogWriter(),
  redactKeys: {'password'},
  redactor: (key, value) =>
      value is String && _looksLikeACardNumber(value) ? '[card]' : value,
);

A redactor that throws fails closed: the value is replaced with the placeholder and the error goes to stderr.

Redaction applies to payload and labels keys only. It does not scan the message string, exception.toString(), or the stack trace. A secret interpolated into a message - or carried by an exception's own toString() - still reaches both sinks. Scanning free text is false-positive-prone, so it's deliberately left to you.

API

Logging methods

LoggerPlus (the object returned by session.logger) exposes:

session.logger.debug('message', payload: {...}, labels: {...});
session.logger.info('message', payload: {...}, labels: {...});
session.logger.warning('message', payload: {...}, labels: {...});
session.logger.error('message', exception: e, stackTrace: st, payload: {...});
session.logger.fatal('message', exception: e, stackTrace: st, payload: {...});
  • payload - arbitrary structured data relevant to this one log call (e.g. {'userId': id}).
  • labels - key/value tags meant to be consistent across many log calls (e.g. {'requestId': id}), suitable for indexing/filtering in your log backend.

Binding context

Use session.bindLogger(...) to attach labels/payload that should be included on every subsequent session.logger call, so you don't have to repeat them - or thread a logger object through your call stack. It enriches session.logger in place for the rest of the request:

session.bindLogger(labels: {'requestId': requestId});

await session.logger.info('Starting request'); // tagged with requestId
await session.logger.info('Finished request'); // still tagged, anywhere

Every later session.logger on that Session carries the bound context. bindLogger also returns the enriched logger if you want a direct reference, but you don't need to keep it - the point is the side effect on session.logger.

When the enrichment should end with a block of work rather than run to the end of the request, use session.runWithLogger(...):

await session.runWithLogger(
  () async {
    session.logger.info('charging');   // carries step=charge
    await chargeCard();
  },
  labels: {'step': 'charge'},
);

session.logger.info('done');           // no step label

The scope is carried by a Zone, so it survives await, nests, and two concurrent branches under Future.wait each see their own. Calling bindLogger inside a scope unwinds with it. Work started inside the scope but never awaited keeps the scoped logger, since the scope follows the asynchronous context rather than the call.

Note: the class is named LoggerPlus, not Logger - package:serverpod already exports its own Logger (from relic_core, used internally for HTTP request logging), so naming ours Logger would collide with it in every file that imports both packages.

Writers

Pick one LogWriter as your productionWriter. Each one emits a single line of JSON per log call, shaped for its target platform's structured logging / reserved-attribute conventions:

Writer Target Notes
GcpJsonLogWriter Google Cloud Logging Emits severity (DEBUG/INFO/WARNING/ERROR/CRITICAL) and logging.googleapis.com/labels, auto-parsed from stdout by the Cloud Logging agent. Pass projectId to also emit logging.googleapis.com/trace for log-to-trace linking.
GenericJsonLogWriter AWS CloudWatch, Azure Monitor / Container Insights, and any agent that indexes arbitrary stdout JSON (Fluent Bit, Vector, Logstash, ...) Emits a flat, provider-neutral object: message, level, timestamp, plus optional labels/payload.
DatadogJsonLogWriter Datadog Log Management Emits status (Datadog's reserved severity attribute - not level), @timestamp, error.message/error.kind/error.stack on exceptions.
AxiomLogWriter Axiom / generic JSON collectors (e.g. Better Stack) Emits _time, level, and a merged data object combining payload and labels.
ElasticEcsLogWriter Elastic Stack / Elastic Cloud (ECS) Emits Elastic Common Schema fields: @timestamp, log.level, message, and error.message/error.type/error.stack_trace. Picked up by Filebeat / Elastic Agent.
NewRelicJsonLogWriter New Relic Logs Emits timestamp, message, level, and error.message/error.class/error.stack, collected from stdout by New Relic's log forwarders.
SplunkJsonLogWriter Splunk Emits flat JSON (time, severity, message) that a Splunk forwarder indexes with a JSON source type - not the HEC {"event": {...}} envelope, which is only for POSTing to HEC directly.
OtelJsonLogWriter OpenTelemetry Collector (OTLP/JSON) Emits an OTel LogRecord (timeUnixNano, severityNumber/severityText, body, attributes). Intended to be collected by an OpenTelemetry Collector pipeline (see note below).
ConsoleLogWriter Local development ANSI-colored, human-readable console output. Automatically used whenever runMode == development.

All writers only ever call print(...) (never stdout.writeln), so they play nicely with Zone-based print interception in tests.

Note on OtelJsonLogWriter: a bare OTLP/JSON LogRecord on stdout is not a turn-key ingestion path on its own - it's meant to be collected by an OpenTelemetry Collector whose pipeline maps these fields (e.g. a filelog receiver with a JSON parser). If you just need a schema a specific vendor ingests directly, prefer that vendor's writer.

Implementing your own writer

A LogWriter is the single unit that decides what a log call turns into. Whichever writer you pass to configure as the productionWriter is the entire production output - there is no default JSON writer running underneath it that you're adding to or filtering. Implement your own when you need something the built-in writers don't:

  • a different schema on stdout (a collector that isn't listed above), or
  • to ship logs over the network (an HTTP call to a provider with no stdout-based ingestion).

write is dispatched without being awaited by the caller, so slower work like a network call won't add latency to the request - just make sure it never throws:

class MyLogWriter implements LogWriter {
  const MyLogWriter();

  @override
  Future<void> write(
    String message, {
    required LogLevel severity,
    required DateTime timestamp,
    Map<String, dynamic>? payload,
    Map<String, String>? labels,
    Object? exception,
    StackTrace? stackTrace,
    String? traceId,
    String? spanId,
  }) async {
    // ship `message`/`payload`/`labels`/`exception`/`traceId` wherever you like.
  }
}

Then pass an instance to configure as productionWriter, exactly like the built-in writers in the Quickstart above:

ServerpodLoggerPlus.configure(
  productionWriter: const MyLogWriter(),
);

That's the only wiring required - session.logger picks it up automatically for every non-development run mode.

Combining writers (keep the default JSON and add your own)

You don't have to choose between a built-in writer and your own logic. To keep a built-in structured-JSON writer and run extra work on top - say an async network push, a metrics counter, or a side-channel alert - wrap them in a MultiLogWriter. It fans every log call out to each writer you give it:

ServerpodLoggerPlus.configure(
  productionWriter: const MultiLogWriter([
    GcpJsonLogWriter(),   // still prints the default JSON to stdout
    PagerDutyLogWriter(), // + your own writer, e.g. an async HTTP call
  ]),
);

Your extra writer only needs to do its part (the network call) - it doesn't have to re-emit the JSON, because GcpJsonLogWriter is still in the list doing that. Writers are dispatched together rather than one after another, so a slow one doesn't hold up the rest, and a failure in one is isolated from the others. (Each writer must still not throw of its own accord - see above.)

Flushing async writers on shutdown

The built-in writers all call print(...) synchronously, so nothing is ever in flight - there's nothing to flush. But if you write an asynchronous writer (a network push, a buffered HTTP client), write is dispatched fire-and-forget, so logs still in flight could be lost if the process exits abruptly during shutdown.

To give such a writer a drain point, implement FlushableLogWriter instead of LogWriter, track your own in-flight futures, and await them in flush():

class MyNetworkLogWriter implements FlushableLogWriter {
  final _inFlight = <Future<void>>{};

  @override
  Future<void> write(String message, {/* ... */}) async {
    final future = _push(message /* ... */);
    _inFlight.add(future);
    await future.whenComplete(() => _inFlight.remove(future));
  }

  @override
  Future<void> flush() => Future.wait(_inFlight);
}

Then drain it from your server's shutdown path, before pod.shutdown():

await ServerpodLoggerPlus.flush();

ServerpodLoggerPlus.flush() is always safe to call: it's a no-op when no writer is configured or when the configured writer isn't a FlushableLogWriter, so it never fails a teardown. A MultiLogWriter is flushable too - it fans flush() out to whichever of its children implement FlushableLogWriter and skips the rest.

Testing

serverpod_logger_plus itself is covered by writer-schema unit tests (see test/) run with plain package:test, using a Zone-based print interceptor (test/util/capture_print.dart) to assert on each writer's JSON output without touching real stdout.

To verify the Y-Splitter behavior end-to-end inside your own Serverpod server, write an integration test with serverpod_test's withServerpod, and assert both routes are exercised - session.log doesn't throw, and your writer received the structured data:

import 'package:serverpod_logger_plus/serverpod_logger_plus.dart';
import 'package:serverpod_test/serverpod_test.dart';

import '../lib/src/generated/protocol.dart';
import '../lib/src/generated/endpoints.dart';

class RecordingLogWriter implements LogWriter {
  final calls = <String>[];

  @override
  Future<void> write(
    String message, {
    required LogLevel severity,
    required DateTime timestamp,
    Map<String, dynamic>? payload,
    Map<String, String>? labels,
    Object? exception,
    StackTrace? stackTrace,
    String? traceId,
    String? spanId,
  }) async {
    calls.add(message);
  }
}

void main() {
  withServerpod('Given a configured LoggerPlus', (sessionBuilder, endpoints) {
    test('when info is logged, then session.log does not throw '
        'and the writer receives the structured data', () async {
      final writer = RecordingLogWriter();
      final session = sessionBuilder.build();
      final logger = LoggerPlus(session, writer: writer);

      await logger.info('Hello from a test');

      expect(writer.calls, contains('Hello from a test'));
    });
  });
}

This test needs to live inside a real generated Serverpod project (it imports that project's generated protocol.dart/endpoints.dart), so it isn't bundled in this package - copy the pattern above into your server's test/integration/ directory.

License

MIT

Releases

Packages

Contributors

Languages