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.
dependencies:
serverpod_logger_plus: ^0.1.0
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.
| 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:
GcpJsonLogWriterrequires yourprojectIdto 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.
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.
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 |
payloadandlabelsare persisted to your database on every log call, regardless of which writer you configure.minimumLevelgates the writer only - settingminimumLevel: LogLevel.errordoes not stop adebugcall's payload from reaching the session-log table. Use Serverpod's ownsessionLogssettings for that. If a value must never be persisted, keep it out ofpayload/labels, or add its key toredactKeys.
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
payloadandlabelskeys 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 owntoString()- still reaches both sinks. Scanning free text is false-positive-prone, so it's deliberately left to you.
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.
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 labelThe 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, 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.
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.
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.
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.)
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.
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.
MIT