serverpod_logger_plus
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 likesession.log(...)today. - The fully structured data (message, labels, payload, exception, stack
trace) is sent to a pluggable
LogWriter, which prints it as JSON onstdoutfor 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 ANSIConsoleLogWriter, regardless of what you passed toconfigure.- Any other run mode (
staging,production,test, ...) → theproductionWriterregistered viaServerpodLoggerPlus.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.loggeris accessed during the request (that's the hook point - Serverpod's HTTP middleware runs before aSessionexists, 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:
GcpJsonLogWriterneeds 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, notLogger-package:serverpodalready exports its ownLogger(fromrelic_core, used internally for HTTP request logging), so naming oursLoggerwould 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/JSONLogRecordon 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. afilelogreceiver 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
Libraries
- serverpod_logger_plus
- Plug-and-play structured logging for Serverpod.