Skip to content

Commit 97a55f3

Browse files
feat(sampling): add head-based session sampling support (#139)
## Description Add head-based session sampling support to control what percentage of sessions send telemetry data. The sampling decision is made once at SDK initialization and remains consistent for the entire session. When a session is not sampled, all telemetry is silently dropped with zero processing overhead using the Null Object Pattern. This aligns with Faro Web SDK sampling behavior. ## Related Issue(s) Fixes #89 ## 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) - [x] 📝 Documentation - [ ] 📈 Performance improvement - [x] 🏗️ 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 ## Screenshots (if applicable) N/A ## Additional Notes **Key changes:** - **FaroConfig**: Added `samplingRate` parameter (0.0 to 1.0, default 1.0) - **SessionSamplingProvider**: Handles one-time sampling decision at init - **RandomValueProvider**: Injectable randomness for testable sampling logic - **NoOpBatchTransport**: Null object pattern for unsampled sessions (zero overhead) - **BatchTransport**: Refactored with private fields, fixed timer cancellation bug **Testing:** - Unit tests for SessionSamplingProvider, RandomValueProvider, FaroConfig - Unit tests for BatchTransport and NoOpBatchTransport - Integration tests verifying end-to-end sampling behavior - Manual testing with example app at different samplingRate values <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core telemetry initialization and transport selection, so mis-sampling or factory singleton behavior could unintentionally drop data across a session. Coverage is strong, but the new no-op path and init-time decision affect all telemetry types. > > **Overview** > Adds head-based **session sampling** via new `FaroConfig.samplingRate` (0.0–1.0, default 1.0), making a one-time sampling decision at SDK init and routing all telemetry through a `NoOpBatchTransport` when unsampled (plus a debug log explaining dropped telemetry). > > Refactors `BatchTransport` internals (private fields/timer handling) and updates `BatchTransportFactory` to select real vs no-op transport based on `isSampled`, backed by new `SessionSamplingProvider` and injectable `RandomValueProvider` for deterministic testing; updates docs/CHANGELOG and adds unit + integration test coverage for sampling and transport behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9e1f88a. 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 4141e53 commit 97a55f3

15 files changed

+1257
-185
lines changed

CHANGELOG.md

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

1010
### Added
1111

12+
- **Session sampling support**: New `samplingRate` configuration option allows controlling what percentage of sessions send telemetry data. This enables cost management and traffic reduction for high-volume applications. (Resolves #89)
13+
14+
- `samplingRate` accepts a value from `0.0` (no sessions sampled) to `1.0` (all sessions sampled, default)
15+
- Sampling decision is made once per session at initialization and applies to all telemetry types (events, logs, exceptions, measurements, traces)
16+
- A debug log is emitted when a session is not sampled (visible in debug builds only)
17+
- Aligns with [Faro Web SDK sampling behavior](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/sampling/)
18+
1219
- **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)
1320

1421
- **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)

