OpenTelemetry Logging for Dart
otel_logging_dart ships your Dart and Flutter application logs to an
OpenTelemetry collector using the OTLP/HTTP
(JSON) protocol. It integrates with the standard
package:logging facade and formats
severities, attributes, and distributed-tracing context according to the
OpenTelemetry logs data model.
It is written in pure Dart (no dart:io), so the same code runs on
server-side Dart, Flutter mobile and desktop, and the web.
Table of contents
- Why this package
- Features
- Requirements & compatibility
- Installation
- Quick start
- Usage guide
- Configuration reference
- How values are mapped
- Flutter notes
- Running a local collector
- Architecture
- Testing
- Troubleshooting
- Contributing
- License
Why this package
OpenTelemetry is the vendor-neutral standard for telemetry (logs, metrics, and
traces). Most backends — Grafana Loki, Honeycomb, Datadog, New Relic, Elastic,
SigNoz, Jaeger-adjacent stacks, or a self-hosted
OpenTelemetry Collector — can ingest
OTLP directly. This package lets a Dart/Flutter app become a first-class log
source in that pipeline without pulling in a heavy SDK: you keep using
package:logging as normal, and each record is converted to a correctly-shaped
OTLP log record and POSTed to your collector's /v1/logs endpoint.
Features
- 🔄 Ships logs to any OTLP/HTTP collector (
/v1/logs, JSON encoding). - 🪵 Drop-in
package:loggingintegration — one call wires it to a logger. - 🧩 Correct OTLP typing — strings, ints, doubles, bools, and JSON-encoded
maps/lists become the proper
AnyValueshapes. - 🔍 Trace correlation —
trace_id/span_id/trace_flagsare validated and attached so logs line up with your traces. - 🧨 Exception expansion — errors become semantic
exception.type,exception.message, andexception.stacktraceattributes. - 🏷️ Resource & global attributes —
service.name,service.version,deployment.environment,host.name, plus any attributes you add to every record. - 🛡️ Safe by design — logging never throws; transport failures are routed to
an
onErrorcallback, with a re-entrancy guard so a failing logger can't loop. - 🌐 Pure Dart — no
dart:io; runs on server, mobile, desktop, and web. - 🔌 Injectable everything — bring your own
http.Client,SeverityMapper,AttributeFormatter, orLogPayloadBuilderfor testing or customization.
Requirements & compatibility
| Dart SDK | >=3.5.0 <4.0.0 |
| Runtime deps | http ^1.2.0, logging ^1.2.0 |
| Platforms | Dart VM/server, Flutter Android/iOS/macOS/Windows/Linux, Web |
| Protocol | OTLP/HTTP with JSON encoding (the collector's /v1/logs route) |
This package targets OTLP/HTTP + JSON, not gRPC and not protobuf-over-HTTP. Point it at the HTTP receiver of your collector (default port
4318).
Installation
dart pub add otel_logging_dart
…or add it manually to pubspec.yaml:
dependencies:
otel_logging_dart: ^0.1.0
Then import it:
import 'package:otel_logging_dart/otel_logging_dart.dart';
Quick start
import 'package:logging/logging.dart';
import 'package:otel_logging_dart/otel_logging_dart.dart';
void main() {
final exporter = OtelLogExporter(
config: const OtelConfig(
enabled: true,
endpoint: 'http://localhost:4318/v1/logs',
serviceName: 'my-dart-app',
serviceVersion: '1.4.2',
environment: 'production',
),
onError: (error, stackTrace) => print('OTLP export failed: $error'),
);
Logger.root.level = Level.ALL; // decide which records flow
exporter.attachToLogger(Logger.root); // forward them to the collector
Logger('orders').info('order processed');
}
That's it — every record that passes the logger's level is converted to an OTLP log record and sent to your collector.
Usage guide
Integrating with package:logging
attachToLogger subscribes to a logger's onRecord stream and exports each
record. It returns the StreamSubscription, so you can cancel it later.
final subscription = exporter.attachToLogger(Logger.root);
// ...
await subscription.cancel(); // stop forwarding to the collector
Remember to set a level — package:logging drops everything by default:
Logger.root.level = Level.ALL; // or Level.INFO, Level.WARNING, etc.
You can attach to the root logger (captures everything) or to a specific named logger to scope what gets exported.
Structured attributes
package:logging carries a structured payload through the message object.
Pass a Map and every entry becomes an OTLP attribute. The message key (if
present) becomes the log body:
Logger('orders').info({
'message': 'order processed',
'order_id': 1234,
'amount': 49.99,
'paid': true,
'items': ['sku-1', 'sku-2'], // lists/maps are JSON-encoded into a string
});
If you log a plain String, it becomes the body and the record simply has no
custom attributes (the logger name is always added as logger.name).
Global attributes
Attributes that should appear on every record — team, region, build id, etc. — go on the config. They are emitted ahead of per-record attributes:
const OtelConfig(
enabled: true,
endpoint: 'http://localhost:4318/v1/logs',
serviceName: 'my-dart-app',
globalAttributes: {
'team': 'payments',
'region': 'eu-west-1',
},
);
Distributed tracing
To correlate a log with a trace, include the reserved keys in your attribute map. They are lifted out of the attributes and placed on the dedicated OTLP fields:
Logger('orders').info({
'message': 'processing order',
'trace_id': '0af7651916cd43dd8448eb211c80319c', // 32 lowercase hex chars
'span_id': 'b7ad6b7169203331', // 16 lowercase hex chars
'trace_flags': 1, // optional, 0–255
});
Validation rules (invalid context is dropped silently rather than corrupting the record):
trace_idmust be exactly 32 hexadecimal characters and not all zeros.span_idmust be exactly 16 hexadecimal characters and not all zeros.- Values are trimmed and lower-cased before validation.
trace_flags: if a validtrace_idandspan_idare present and you omittrace_flags(or pass something out of the0–255range), the record is marked sampled (flags = 1). If either id is missing/invalid,flags = 0.
Logging exceptions
Errors passed through package:logging (the optional second/third arguments)
are expanded into semantic attributes:
try {
// ...
} catch (error, stackTrace) {
Logger('payments').severe('payment failed', error, stackTrace);
}
produces the attributes:
exception.type— the error's runtime typeexception.message—error.toString()exception.stacktrace— the stack trace (when provided)
You can also attach an error to a record you build yourself via
OtelLogRecord(error: ..., stackTrace: ...), or include an exception /
error key in an attribute map.
Authentication & custom headers
Most hosted collectors require an API key or bearer token. Add any HTTP headers
via headers; they are sent with every request alongside Content-Type:
const OtelConfig(
enabled: true,
endpoint: 'https://otlp.example-vendor.com/v1/logs',
serviceName: 'my-dart-app',
headers: {
'Authorization': 'Bearer <token>',
'x-api-key': '<key>',
},
);
Emitting records directly
You don't have to use package:logging at all. Build and emit an
OtelLogRecord yourself:
await exporter.emit(
OtelLogRecord(
severityNumber: SeverityMapper.severityWarn,
severityText: 'warn',
body: 'cache miss',
attributes: {'key': 'user:42'},
// optional: time, traceId, spanId, traceFlags, error, stackTrace
),
);
emit returns a Future that completes once the record has been sent (or its
failure handled). It never throws.
Handling export failures
The logging path is intentionally non-throwing. Network errors, timeouts,
non-2xx responses, and a missing endpoint are all routed to your onError
callback instead of bubbling up into your app:
OtelLogExporter(
config: config,
onError: (error, stackTrace) {
// e.g. forward to a local file logger, increment a metric, etc.
stderr.writeln('Failed to export log: $error');
},
);
A re-entrancy guard ensures that if your onError handler itself logs (and that
log is also exported and also fails), it won't spiral into an infinite loop.
Enabling / disabling at runtime
When enabled is false, emit is a no-op and nothing is sent — handy for
disabling export in tests or local development without removing the wiring:
final config = OtelConfig.fromEnvironment(Platform.environment)
.copyWith(enabled: !kReleaseMode ? false : true);
Cleanup
If you let the exporter create its own http.Client, call close() when you're
done so the client's resources are released (and further emit calls become
no-ops). If you passed in your own client, you own its lifecycle.
exporter.close();
Configuration reference
OtelConfig is an immutable value object. Construct it directly or use
copyWith to derive variants.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Master switch. When false, emit does nothing. |
endpoint |
String |
'' |
OTLP/HTTP logs endpoint, e.g. http://localhost:4318/v1/logs. |
serviceName |
String |
'dart-app' |
service.name resource attribute. |
serviceVersion |
String |
'1.0.0' |
service.version resource attribute. |
environment |
String |
'production' |
deployment.environment resource attribute. |
hostName |
String |
'unknown' |
host.name resource attribute. |
timeout |
Duration |
5s |
Per-request HTTP timeout. |
headers |
Map<String, String> |
{} |
Extra headers (auth, etc.) on every request. |
globalAttributes |
Map<String, Object?> |
{} |
Attributes added to every log record. |
scopeName |
String |
'dart-logs' |
Instrumentation scope name in scopeLogs. |
scopeVersion |
String |
'unknown' |
Instrumentation scope version in scopeLogs. |
Configuring from environment variables
OtelConfig.fromEnvironment reads a string map (typically
Platform.environment on the Dart VM):
import 'dart:io';
final config = OtelConfig.fromEnvironment(
Platform.environment,
hostName: Platform.localHostname, // optional; defaults to 'unknown'
);
dart:iois used by you here, not by the package — so the core library stays web-compatible. On web, buildOtelConfigdirectly instead.
| Environment variable | Maps to | Default |
|---|---|---|
OTEL_ENABLED |
enabled (true/1/yes/on → true) |
false |
OTEL_EXPORTER_ENDPOINT |
endpoint |
'' |
OTEL_SERVICE_NAME |
serviceName |
dart-app |
OTEL_SERVICE_VERSION |
serviceVersion |
1.0.0 |
OTEL_ENVIRONMENT (falls back to APP_ENV) |
environment |
production |
OTEL_HTTP_TIMEOUT |
timeout (seconds) |
5 |
headers, globalAttributes, and hostName can also be passed as named
arguments to fromEnvironment.
How values are mapped
Severity mapping
SeverityMapper buckets a package:logging Level into an OTLP
severityNumber. The severityText is the lower-cased Dart level name, so it
stays meaningful even for custom levels. Mapping is range-based, so any
custom integer level value still lands in a sensible bucket.
package:logging level |
value | severityNumber |
severityText |
|---|---|---|---|
FINEST |
300 | 1 (TRACE) | finest |
FINER |
400 | 1 (TRACE) | finer |
FINE |
500 | 5 (DEBUG) | fine |
CONFIG |
700 | 5 (DEBUG) | config |
INFO |
800 | 9 (INFO) | info |
WARNING |
900 | 13 (WARN) | warning |
SEVERE |
1000 | 17 (ERROR) | severe |
SHOUT |
1200 | 21 (FATAL) | shout |
Range boundaries: [0,500) → 1, [500,800) → 5, [800,900) → 9,
[900,1000) → 13, [1000,1200) → 17, [1200,∞) → 21. The numeric constants are
exposed as SeverityMapper.severityTrace, …severityDebug, …severityInfo,
…severityWarn, …severityError, and …severityFatal.
Attribute type mapping
AttributeFormatter converts each Dart value to an OTLP AnyValue:
| Dart value | OTLP value |
|---|---|
String |
{ "stringValue": ... } |
int |
{ "intValue": ... } |
double |
{ "doubleValue": ... } |
bool |
{ "boolValue": ... } |
null |
{ "stringValue": "null" } |
Map / Iterable |
JSON-encoded into { "stringValue": ... } |
| anything else | { "stringValue": value.toString() } |
An exception or error key whose value is an error object is expanded into the
exception.* attributes described above instead of being formatted as a single
value.
The OTLP payload
Each record is sent as a complete resourceLogs envelope. For example,
Logger('orders').info({'message': 'order processed', 'order_id': 1234}) with
the quick-start config produces roughly:
{
"resourceLogs": [
{
"resource": {
"attributes": [
{ "key": "service.name", "value": { "stringValue": "my-dart-app" } },
{ "key": "service.version", "value": { "stringValue": "1.4.2" } },
{ "key": "deployment.environment", "value": { "stringValue": "production" } },
{ "key": "host.name", "value": { "stringValue": "unknown" } }
]
},
"scopeLogs": [
{
"scope": { "name": "dart-logs", "version": "unknown" },
"logRecords": [
{
"timeUnixNano": "1717977600000000000",
"severityNumber": 9,
"severityText": "info",
"body": { "stringValue": "order processed" },
"attributes": [
{ "key": "order_id", "value": { "intValue": 1234 } },
{ "key": "logger.name", "value": { "stringValue": "orders" } }
],
"droppedAttributesCount": 0,
"flags": 0,
"traceId": "",
"spanId": ""
}
]
}
]
}
]
}
Timestamps are emitted as nanoseconds since the Unix epoch
(microsecondsSinceEpoch * 1000) encoded as a string, per the OTLP/JSON
encoding of 64-bit fields.
Flutter notes
- Works on all Flutter targets. On web, configure
OtelConfigdirectly (noPlatform.environment). - Send your collector endpoint over HTTPS in production; on Android, cleartext HTTP to a dev collector may require a network-security-config exception.
- For mobile apps, point at a collector you control (or a vendor's OTLP ingest)
rather than a collector on
localhost. - Consider only enabling export in release builds, or gating it behind a user consent / telemetry setting.
Running a local collector
A minimal collector to receive logs over OTLP/HTTP on port 4318:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
logs:
receivers: [otlp]
exporters: [debug]
docker run --rm -p 4318:4318 \
-v "$(pwd)/otel-collector-config.yaml:/etc/otelcol/config.yaml" \
otel/opentelemetry-collector:latest
Then set endpoint: 'http://localhost:4318/v1/logs' and watch the records print
in the collector's debug output.
Architecture
The package is small and composable; each class has a single responsibility and can be replaced via constructor injection.
| Class | Responsibility |
|---|---|
OtelLogExporter |
Builds payloads, POSTs them, and bridges package:logging. |
OtelConfig |
Immutable configuration (with fromEnvironment and copyWith). |
OtelLogRecord |
Framework-agnostic log entry model (plus fromLogging). |
LogPayloadBuilder |
Constructs the OTLP resourceLogs envelope. |
AttributeFormatter |
Converts Dart values to OTLP typed attributes. |
SeverityMapper |
Maps package:logging levels to OTLP severities. |
Logger.info({...})
│ package:logging LogRecord
▼
OtelLogExporter.attachToLogger ──▶ OtelLogRecord.fromLogging
│ │ (SeverityMapper)
▼ ▼
OtelLogExporter.emit ──▶ LogPayloadBuilder.build ──▶ AttributeFormatter
│ │
▼ ▼
http.Client.post(endpoint) ◀── OTLP resourceLogs JSON
Testing
The package ships with a full unit-test suite. To run it:
dart test
Because every collaborator is injectable, testing your own integration is easy —
pass a MockClient
from package:http:
import 'package:http/testing.dart';
import 'package:http/http.dart' as http;
final client = MockClient((request) async {
// assert on request.body here
return http.Response('', 204);
});
final exporter = OtelLogExporter(config: config, client: client);
Troubleshooting
Nothing is being sent.
Check that enabled is true, the logger's level is low enough to emit the
records, and endpoint is non-empty. An empty endpoint is reported through
onError as a StateError.
I get a ClientException in onError.
The collector returned a non-2xx status, or the host was unreachable. The
exception message includes the HTTP status (e.g. HTTP 500). Verify the
endpoint path ends in /v1/logs and that any required auth headers are set.
Requests time out.
Increase timeout, or check connectivity to the collector. In high-volume apps,
prefer a nearby/batching collector.
My attributes show up as a JSON string.
Maps and lists are JSON-encoded into a single stringValue (OTLP attributes are
flat). Flatten them into individual keys if you need them queryable.
Contributing
Issues and pull requests are welcome. Please run dart analyze, dart format .,
and dart test before submitting.
License
MIT — see LICENSE.
Libraries
- otel_logging_dart
- Send Dart application logs to an OpenTelemetry collector over OTLP/HTTP.