Skip to content

Commit 4141e53

Browse files
feat(tracing): add ContextScope for span context lifetime control (#138)
## Description Add `ContextScope` parameter to `startSpan()` that controls how long a span remains active in zone context for automatic parent assignment. - **`ContextScope.callback` (default)**: Span is deactivated when callback completes. Timer/stream callbacks firing after the callback ends will start fresh, independent traces. - **`ContextScope.zone`**: Span stays active for the entire zone lifetime, allowing timer callbacks to inherit the parent span. This provides explicit control over timer callback behavior, fixing the issue where spans that had ended remained active in zone context causing unexpected parent-child relationships. ## Related Issue(s) Fixes #105 ## Type of Change - [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) - [x] 🚀 New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] 📝 Documentation - [ ] 📈 Performance improvement - [ ] 🏗️ Code refactoring - [ ] 🧹 Chore / Housekeeping ## Checklist - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the CHANGELOG.md under the "Unreleased" section ## Additional Notes ### Implementation Details - `SpanContextHolder` wrapper class tracks span + context scope + active state - `ContextScope` enum defined in `span.dart` with `callback` and `zone` values - `FaroZoneSpanManager.executeWithSpan()` conditionally deactivates the holder based on scope - Public API updated in `Faro.startSpan()` and `FaroTracer.startSpan()` ### Example App Added "Context Scope" demo button in the tracing feature to test and demonstrate both `ContextScope` values with timer callbacks. ### Documentation Consolidated timer/async callback documentation in `doc/Features.md` under "Timer & Async Callback Behavior" section. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes zone-based span context propagation semantics and adds a new public parameter to `startSpan()`, which could alter parent/child relationships for async callbacks (timers/streams) in existing integrations. > > **Overview** > Adds a new `contextScope` parameter to `Faro().startSpan()`/`FaroTracer.startSpan()` to control how long a span remains the active parent in zone context (`callback` default vs `zone` for long-lived async work). > > Refactors zone propagation to store a `SpanContextHolder` in zone values and conditionally deactivate it after the callback, so timer/stream callbacks firing later don’t accidentally inherit an ended span unless explicitly requested. > > Updates docs/CHANGELOG and the example tracing UI to demonstrate the new behavior, and expands unit/integration tests around context scoping and real timer behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4453420. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ea5e8b0 commit 4141e53

File tree

11 files changed

+860
-138
lines changed

11 files changed

+860
-138
lines changed

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- **Span.noParent sentinel**: New `Span.noParent` static constant allows explicitly starting a span with no parent, ignoring the active span in zone context. Useful for timer callbacks or event-driven scenarios where the original parent span may have ended but remains in zone context. (Resolves #105)
12+
- **ContextScope for span context lifetime control**: New `contextScope` parameter on `startSpan()` controls how long a span remains active in zone context for auto-assignment. `ContextScope.callback` (default) deactivates the span when the callback completes, preventing timer/stream callbacks from inheriting it. `ContextScope.zone` keeps the span active for the entire zone lifetime, useful when you want timer callbacks to be children of the parent span. (Resolves #105)
13+
14+
- **Span.noParent sentinel**: New `Span.noParent` static constant allows explicitly starting a span with no parent, ignoring the active span in zone context. Useful for timer callbacks or event-driven scenarios where you want to start a fresh, independent trace. (Resolves #105)
1315

1416
### Fixed
1517

@@ -34,18 +36,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3436
### Added
3537

3638
- **User management with FaroUser model**: New `FaroUser` class for comprehensive user identification
39+
3740
- Replaces the legacy `User` model with a more feature-rich implementation
3841
- Supports `id`, `username`, `email`, and custom `attributes` fields
3942
- Custom attributes align with [Faro Web SDK MetaUser](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/architecture/metas/#how-to-use-the-user-meta) for cross-platform consistency
4043
- Includes `FaroUser.cleared()` constructor to explicitly clear user data
4144

4245
- **User persistence**: New `persistUser` option in `FaroConfig` (default: `true`)
46+
4347
- Automatically saves user identity to device storage
4448
- Restores user on subsequent app launches for consistent session tracking
4549
- Early events like `appStart` include user data when persistence is enabled
4650
- Fires `user_set` event on restore and `user_updated` event on changes
4751

4852
- **Initial user configuration**: New `initialUser` option in `FaroConfig`
53+
4954
- Set a user immediately on SDK initialization
5055
- Use `FaroUser.cleared()` to explicitly clear any persisted user on start
5156
- Useful for apps that know the user at startup or need to force logout state
@@ -117,6 +122,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
117122
### Fixed
118123

119124
- **SDK name consistency across telemetry types**: Updated SDK identification to use consistent naming
125+
120126
- Changed hardcoded 'rum-flutter' SDK name to use `FaroConstants.sdkName` for consistency with OpenTelemetry traces
121127
- Maintains backend-compatible version '1.3.5' for proper web SDK version validation
122128
- Added actual Faro Flutter SDK version to session attributes as 'faro_sdk_version' for tracking real SDK version
@@ -133,7 +139,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
133139
### Changed
134140

135141
- **BREAKING: Package structure refactoring to follow Flutter plugin conventions**: Reorganized the package to align with Flutter/Dart ecosystem standards and best practices
142+
136143
- **Breaking Change**: Main entry point changed from `faro_sdk.dart` to `faro.dart`
144+
137145
- The package now follows the standard `lib/<package_name>.dart` convention
138146
- Removed `lib/faro_sdk.dart` file entirely
139147
- `lib/faro.dart` is now the single main entry point with selective barrel exports
@@ -149,13 +157,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
149157
```
150158
151159
- **Architecture Improvements**:
160+
152161
- Moved core `Faro` class implementation from `lib/faro.dart` to `lib/src/faro.dart`
153162
- `lib/faro.dart` now serves as a clean barrel export file exposing only public APIs
154163
- All implementation details properly organized under `lib/src/` directory
155164
- Clear separation between public API surface and private implementation
156165
- Follows established Flutter ecosystem conventions used by popular packages like Provider, BLoC, and Dio
157166
158167
- **Benefits**:
168+
159169
- **Cleaner API boundaries**: Clear distinction between public and private APIs
160170
- **Better maintainability**: Implementation details can evolve without affecting public interface
161171
- **Consistent developer experience**: Matches patterns developers expect from other Flutter packages
@@ -167,11 +177,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
167177
### Added
168178
169179
- **Type-Safe Log Level API**: New `LogLevel` enum for improved logging reliability and developer experience
180+
170181
- Introduced `LogLevel` enum with values: `trace`, `debug`, `info`, `log`, `warn`, `error`
171182
- Aligns with Grafana Faro Web SDK for cross-platform consistency
172183
- Includes `fromString()` method for backward compatibility, supporting both `'warn'` and `'warning'` variants
173184
174185
- **Enhanced Tracing and Span API**: Major improvements to distributed tracing capabilities
186+
175187
- New `startSpan<T>()` method for automatic span lifecycle management with callback-based execution
176188
- New `startSpanManual()` method for manual span lifecycle management when precise control is needed
177189
- New `getActiveSpan()` method to access the currently active span from anywhere in the execution context
@@ -183,15 +195,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
183195
- Comprehensive documentation with detailed examples for common tracing patterns
184196
185197
- **Centralized Session Management**: New `SessionIdProvider` for consistent session handling across the SDK
198+
186199
- Dedicated session ID generation and management
187200
- Better integration with tracing system for session context propagation
188201
- Factory pattern for testable session management
189202
190203
- **SDK Constants Management**: New centralized constants system
204+
191205
- Added `FaroConstants` class for SDK version and name management
192206
- Better version tracking and consistency across the codebase
193207
194208
- **BREAKING: Synchronous API for telemetry methods**: Refactored telemetry methods to remove unnecessary async patterns for improved performance and developer experience
209+
195210
- **Breaking Change**: The following methods changed from `Future<void>?` to `void`:
196211
- `pushEvent()` - Send custom events
197212
- `pushLog()` - Send custom logs
@@ -217,13 +232,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
217232
- **Internal Architecture**: Introduced `BatchTransportFactory` singleton pattern for better dependency management and testing
218233
219234
- **BREAKING: pushLog API requires LogLevel enum**: Enhanced logging API for better type safety and consistency
235+
220236
- **Breaking Change**: `pushLog()` now requires a `LogLevel` parameter instead of optional `String?`
221237
- **Migration**: Replace `level: "warn"` with `level: LogLevel.warn` in your pushLog calls
222238
- **Benefit**: Eliminates typos in log levels and provides better IDE support
223239
- **Compatibility**: Existing string-based log levels in internal code updated to use LogLevel enum
224240
- **Documentation**: All examples and documentation updated to reflect the new API
225241
226242
- **Tracing Architecture Refactoring**: Complete redesign of the internal tracing system
243+
227244
- Replaced legacy `tracer.dart` and `tracer_provider.dart` with new `FaroTracer` implementation
228245
- New `FaroZoneSpanManager` for robust zone-based span context management
229246
- Improved `Span` class with cleaner API and better OpenTelemetry integration

doc/Features.md

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -286,30 +286,69 @@ await Faro().startSpan('main_operation', (span) async {
286286
});
287287
```
288288

289-
### 🔓 Starting Independent Traces
289+
### 🕐 Timer & Async Callback Behavior
290290

291-
In some scenarios (like timer callbacks or event-driven architectures), you may want to start a new trace without inheriting from the active span in the zone context. Use `Span.noParent` for this:
291+
When using `startSpan()`, you have control over how timer and stream callbacks relate to the parent span. The `contextScope` parameter determines this behavior.
292+
293+
#### Default: Timer Callbacks Start New Traces
294+
295+
By default (`ContextScope.callback`), spans are deactivated from context when the callback completes. Timer or stream callbacks that fire _after_ the callback has completed will start their own independent traces:
292296

293297
```dart
294-
// Inside a callback where the original parent span may have ended
295298
await Faro().startSpan('parent-operation', (parentSpan) async {
296-
// Set up a periodic timer
299+
// This timer fires after the callback completes (30s > doMainWork duration)
297300
Timer.periodic(Duration(seconds: 30), (_) {
298-
// Without Span.noParent, this would inherit the (possibly ended) parent span
299-
// Use Span.noParent to start a fresh, independent trace
300-
Faro().startSpan('periodic-check', parentSpan: Span.noParent, (span) async {
301+
// Starts a NEW trace because parent was deactivated when callback ended
302+
Faro().startSpan('periodic-check', (span) async {
301303
await performHealthCheck();
302304
});
303305
});
304306
305-
await doMainWork();
306-
});
307+
await doMainWork(); // Takes less than 30 seconds
308+
}); // Parent span deactivated here
307309
```
308310

309-
The `parentSpan` parameter supports three states:
310-
- **Not provided / null**: Uses the active span from zone context (default behavior)
311-
- **`Span.noParent`**: Explicitly starts a new root trace with no parent
312-
- **Specific `Span` instance**: Uses that span as the parent
311+
#### Keeping Span Active for Timer Callbacks
312+
313+
If you want timer callbacks to inherit the parent span (same trace), use `ContextScope.zone`:
314+
315+
```dart
316+
await Faro().startSpan('long-running-operation', (parentSpan) async {
317+
// Timer callbacks WILL be children of this span
318+
Timer.periodic(Duration(seconds: 5), (_) {
319+
Faro().startSpan('progress-update', (span) async {
320+
await reportProgress(); // Same traceId as parent
321+
});
322+
});
323+
324+
await doWork();
325+
}, contextScope: ContextScope.zone); // Span stays active for zone lifetime
326+
```
327+
328+
#### Explicit New Trace with Span.noParent
329+
330+
For explicit control, use `Span.noParent` to start a fresh trace regardless of context:
331+
332+
```dart
333+
// Useful inside zone-scoped spans or manual spans
334+
Faro().startSpan('independent-operation', (span) async {
335+
await doSomething();
336+
}, parentSpan: Span.noParent); // Always starts new trace
337+
```
338+
339+
#### Summary
340+
341+
| Scenario | Result |
342+
| --------------------------------- | --------------------------------- |
343+
| Default (`ContextScope.callback`) | Timer callbacks → new trace |
344+
| `ContextScope.zone` | Timer callbacks → child of parent |
345+
| `parentSpan: Span.noParent` | Always new trace (explicit) |
346+
347+
The `parentSpan` parameter supports three values:
348+
349+
- **Not provided / null**: Uses active span from zone context
350+
- **`Span.noParent`**: Explicitly starts a new root trace
351+
- **Specific `Span` instance**: Uses that span as parent
313352

314353
### 🔄 Span Features
315354

example/lib/features/tracing/domain/tracing_service.dart

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:math';
23

34
import 'package:faro/faro.dart';
@@ -272,6 +273,106 @@ class TracingService {
272273
log('Error: $error', isError: true);
273274
}
274275
}
276+
277+
/// Demonstrates ContextScope for controlling span context lifetime.
278+
///
279+
/// Shows the difference between ContextScope.callback (default) and
280+
/// ContextScope.zone for timer/async operations.
281+
Future<void> runContextScopeDemo(LogCallback log) async {
282+
log('Starting ContextScope demo...');
283+
log('');
284+
log('ContextScope controls whether timer callbacks inherit the parent span.');
285+
log('');
286+
287+
try {
288+
// Demo 1: Default behavior (ContextScope.callback)
289+
log('--- Demo 1: ContextScope.callback (default) ---');
290+
log('Timer callbacks will NOT inherit the parent span.');
291+
292+
String? parentTraceId1;
293+
String? timerSpanTraceId1;
294+
final completer1 = Completer<void>();
295+
296+
await Faro().startSpan<void>(
297+
'parent-callback-scope',
298+
(parentSpan) async {
299+
parentTraceId1 = parentSpan.traceId;
300+
log('Parent span started (traceId: ${parentSpan.traceId.substring(0, 8)}...)');
301+
302+
// Schedule a timer that fires after parent callback ends
303+
Timer(const Duration(milliseconds: 100), () async {
304+
await Faro().startSpan<void>(
305+
'timer-child-callback',
306+
(timerSpan) async {
307+
timerSpanTraceId1 = timerSpan.traceId;
308+
log(' Timer span (traceId: ${timerSpan.traceId.substring(0, 8)}...)');
309+
},
310+
);
311+
completer1.complete();
312+
});
313+
314+
await Future.delayed(const Duration(milliseconds: 50));
315+
log('Parent callback ending...');
316+
},
317+
// contextScope: ContextScope.callback, // This is the default
318+
);
319+
320+
await completer1.future;
321+
if (timerSpanTraceId1 != parentTraceId1) {
322+
log('Result: Timer span has DIFFERENT traceId = new trace');
323+
} else {
324+
log('Result: Timer span has SAME traceId (unexpected)');
325+
}
326+
log('');
327+
328+
// Demo 2: Zone scope (ContextScope.zone)
329+
log('--- Demo 2: ContextScope.zone ---');
330+
log('Timer callbacks WILL inherit the parent span.');
331+
332+
String? parentTraceId;
333+
String? timerSpanTraceId2;
334+
final completer2 = Completer<void>();
335+
336+
await Faro().startSpan<void>(
337+
'parent-zone-scope',
338+
(parentSpan) async {
339+
parentTraceId = parentSpan.traceId;
340+
log('Parent span started (traceId: ${parentSpan.traceId.substring(0, 8)}...)');
341+
342+
// Schedule a timer that fires after parent callback ends
343+
Timer(const Duration(milliseconds: 100), () async {
344+
await Faro().startSpan<void>(
345+
'timer-child-zone',
346+
(timerSpan) async {
347+
timerSpanTraceId2 = timerSpan.traceId;
348+
log(' Timer span (traceId: ${timerSpan.traceId.substring(0, 8)}...)');
349+
},
350+
);
351+
completer2.complete();
352+
});
353+
354+
await Future.delayed(const Duration(milliseconds: 50));
355+
log('Parent callback ending...');
356+
},
357+
contextScope: ContextScope.zone, // Keep span active for timer
358+
);
359+
360+
await completer2.future;
361+
362+
if (timerSpanTraceId2 == parentTraceId) {
363+
log('Result: Timer span has SAME traceId = child of parent!');
364+
} else {
365+
log('Result: Timer span traceId differs (unexpected)');
366+
}
367+
368+
log('');
369+
log('ContextScope demo completed!');
370+
log('Use ContextScope.zone when you want timer/stream callbacks');
371+
log('to be children of the parent span.');
372+
} catch (error) {
373+
log('Error: $error', isError: true);
374+
}
375+
}
275376
}
276377

277378
// =============================================================================

example/lib/features/tracing/presentation/tracing_page.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ class _ButtonsSection extends StatelessWidget {
117117
onPressed: actions.runSpanWithNoParent,
118118
isRunning: uiState.isRunning,
119119
),
120+
_SpanButton(
121+
label: 'Context Scope',
122+
icon: Icons.timer,
123+
onPressed: actions.runContextScopeDemo,
124+
isRunning: uiState.isRunning,
125+
),
120126
],
121127
),
122128
],

example/lib/features/tracing/presentation/tracing_page_view_model.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ abstract interface class TracingPageActions {
7070

7171
/// Demonstrates Span.noParent for independent traces.
7272
Future<void> runSpanWithNoParent();
73+
74+
/// Demonstrates ContextScope for controlling span context lifetime.
75+
Future<void> runContextScopeDemo();
7376
}
7477

7578
// =============================================================================
@@ -183,6 +186,11 @@ class _TracingPageViewModel extends Notifier<TracingPageUiState>
183186
Future<void> runSpanWithNoParent() async {
184187
await _runSpanOperation(_tracingService.runSpanWithNoParent);
185188
}
189+
190+
@override
191+
Future<void> runContextScopeDemo() async {
192+
await _runSpanOperation(_tracingService.runContextScopeDemo);
193+
}
186194
}
187195

188196
// =============================================================================

0 commit comments

Comments
 (0)