serverpod_logger_plus 0.4.0 copy "serverpod_logger_plus: ^0.4.0" to clipboard
serverpod_logger_plus: ^0.4.0 copied to clipboard

Plug-and-play structured logging for Serverpod: dual-routes each log to Serverpod Insights and structured JSON on stdout for GCP, Datadog, Elastic, and more.

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 #

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

void run(List<String> args) async {
  // Pick the writer for your production log sink. Required once, at
  // startup, before any request accesses `session.logger`.
  ServerpodLoggerPlus.configure(
    productionWriter: const GcpJsonLogWriter(),
  );

  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 picks the right writer:

  • runMode == development → a local ANSI ConsoleLogWriter, regardless of what you passed to configure.
  • Any other run mode (staging, production, test, ...) → the productionWriter registered via ServerpodLoggerPlus.configure.

Suppressing low-severity noise #

By default every level is written. Pass minimumLevel to drop calls below a threshold from the writer's output - useful for keeping debug chatter (and its ingestion cost) out of production log sinks:

ServerpodLoggerPlus.configure(
  productionWriter: const GcpJsonLogWriter(),
  minimumLevel: LogLevel.info, // debug is dropped by the writer
);

This gates only this package's writer. Serverpod's own session log is unaffected and continues to be filtered by Serverpod's log settings, so dropped-from-stdout entries can still reach the database/Insights.

Automatic request logging #

Serverpod already writes one session-log row per completed call to its database (duration, query timings, uncaught exceptions), surfaced in Insights - but that record never goes through your LogWriter, so it doesn't reach your structured stdout sink. Set logRequests: true to bridge the gap: every session that touches session.logger emits one structured Request completed record (endpoint, method, duration) to your writer when it closes.

ServerpodLoggerPlus.configure(
  productionWriter: const GcpJsonLogWriter(),
  logRequests: true,
);

You can also opt in for a single request explicitly, without the global flag, by calling session.logger.logRequestOnClose() in a handler - it's idempotent per session.

Two caveats worth knowing:

  • The completion record is emitted only if session.logger is accessed during the request (that's the hook point - Serverpod's HTTP middleware runs before a Session exists, so it isn't the right layer for session-scoped logs).
  • Serverpod's session-close hook isn't given the call's error, so this record can't say whether the call failed. Keep using session.logger.error(...)/fatal(...) in your catch blocks for failures.

Binding distributed trace context #

Serverpod has its own per-session id but doesn't propagate standard distributed-trace headers onto your logs. Set bindTraceContext: true and session.logger reads the incoming request's trace headers and binds traceId/spanId as labels on every log call for that session, so your aggregator can pivot from a log line to the full trace.

ServerpodLoggerPlus.configure(
  productionWriter: const GcpJsonLogWriter(projectId: 'my-gcp-project'),
  bindTraceContext: true,
);

The first recognized header wins, in this order: W3C traceparent, GCP X-Cloud-Trace-Context, AWS X-Amzn-Trace-Id, then Datadog x-datadog-trace-id/x-datadog-parent-id. Sessions without a request (internal or future-call sessions) are simply left untagged. You can also call extractTraceContext(session) yourself if you want the ids without the global flag.

For a proprietary trace header (or any non-standard encoding), pass your own traceContextExtractor to replace the built-in parsing:

ServerpodLoggerPlus.configure(
  productionWriter: const GcpJsonLogWriter(projectId: 'my-gcp-project'),
  bindTraceContext: true,
  traceContextExtractor: (session) {
    final id = session.request?.headers['x-corp-trace-id']?.first;
    return id == null ? const {} : {'traceId': id};
  },
);

Return extractTraceContext(session) from inside your extractor if you want to keep the standard-header parsing as a fallback.

Each built-in writer maps the bound traceId/spanId into its provider's reserved trace field - OTel's native LogRecord traceId/spanId, ECS and New Relic trace.id/span.id, Datadog dd.trace_id/dd.span_id, GCP logging.googleapis.com/trace - so your backend links the log line to its trace automatically. Two provider notes:

  • GcpJsonLogWriter needs your project id to build the reserved trace field: GcpJsonLogWriter(projectId: '...'). Without it, the trace id is emitted as a label instead (searchable, but without automatic log-to-trace linking).
  • Datadog expects 64-bit decimal ids; the id is passed through as received, so it links when the incoming trace header is Datadog's own.

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 (like Serverpod Mini), or if you've explicitly set sessionLogs: { consoleEnabled: true } for that environment.

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.

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.

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` 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

1
likes
160
points
202
downloads

Documentation

API reference

Publisher

verified publisherheinrich.dev

Weekly Downloads

Plug-and-play structured logging for Serverpod: dual-routes each log to Serverpod Insights and structured JSON on stdout for GCP, Datadog, Elastic, and more.

Repository (GitHub)
View/report issues
Contributing

Topics

#serverpod #logging #observability #structured-logging #monitoring

License

MIT (license)

Dependencies

serverpod

More

Packages that depend on serverpod_logger_plus