diff --git a/CHANGELOG.md b/CHANGELOG.md index 2097c94..4eee689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.4] - 2025-10-30 +- Added a BaggageSpanProcessor that adds Baggage as SpanAttributes + ## [0.9.3] - 2025-10-25 ### Added - New W3CTracePropagator diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b265bab..49f9b12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,12 +24,12 @@ There are many ways to contribute to this project: 1. Fork the repository 2. Clone your fork: ```bash - git clone https://github.com/YOUR_USERNAME/opentelemetry_api.git - cd opentelemetry_api + git clone https://github.com/YOUR_USERNAME/dartastic_opentelemetry.git + cd dartastic_opentelemetry ``` 3. Add the upstream repository: ```bash - git remote add upstream https://github.com/MindfulSoftwareLLC/opentelemetry_api.git + git remote add upstream https://github.com/MindfulSoftwareLLC/dartastic_opentelemetry.git ``` 4. Install dependencies: ```bash diff --git a/README.md b/README.md index 3afdd5a..e07f196 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ OpenTelemetry data. - Tracing with span processors and samplers - Metrics collection and aggregation - Context propagation - - Baggage management + - Baggage management and optional `BaggageSpanProcessor` to automatically copy baggage entries as span attributes - Logging is not available yet [Dartastic OTel](https://pub.dev/packages/dartastic_opentelemetry) is suitable for Dart backends, CLIs or any diff --git a/example/baggage_example.dart b/example/baggage_example.dart index 80be074..4bb1cc5 100644 --- a/example/baggage_example.dart +++ b/example/baggage_example.dart @@ -2,10 +2,20 @@ // Copyright 2025, Michael Bushe, All rights reserved. import 'package:dartastic_opentelemetry/src/otel.dart'; +import 'package:dartastic_opentelemetry/src/trace/export/baggage_span_processor.dart'; import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart'; Future main() async { - // Create a baggage with a single key-value pair + await OTel.initialize( + serviceName: 'my-service', + serviceVersion: '1.0.0', + ); + + // Optionally initialize tracer provider with a BaggageSpanProcessor + final tracerProvider = OTel.tracerProvider(); + // Make baggage automatically appear in span attributes + tracerProvider.addSpanProcessor(const BaggageSpanProcessor()); + final baggage = OTel.baggage( {'customer.id': OTel.baggageEntry('123', 'source=mobile app')}); @@ -14,7 +24,8 @@ Future main() async { // Best practice: Use dot notation for key namespacing to avoid conflicts final enrichedBaggage = baggage .copyWith('deployment.environment', 'staging') - .copyWith('user.region', 'us-west', 'source=user profile'); + .copyWith('user.region', 'us-west', 'source=user profile') + .copyWith('client.session.id', 'session-123'); // Baggage is always associated with a Context // This allows it to automatically propagate through your application diff --git a/lib/dartastic_opentelemetry.dart b/lib/dartastic_opentelemetry.dart index b2a9473..b5b13c7 100644 --- a/lib/dartastic_opentelemetry.dart +++ b/lib/dartastic_opentelemetry.dart @@ -108,6 +108,7 @@ export 'src/otel.dart'; export 'src/resource/resource.dart'; export 'src/resource/resource_detector.dart'; export 'src/resource/web_detector.dart'; +export 'src/trace/export/baggage_span_processor.dart'; export 'src/trace/export/batch_span_processor.dart'; export 'src/trace/export/composite_exporter.dart'; export 'src/trace/export/console_exporter.dart'; diff --git a/lib/src/trace/export/baggage_span_processor.dart b/lib/src/trace/export/baggage_span_processor.dart new file mode 100644 index 0000000..357392e --- /dev/null +++ b/lib/src/trace/export/baggage_span_processor.dart @@ -0,0 +1,78 @@ +// Licensed under the Apache License, Version 2.0 +// Copyright 2025, Michael Bushe, All rights reserved. + +import 'package:dartastic_opentelemetry/src/trace/span.dart'; +import 'package:dartastic_opentelemetry/src/trace/span_processor.dart'; +import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart'; + +/// Automatically copies baggage entries to span attributes on span start. +/// +/// https://opentelemetry.io/docs/specs/otel/baggage/api/#baggage-propagation +/// "Because a common use case for Baggage is to add data to Span Attributes +/// across a whole trace, several languages have Baggage Span Processors that +/// add data from baggage as attributes on span creation." +/// +/// This BaggageSpanProcessor extracts all baggage entries from the parent or +/// current context and adds them as span attributes when spans are created. +/// +/// This enables baggage values to be: +/// - Visible in tracing backends (HyperDX, Jaeger, etc.) +/// - Searchable and filterable for trace queries +/// - Automatically propagated to all spans without manual attribute setting +/// +/// Example usage: +/// +/// ```dart +/// final tracerProvider = OTel.tracerProvider(); +/// tracerProvider.addSpanProcessor(BaggageSpanProcessor()); +/// ``` +class BaggageSpanProcessor implements SpanProcessor { + /// Creates a [BaggageSpanProcessor] instance. + const BaggageSpanProcessor(); + + @override + Future onStart(Span span, Context? parentContext) async { + // Extract baggage from the current context + final baggage = Context.current.baggage; + if (baggage == null) { + return; + } + + final entries = baggage.getAllEntries(); + if (entries.isEmpty) { + return; + } + + // Convert baggage entries to a map for span attributes + // Note: Baggage metadata is intentionally not included as it's not part of the value + final attributeMap = {}; + for (final entry in entries.entries) { + attributeMap[entry.key] = entry.value.value; + } + + // Add all baggage attributes to the span at once + if (attributeMap.isNotEmpty) { + span.addAttributes(Attributes.of(attributeMap)); + } + } + + @override + Future onEnd(Span span) async { + // No-op: baggage attributes are already added during onStart + } + + @override + Future onNameUpdate(Span span, String newName) async { + // No-op: baggage values don't change when span name is updated + } + + @override + Future shutdown() async { + // No-op: this processor has no resources to clean up + } + + @override + Future forceFlush() async { + // No-op: this processor doesn't batch or queue spans + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 95d1453..584e197 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: dartastic_opentelemetry description: Dartastic.io's OpenTelemetry SDK for Dart -version: 0.9.3 +version: 0.9.4 repository: https://github.com/MindfulSoftwareLLC/dartastic_opentelemetry.git homepage: https://dartastic.io diff --git a/test/unit/trace/export/baggage_span_processor_test.dart b/test/unit/trace/export/baggage_span_processor_test.dart new file mode 100644 index 0000000..88d0311 --- /dev/null +++ b/test/unit/trace/export/baggage_span_processor_test.dart @@ -0,0 +1,289 @@ +// Licensed under the Apache License, Version 2.0 +// Copyright 2025, Michael Bushe, All rights reserved. + +import 'package:dartastic_opentelemetry/dartastic_opentelemetry.dart'; +import 'package:test/test.dart'; + +import '../../../testing_utils/in_memory_span_exporter.dart'; + +void main() { + group('BaggageSpanProcessor', () { + late InMemorySpanExporter exporter; + late SimpleSpanProcessor exportProcessor; + late BaggageSpanProcessor baggageProcessor; + late TracerProvider tracerProvider; + + setUp(() async { + // Clean state for each test + await OTel.reset(); + + // Create processors + exporter = InMemorySpanExporter(); + exportProcessor = SimpleSpanProcessor(exporter); + baggageProcessor = const BaggageSpanProcessor(); + + // Initialize OTel with minimal configuration + await OTel.initialize( + serviceName: 'test-baggage-service', + serviceVersion: '1.0.0', + enableMetrics: false, + ); + + tracerProvider = OTel.tracerProvider(); + // Add baggage processor first so it runs before export + tracerProvider.addSpanProcessor(baggageProcessor); + tracerProvider.addSpanProcessor(exportProcessor); + }); + + tearDown(() async { + await exportProcessor.shutdown(); + await exporter.shutdown(); + await tracerProvider.shutdown(); + await OTel.reset(); + }); + + test('Copies baggage entries to span attributes on start', () async { + exporter.clear(); + + // Create baggage with multiple entries + final baggage = OTel.baggage() + .copyWith('client.session.id', 'session-123') + .copyWith('user.id', 'user-456') + .copyWith('deployment.environment', 'staging'); + + // Run with baggage context + final context = OTel.context().withBaggage(baggage); + await context.run(() async { + final tracer = tracerProvider.getTracer('test-tracer'); + final span = tracer.startSpan('test-span-with-baggage'); + span.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Verify span was exported with baggage attributes + expect(exporter.spans, hasLength(1)); + final exportedSpan = exporter.spans.first; + + expect(exportedSpan.attributes.getString('client.session.id'), + equals('session-123')); + expect(exportedSpan.attributes.getString('user.id'), equals('user-456')); + expect(exportedSpan.attributes.getString('deployment.environment'), + equals('staging')); + }); + + test('Handles empty baggage gracefully', () async { + exporter.clear(); + + // Create empty baggage + final baggage = OTel.baggage(); + + // Run with empty baggage context + final context = OTel.context().withBaggage(baggage); + await context.run(() async { + final tracer = tracerProvider.getTracer('test-tracer'); + final span = tracer.startSpan('test-span-empty-baggage'); + span.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Verify span was exported without baggage attributes + expect(exporter.spans, hasLength(1)); + final exportedSpan = exporter.spans.first; + expect(exportedSpan.name, equals('test-span-empty-baggage')); + // Span should have been created successfully without any baggage attributes + }); + + test('Handles no baggage context gracefully', () async { + exporter.clear(); + + // Run without any baggage context + final tracer = tracerProvider.getTracer('test-tracer'); + final span = tracer.startSpan('test-span-no-baggage'); + span.end(); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Verify span was exported successfully + expect(exporter.spans, hasLength(1)); + final exportedSpan = exporter.spans.first; + expect(exportedSpan.name, equals('test-span-no-baggage')); + }); + + test('Ignores baggage metadata', () async { + exporter.clear(); + + // Create baggage with metadata + final baggage = OTel.baggage().copyWith('transaction.id', 'tx-789', + 'metadata=source:mobile-app;priority=high'); + + // Run with baggage context + final context = OTel.context().withBaggage(baggage); + await context.run(() async { + final tracer = tracerProvider.getTracer('test-tracer'); + final span = tracer.startSpan('test-span-with-metadata'); + span.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Verify span has baggage value but not metadata + expect(exporter.spans, hasLength(1)); + final exportedSpan = exporter.spans.first; + + // The attribute should contain only the value, not the metadata + expect(exportedSpan.attributes.getString('transaction.id'), + equals('tx-789')); + }); + + test('Works with nested spans in baggage context', () async { + exporter.clear(); + + // Create baggage + final baggage = OTel.baggage().copyWith('request.id', 'req-999'); + + // Run with baggage context and create nested spans + final context = OTel.context().withBaggage(baggage); + await context.run(() async { + final tracer = tracerProvider.getTracer('test-tracer'); + + final parentSpan = tracer.startSpan('parent-span'); + final childSpan = tracer.startSpan('child-span'); + + childSpan.end(); + parentSpan.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Both spans should have the baggage attribute + expect(exporter.spans, hasLength(2)); + + for (final span in exporter.spans) { + expect(span.attributes.getString('request.id'), equals('req-999')); + } + }); + + test('Baggage changes are reflected in new spans', () async { + exporter.clear(); + + final tracer = tracerProvider.getTracer('test-tracer'); + + // First context with initial baggage + final baggage1 = OTel.baggage().copyWith('stage', 'initial'); + await OTel.context().withBaggage(baggage1).run(() async { + final span1 = tracer.startSpan('span-1'); + span1.end(); + }); + + // Second context with updated baggage + final baggage2 = OTel.baggage().copyWith('stage', 'updated'); + await OTel.context().withBaggage(baggage2).run(() async { + final span2 = tracer.startSpan('span-2'); + span2.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Verify both spans have different baggage values + expect(exporter.spans, hasLength(2)); + + final span1 = exporter.findSpanByName('span-1')!; + expect(span1.attributes.getString('stage'), equals('initial')); + + final span2 = exporter.findSpanByName('span-2')!; + expect(span2.attributes.getString('stage'), equals('updated')); + }); + + test('onEnd is a no-op', () async { + // This test verifies that onEnd doesn't throw or cause issues + final span = OTel.tracer().startSpan('test-span'); + await baggageProcessor.onEnd(span); + // If we get here without errors, the test passes + span.end(); + }); + + test('onNameUpdate is a no-op', () async { + // This test verifies that onNameUpdate doesn't throw or cause issues + final span = OTel.tracer().startSpan('test-span'); + await baggageProcessor.onNameUpdate(span, 'new-name'); + // If we get here without errors, the test passes + span.end(); + }); + + test('shutdown is a no-op', () async { + // This test verifies that shutdown doesn't throw or cause issues + await baggageProcessor.shutdown(); + // If we get here without errors, the test passes + }); + + test('forceFlush is a no-op', () async { + // This test verifies that forceFlush doesn't throw or cause issues + await baggageProcessor.forceFlush(); + // If we get here without errors, the test passes + }); + + test('Processor can be used with multiple tracers', () async { + exporter.clear(); + + final baggage = OTel.baggage().copyWith('common.attribute', 'shared'); + + final context = OTel.context().withBaggage(baggage); + await context.run(() async { + final tracer1 = tracerProvider.getTracer('tracer-1'); + final tracer2 = tracerProvider.getTracer('tracer-2'); + + final span1 = tracer1.startSpan('span-from-tracer-1'); + final span2 = tracer2.startSpan('span-from-tracer-2'); + + span1.end(); + span2.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Both spans from different tracers should have baggage attributes + expect(exporter.spans, hasLength(2)); + + for (final span in exporter.spans) { + expect(span.attributes.getString('common.attribute'), equals('shared')); + } + }); + + test('Processor works with high cardinality baggage', () async { + exporter.clear(); + + // Create baggage with many entries + var baggage = OTel.baggage(); + for (int i = 0; i < 20; i++) { + baggage = baggage.copyWith('key$i', 'value$i'); + } + + final context = OTel.context().withBaggage(baggage); + await context.run(() async { + final tracer = tracerProvider.getTracer('test-tracer'); + final span = tracer.startSpan('span-with-many-attributes'); + span.end(); + }); + + // Force flush to ensure export + await exportProcessor.forceFlush(); + + // Verify all baggage entries were added as attributes + expect(exporter.spans, hasLength(1)); + final exportedSpan = exporter.spans.first; + + for (int i = 0; i < 20; i++) { + expect(exportedSpan.attributes.getString('key$i'), equals('value$i')); + } + }); + }); +}