otel_logging_dart 0.1.1
otel_logging_dart: ^0.1.1 copied to clipboard
Send Dart and Flutter application logs to an OpenTelemetry collector over the OTLP/HTTP protocol, with package:logging integration and trace correlation.
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.