doc/Configurations.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,42 @@ Faro().runApp(
499499
- **Faro session** (`meta.session.attributes`): Values are stringified per Faro protocol requirements
500500
- **Span resources** (`resource.attributes`): Types are preserved (int, double, bool, String), enabling numeric queries and filtering in trace backends
501501

502+
### Session Sampling
503+
504+
Control what percentage of sessions send telemetry data. This is useful for managing costs and reducing traffic for high-volume applications.
505+
506+
```dart
507+
Faro().runApp(
508+
optionsConfiguration: FaroConfig(
509+
// ...
510+
samplingRate: 0.5, // Sample 50% of sessions (default: 1.0 = 100%)
511+
// ...
512+
),
513+
appRunner: () => runApp(const MyApp()),
514+
);
515+
```
516+
517+
**How it works:**
518+
519+
- The `samplingRate` value ranges from `0.0` (no sessions sampled) to `1.0` (all sessions sampled)
520+
- The sampling decision is made once per session at initialization time
521+
- When a session is not sampled, all telemetry (events, logs, exceptions, measurements, traces) is silently dropped
522+
- A debug log is emitted when a session is not sampled, for transparency during development
523+
524+
**Example values:**
525+
526+
| samplingRate | Behavior |
527+
| ------------ | ---------------------------------------------- |
528+
| `1.0` | All sessions sampled (default - send all data) |
529+
| `0.5` | 50% of sessions sampled |
530+
| `0.1` | 10% of sessions sampled |
531+
| `0.0` | No sessions sampled (no data sent) |
532+
533+
**Notes:**
534+
535+
- Sampling is head-based: the decision is made at SDK initialization and remains consistent for the entire session
536+
- This aligns with [Faro Web SDK sampling behavior](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/sampling/)
537+
502538
### Data Collection Control
503539

504540
Faro provides the ability to enable or disable data collection at runtime. This setting is automatically persisted across app restarts, so you don't need to set it every time your app starts.

example/lib/main.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ void main() async {
4141
appEnv: 'Test',
4242
apiKey: faroApiKey,
4343
namespace: 'flutter_app',
44+
samplingRate: 1.0,
4445
anrTracking: true,
4546
cpuUsageVitals: true,
4647
collectorUrl: faroCollectorUrl,

lib/src/configurations/faro_config.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ class FaroConfig {
2525
this.sessionAttributes,
2626
this.initialUser,
2727
this.persistUser = true,
28+
this.samplingRate = 1.0,
2829
}) : assert(appName.isNotEmpty, 'appName cannot be empty'),
2930
assert(appEnv.isNotEmpty, 'appEnv cannot be empty'),
3031
assert(apiKey.isNotEmpty, 'apiKey cannot be empty'),
3132
assert(maxBufferLimit > 0, 'maxBufferLimit must be greater than 0'),
33+
assert(
34+
samplingRate >= 0.0 && samplingRate <= 1.0,
35+
'samplingRate must be between 0.0 and 1.0',
36+
),
3237
batchConfig = batchConfig ?? BatchConfig();
3338
final String appName;
3439
final String appEnv;
@@ -76,4 +81,18 @@ class FaroConfig {
7681
///
7782
/// Set to `false` to disable user persistence.
7883
final bool persistUser;
84+
85+
/// Session sampling rate (0.0 to 1.0, default: 1.0 = 100%).
86+
///
87+
/// Controls the probability that a session will be sampled. When a session
88+
/// is not sampled, no telemetry (events, logs, exceptions, measurements,
89+
/// traces) is sent for that session.
90+
///
91+
/// Examples:
92+
/// - `1.0` (default): 100% of sessions are sampled (all telemetry sent)
93+
/// - `0.5`: 50% of sessions are sampled
94+
/// - `0.0`: 0% of sessions are sampled (no telemetry sent)
95+
///
96+
/// The sampling decision is made once per session at initialization time.
97+
final double samplingRate;
7998
}

lib/src/faro.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:faro/src/integrations/on_error_integration.dart';
1616
import 'package:faro/src/models/models.dart';
1717
import 'package:faro/src/native_platform_interaction/faro_native_methods.dart';
1818
import 'package:faro/src/session/session_id_provider.dart';
19+
import 'package:faro/src/session/session_sampling_provider.dart';
1920
import 'package:faro/src/tracing/faro_tracer.dart';
2021
import 'package:faro/src/tracing/span.dart';
2122
import 'package:faro/src/transport/batch_transport.dart';
@@ -129,10 +130,23 @@ class Faro {
129130
persistUser: optionsConfiguration.persistUser,
130131
);
131132

133+
// Make sampling decision (once per session)
134+
final isSampled = SessionSamplingProviderFactory()
135+
.create(samplingRate: optionsConfiguration.samplingRate)
136+
.isSampled;
137+
138+
if (!isSampled) {
139+
log(
140+
'Faro: Session not sampled (samplingRate: '
141+
'${optionsConfiguration.samplingRate}). Telemetry will be dropped.',
142+
);
143+
}
144+
132145
_batchTransport = BatchTransportFactory().create(
133146
initialPayload: Payload(meta),
134147
batchConfig: config?.batchConfig ?? BatchConfig(),
135148
transports: _transports,
149+
isSampled: isSampled,
136150
);
137151

138152
if (config?.transports == null) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'package:faro/src/util/random_value_provider.dart';
2+
import 'package:flutter/foundation.dart';
3+
4+
/// Determines if a session should be sampled.
5+
///
6+
/// The sampling decision is made once at construction time and is immutable.
7+
/// This ensures consistent behavior throughout the session lifecycle.
8+
class SessionSamplingProvider {
9+
/// Creates a sampling provider with the given [samplingRate].
10+
///
11+
/// The [samplingRate] is clamped to the valid range [0.0, 1.0] to ensure
12+
/// safe behavior even if an invalid value is provided in production builds
13+
/// (where assert statements are not checked).
14+
SessionSamplingProvider({
15+
required double samplingRate,
16+
required RandomValueProvider randomValueProvider,
17+
}) : isSampled =
18+
randomValueProvider.nextDouble() < samplingRate.clamp(0.0, 1.0);
19+
20+
/// Whether this session is sampled.
21+
///
22+
/// When `true`, telemetry data will be sent for this session.
23+
/// When `false`, telemetry data will be dropped.
24+
final bool isSampled;
25+
}
26+
27+
/// Factory for creating [SessionSamplingProvider] instances.
28+
///
29+
/// Uses singleton pattern to ensure the sampling decision is made only once
30+
/// per session and remains consistent.
31+
class SessionSamplingProviderFactory {
32+
static SessionSamplingProvider? _instance;
33+
34+
/// Creates or returns the singleton [SessionSamplingProvider] instance.
35+
///
36+
/// The [samplingRate] is only used when creating the first instance.
37+
/// Subsequent calls return the cached instance regardless of the provided
38+
/// [samplingRate].
39+
///
40+
/// If [randomValueProvider] is not provided, uses the default from
41+
/// [RandomValueProviderFactory].
42+
SessionSamplingProvider create({
43+
required double samplingRate,
44+
RandomValueProvider? randomValueProvider,
45+
}) {
46+
_instance ??= SessionSamplingProvider(
47+
samplingRate: samplingRate,
48+
randomValueProvider:
49+
randomValueProvider ?? RandomValueProviderFactory().create(),
50+
);
51+
return _instance!;
52+
}
53+
54+
/// Resets the singleton instance. Primarily for testing purposes.
55+
@visibleForTesting
56+
void reset() => _instance = null;
57+
}

lib/src/transport/batch_transport.dart

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,58 +6,63 @@ import 'package:faro/src/configurations/batch_config.dart';
66
import 'package:faro/src/models/models.dart';
77
import 'package:faro/src/models/span_record.dart';
88
import 'package:faro/src/transport/faro_base_transport.dart';
9+
import 'package:faro/src/transport/no_op_batch_transport.dart';
910
import 'package:faro/src/util/payload_extension.dart';
1011
import 'package:flutter/foundation.dart';
1112

13+
/// Transport that batches telemetry data and sends it periodically.
1214
class BatchTransport {
13-
BatchTransport(
14-
{required this.payload,
15-
required this.batchConfig,
16-
required this.transports}) {
17-
if (batchConfig.enabled) {
18-
Timer.periodic(batchConfig.sendTimeout, (t) {
19-
flushTimer = t;
20-
flush(payload);
15+
BatchTransport({
16+
required Payload payload,
17+
required BatchConfig batchConfig,
18+
required List<BaseTransport> transports,
19+
}) : _payload = payload,
20+
_batchConfig = batchConfig,
21+
_transports = transports {
22+
if (_batchConfig.enabled) {
23+
_flushTimer = Timer.periodic(_batchConfig.sendTimeout, (_) {
24+
flush(_payload);
2125
resetPayload();
2226
});
2327
} else {
24-
batchConfig.payloadItemLimit = 1;
28+
_batchConfig.payloadItemLimit = 1;
2529
}
2630
}
27-
Payload payload;
28-
BatchConfig batchConfig;
29-
List<BaseTransport> transports;
30-
Timer? flushTimer;
31+
32+
final Payload _payload;
33+
final BatchConfig _batchConfig;
34+
final List<BaseTransport> _transports;
35+
Timer? _flushTimer;
3136

3237
void addEvent(Event event) {
33-
payload.events.add(event);
38+
_payload.events.add(event);
3439
checkPayloadItemLimit();
3540
}
3641

3742
void addMeasurement(Measurement measurement) {
38-
payload.measurements.add(measurement);
43+
_payload.measurements.add(measurement);
3944
checkPayloadItemLimit();
4045
}
4146

4247
void addLog(FaroLog faroLog) {
43-
payload.logs.add(faroLog);
48+
_payload.logs.add(faroLog);
4449
checkPayloadItemLimit();
4550
}
4651

4752
void addSpan(SpanRecord spanRecord) {
48-
payload.traces.addSpan(spanRecord);
53+
_payload.traces.addSpan(spanRecord);
4954
checkPayloadItemLimit();
5055
}
5156

5257
void addExceptions(FaroException exception) {
53-
payload.exceptions.add(exception);
58+
_payload.exceptions.add(exception);
5459
checkPayloadItemLimit();
5560
}
5661

5762
void updatePayloadMeta(Meta meta) {
58-
flush(payload);
63+
flush(_payload);
5964
resetPayload();
60-
payload.meta = meta;
65+
_payload.meta = meta;
6166
}
6267

6368
Future<void> flush(Payload payload) async {
@@ -69,43 +74,43 @@ class BatchTransport {
6974
return;
7075
}
7176

72-
if (transports.isNotEmpty) {
73-
final currentTransports = transports;
77+
if (_transports.isNotEmpty) {
78+
final currentTransports = _transports;
7479
for (final transport in currentTransports) {
7580
await transport.send(payloadJson);
7681
}
7782
}
7883
}
7984

8085
void checkPayloadItemLimit() {
81-
if (payloadSize() >= batchConfig.payloadItemLimit) {
82-
flush(payload);
86+
if (payloadSize() >= _batchConfig.payloadItemLimit) {
87+
flush(_payload);
8388
resetPayload();
8489
}
8590
}
8691

8792
void dispose() {
88-
flushTimer?.cancel();
93+
_flushTimer?.cancel();
8994
}
9095

9196
bool isPayloadEmpty() {
92-
return payload.isEmpty();
97+
return _payload.isEmpty();
9398
}
9499

95100
int payloadSize() {
96-
return payload.logs.length +
97-
payload.measurements.length +
98-
payload.events.length +
99-
payload.exceptions.length +
100-
payload.traces.numberSpans();
101+
return _payload.logs.length +
102+
_payload.measurements.length +
103+
_payload.events.length +
104+
_payload.exceptions.length +
105+
_payload.traces.numberSpans();
101106
}
102107

103108
void resetPayload() {
104-
payload.events = [];
105-
payload.measurements = [];
106-
payload.logs = [];
107-
payload.exceptions = [];
108-
payload.traces.resetSpans();
109+
_payload.events = [];
110+
_payload.measurements = [];
111+
_payload.logs = [];
112+
_payload.exceptions = [];
113+
_payload.traces.resetSpans();
109114
}
110115
}
111116

@@ -118,15 +123,22 @@ class BatchTransportFactory {
118123
required Payload initialPayload,
119124
required BatchConfig batchConfig,
120125
required List<BaseTransport> transports,
126+
required bool isSampled,
121127
}) {
122128
if (_instance != null) {
123129
return _instance!;
124130
}
125131

126-
final instance = BatchTransport(
132+
final BatchTransport instance;
133+
if (isSampled) {
134+
instance = BatchTransport(
127135
payload: initialPayload,
128136
batchConfig: batchConfig,
129-
transports: transports);
137+
transports: transports,
138+
);
139+
} else {
140+
instance = NoOpBatchTransport();
141+
}
130142

131143
_instance = instance;
132144
return instance;

0 commit comments

Comments
 (0)