Skip to content

Commit 2e6aa40

Browse files
feat(sampling): add custom sampling function support (#141)
## Description Add `SamplingFunction` for dynamic, context-aware session sampling decisions. Users can now implement custom sampling logic based on user attributes, app environment, or other session metadata. This extends the existing fixed-rate sampling (#139) with a function-based approach, allowing use cases like: - Sample all beta testers at 100% while sampling regular users at 10% - Different sampling rates per environment - A/B testing with different sampling for user segments - Sampling based on custom session attributes ## 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 - [ ] 🏗️ 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 ## Changes ### SDK Core - Add `Sampling` sealed class with `SamplingRate` and `SamplingFunction` variants - Add `SamplingContext` exposing session metadata (user, app, session attributes) to sampling functions - Update `FaroConfig` to accept optional `sampling` parameter - Update `SessionSamplingProvider` to evaluate sampling functions with context ### Documentation - Add comprehensive "Session Sampling" section to `Configurations.md` - Include examples for fixed rate, dynamic sampling, and available context properties ### Example App - Add sampling settings feature to test different sampling configurations - Follows established Riverpod ViewModel pattern from `features/tracing/` ### Tests - Unit tests for `Sampling`, `SamplingRate`, `SamplingFunction` - Unit tests for `SamplingContext` - Integration tests for end-to-end sampling behavior with functions ## Additional Notes This aligns with [Faro Web SDK sampling behavior](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/sampling/) where sampling decisions are head-based (made once at SDK init) and apply to all telemetry types. Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new public configuration surface for sampling and changes SDK initialization order (app meta set before sampling) which can affect telemetry collection behavior across sessions; covered by expanded unit/integration tests but impacts core data-drop logic. > > **Overview** > Adds a new `sampling` API to replace `samplingRate`, introducing a sealed `Sampling` abstraction with `SamplingRate` (fixed) and `SamplingFunction` (context-aware) plus a `SamplingContext` exposing session/user/app meta for dynamic decisions (values clamped to 0.0–1.0). > > Updates SDK init to compute sampling once per session using the new config, exposes `Faro().isSampled`, and ensures app metadata is set before sampling so functions can depend on it. Documentation, changelog, and tests are updated accordingly, and the example app gains a new sampling settings screen (persisted via SharedPreferences/Riverpod) to demo switching sampling strategies. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2bcc739. 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 aadc221 commit 2e6aa40

24 files changed

+1684
-182
lines changed

CHANGELOG.md

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ 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)
12+
- **Session sampling support**: New `sampling` 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+
- Use `SamplingRate(0.5)` for fixed 50% sampling
14+
- Use `SamplingFunction((context) => ...)` for dynamic sampling based on session context (user attributes, app environment, etc.)
15+
- If not provided, all sessions are sampled (100%)
1516
- 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+
- Example: `sampling: SamplingFunction((context) => context.meta.user?.attributes?['role'] == 'beta' ? 1.0 : 0.1)`
1718
- Aligns with [Faro Web SDK sampling behavior](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/sampling/)
1819

1920
- **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)
@@ -43,21 +44,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4344
### Added
4445

4546
- **User management with FaroUser model**: New `FaroUser` class for comprehensive user identification
46-
4747
- Replaces the legacy `User` model with a more feature-rich implementation
4848
- Supports `id`, `username`, `email`, and custom `attributes` fields
4949
- 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
5050
- Includes `FaroUser.cleared()` constructor to explicitly clear user data
5151

5252
- **User persistence**: New `persistUser` option in `FaroConfig` (default: `true`)
53-
5453
- Automatically saves user identity to device storage
5554
- Restores user on subsequent app launches for consistent session tracking
5655
- Early events like `appStart` include user data when persistence is enabled
5756
- Fires `user_set` event on restore and `user_updated` event on changes
5857

5958
- **Initial user configuration**: New `initialUser` option in `FaroConfig`
60-
6159
- Set a user immediately on SDK initialization
6260
- Use `FaroUser.cleared()` to explicitly clear any persisted user on start
6361
- Useful for apps that know the user at startup or need to force logout state
@@ -129,7 +127,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
129127
### Fixed
130128

131129
- **SDK name consistency across telemetry types**: Updated SDK identification to use consistent naming
132-
133130
- Changed hardcoded 'rum-flutter' SDK name to use `FaroConstants.sdkName` for consistency with OpenTelemetry traces
134131
- Maintains backend-compatible version '1.3.5' for proper web SDK version validation
135132
- Added actual Faro Flutter SDK version to session attributes as 'faro_sdk_version' for tracking real SDK version
@@ -146,9 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
146143
### Changed
147144

148145
- **BREAKING: Package structure refactoring to follow Flutter plugin conventions**: Reorganized the package to align with Flutter/Dart ecosystem standards and best practices
149-
150146
- **Breaking Change**: Main entry point changed from `faro_sdk.dart` to `faro.dart`
151-
152147
- The package now follows the standard `lib/<package_name>.dart` convention
153148
- Removed `lib/faro_sdk.dart` file entirely
154149
- `lib/faro.dart` is now the single main entry point with selective barrel exports
@@ -164,15 +159,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
164159
```
165160
166161
- **Architecture Improvements**:
167-
168162
- Moved core `Faro` class implementation from `lib/faro.dart` to `lib/src/faro.dart`
169163
- `lib/faro.dart` now serves as a clean barrel export file exposing only public APIs
170164
- All implementation details properly organized under `lib/src/` directory
171165
- Clear separation between public API surface and private implementation
172166
- Follows established Flutter ecosystem conventions used by popular packages like Provider, BLoC, and Dio
173167
174168
- **Benefits**:
175-
176169
- **Cleaner API boundaries**: Clear distinction between public and private APIs
177170
- **Better maintainability**: Implementation details can evolve without affecting public interface
178171
- **Consistent developer experience**: Matches patterns developers expect from other Flutter packages
@@ -184,13 +177,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
184177
### Added
185178
186179
- **Type-Safe Log Level API**: New `LogLevel` enum for improved logging reliability and developer experience
187-
188180
- Introduced `LogLevel` enum with values: `trace`, `debug`, `info`, `log`, `warn`, `error`
189181
- Aligns with Grafana Faro Web SDK for cross-platform consistency
190182
- Includes `fromString()` method for backward compatibility, supporting both `'warn'` and `'warning'` variants
191183
192184
- **Enhanced Tracing and Span API**: Major improvements to distributed tracing capabilities
193-
194185
- New `startSpan<T>()` method for automatic span lifecycle management with callback-based execution
195186
- New `startSpanManual()` method for manual span lifecycle management when precise control is needed
196187
- New `getActiveSpan()` method to access the currently active span from anywhere in the execution context
@@ -202,18 +193,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
202193
- Comprehensive documentation with detailed examples for common tracing patterns
203194
204195
- **Centralized Session Management**: New `SessionIdProvider` for consistent session handling across the SDK
205-
206196
- Dedicated session ID generation and management
207197
- Better integration with tracing system for session context propagation
208198
- Factory pattern for testable session management
209199
210200
- **SDK Constants Management**: New centralized constants system
211-
212201
- Added `FaroConstants` class for SDK version and name management
213202
- Better version tracking and consistency across the codebase
214203
215204
- **BREAKING: Synchronous API for telemetry methods**: Refactored telemetry methods to remove unnecessary async patterns for improved performance and developer experience
216-
217205
- **Breaking Change**: The following methods changed from `Future<void>?` to `void`:
218206
- `pushEvent()` - Send custom events
219207
- `pushLog()` - Send custom logs
@@ -239,15 +227,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
239227
- **Internal Architecture**: Introduced `BatchTransportFactory` singleton pattern for better dependency management and testing
240228
241229
- **BREAKING: pushLog API requires LogLevel enum**: Enhanced logging API for better type safety and consistency
242-
243230
- **Breaking Change**: `pushLog()` now requires a `LogLevel` parameter instead of optional `String?`
244231
- **Migration**: Replace `level: "warn"` with `level: LogLevel.warn` in your pushLog calls
245232
- **Benefit**: Eliminates typos in log levels and provides better IDE support
246233
- **Compatibility**: Existing string-based log levels in internal code updated to use LogLevel enum
247234
- **Documentation**: All examples and documentation updated to reflect the new API
248235
249236
- **Tracing Architecture Refactoring**: Complete redesign of the internal tracing system
250-
251237
- Replaced legacy `tracer.dart` and `tracer_provider.dart` with new `FaroTracer` implementation
252238
- New `FaroZoneSpanManager` for robust zone-based span context management
253239
- Improved `Span` class with cleaner API and better OpenTelemetry integration

doc/Configurations.md

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -503,38 +503,91 @@ Faro().runApp(
503503

504504
Control what percentage of sessions send telemetry data. This is useful for managing costs and reducing traffic for high-volume applications.
505505

506+
#### Fixed Sampling Rate
507+
508+
Use `SamplingRate` for a constant sampling probability:
509+
506510
```dart
507511
Faro().runApp(
508512
optionsConfiguration: FaroConfig(
509513
// ...
510-
samplingRate: 0.5, // Sample 50% of sessions (default: 1.0 = 100%)
514+
sampling: SamplingRate(0.5), // Sample 50% of sessions (default: 100% if omitted)
511515
// ...
512516
),
513517
appRunner: () => runApp(const MyApp()),
514518
);
515519
```
516520

521+
**Example values:**
522+
523+
| Sampling | Behavior |
524+
| ------------------- | ---------------------------------------------- |
525+
| Not provided | All sessions sampled (default - send all data) |
526+
| `SamplingRate(1.0)` | All sessions sampled (100%) |
527+
| `SamplingRate(0.5)` | 50% of sessions sampled |
528+
| `SamplingRate(0.1)` | 10% of sessions sampled |
529+
| `SamplingRate(0.0)` | No sessions sampled (no data sent) |
530+
531+
#### Dynamic Sampling
532+
533+
Use `SamplingFunction` for dynamic sampling decisions based on session context such as user attributes, app environment, or session metadata:
534+
535+
```dart
536+
Faro().runApp(
537+
optionsConfiguration: FaroConfig(
538+
appName: 'MyApp',
539+
appEnv: 'production',
540+
apiKey: 'xxx',
541+
collectorUrl: 'https://...',
542+
sampling: SamplingFunction((context) {
543+
// Sample all beta users
544+
if (context.meta.user?.attributes?['role'] == 'beta') {
545+
return 1.0;
546+
}
547+
// Sample 10% of production sessions
548+
if (context.meta.app?.environment == 'production') {
549+
return 0.1;
550+
}
551+
// Sample all in development
552+
return 1.0;
553+
}),
554+
),
555+
appRunner: () => runApp(const MyApp()),
556+
);
557+
```
558+
517559
**How it works:**
518560

519-
- The `samplingRate` value ranges from `0.0` (no sessions sampled) to `1.0` (all sessions sampled)
520561
- The sampling decision is made once per session at initialization time
521562
- When a session is not sampled, all telemetry (events, logs, exceptions, measurements, traces) is silently dropped
522563
- A debug log is emitted when a session is not sampled, for transparency during development
564+
- Invalid return values (< 0.0 or > 1.0) are clamped to the valid range
523565

524-
**Example values:**
566+
**Available context:**
525567

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) |
568+
| Property | Access Pattern | Description |
569+
| ------------------ | ---------------------------------- | ---------------------------------------- |
570+
| Session ID | `context.meta.session?.id` | Unique session identifier |
571+
| Session attributes | `context.meta.session?.attributes` | Custom sessionAttributes from config |
572+
| User ID | `context.meta.user?.id` | User ID (from initialUser or persisted) |
573+
| User attributes | `context.meta.user?.attributes` | Custom user attributes |
574+
| App name | `context.meta.app?.name` | App name from config |
575+
| App environment | `context.meta.app?.environment` | App environment from config |
576+
| App version | `context.meta.app?.version` | App version (from config or PackageInfo) |
577+
| SDK version | `context.meta.sdk?.version` | Faro SDK version |
532578

533579
**Notes:**
534580

535581
- Sampling is head-based: the decision is made at SDK initialization and remains consistent for the entire session
536582
- This aligns with [Faro Web SDK sampling behavior](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/sampling/)
537583

584+
**Use cases:**
585+
586+
- Sample all beta testers while sampling only 10% of regular users
587+
- Different sampling rates per environment (100% in dev, 10% in production)
588+
- A/B testing with different sampling for different user segments
589+
- Sampling based on custom session attributes (team, feature flags, etc.)
590+
538591
### Data Collection Control
539592

540593
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.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import 'package:faro/faro.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:shared_preferences/shared_preferences.dart';
4+
5+
import '../models/sampling_setting.dart';
6+
7+
/// Service for managing sampling settings.
8+
///
9+
/// Handles loading, saving, and querying sampling configuration
10+
/// that is passed to FaroConfig on app startup.
11+
class SamplingSettingsService {
12+
SamplingSettingsService({required SharedPreferences prefs}) : _prefs = prefs;
13+
14+
final SharedPreferences _prefs;
15+
16+
static const String _samplingSettingKey = 'faro_sampling_setting';
17+
18+
// ===========================================================================
19+
// Sampling Setting
20+
// ===========================================================================
21+
22+
/// Gets the current sampling setting.
23+
SamplingSetting get samplingSetting {
24+
final storedName = _prefs.getString(_samplingSettingKey);
25+
if (storedName == null) {
26+
return SamplingSetting.all;
27+
}
28+
return SamplingSetting.values.firstWhere(
29+
(e) => e.name == storedName,
30+
orElse: () => SamplingSetting.all,
31+
);
32+
}
33+
34+
/// Gets the [Sampling] configuration for the current setting.
35+
Sampling? get sampling => samplingSetting.sampling;
36+
37+
/// Sets the sampling setting.
38+
Future<void> setSamplingSetting(SamplingSetting setting) async {
39+
if (setting == SamplingSetting.all) {
40+
await _prefs.remove(_samplingSettingKey);
41+
} else {
42+
await _prefs.setString(_samplingSettingKey, setting.name);
43+
}
44+
}
45+
46+
// ===========================================================================
47+
// Current Session Info
48+
// ===========================================================================
49+
50+
/// Gets whether the current session is sampled.
51+
bool get isSessionSampled => Faro().isSampled;
52+
53+
/// Gets a display string for the current sampling config.
54+
String getCurrentSamplingDisplay() {
55+
final config = Faro().config;
56+
if (config == null) return 'Not initialized';
57+
58+
final sampling = config.sampling;
59+
if (sampling == null) {
60+
return '100% (default)';
61+
}
62+
if (sampling is SamplingRate) {
63+
final percent = (sampling.rate * 100).toStringAsFixed(0);
64+
return 'SamplingRate($percent%)';
65+
}
66+
if (sampling is SamplingFunction) {
67+
return 'SamplingFunction';
68+
}
69+
return 'Unknown';
70+
}
71+
72+
/// Gets the current session's sampling setting.
73+
///
74+
/// This attempts to match the current config's sampling to a known setting.
75+
SamplingSetting? get currentSessionSamplingSetting {
76+
final config = Faro().config;
77+
if (config == null) return null;
78+
79+
final currentSampling = config.sampling;
80+
81+
// Match against known settings
82+
for (final setting in SamplingSetting.values) {
83+
if (_samplingMatches(currentSampling, setting)) {
84+
return setting;
85+
}
86+
}
87+
88+
return null; // Unknown/custom sampling
89+
}
90+
91+
/// Checks if the current config sampling matches a setting.
92+
bool _samplingMatches(Sampling? sampling, SamplingSetting setting) {
93+
if (sampling == null && setting == SamplingSetting.all) {
94+
return true;
95+
}
96+
if (sampling is SamplingRate) {
97+
switch (setting) {
98+
case SamplingSetting.none:
99+
return sampling.rate == 0.0;
100+
case SamplingSetting.half:
101+
return sampling.rate == 0.5;
102+
case SamplingSetting.tenPercent:
103+
return sampling.rate == 0.1;
104+
default:
105+
return false;
106+
}
107+
}
108+
// For SamplingFunction, we can't easily compare, so check by setting type
109+
if (sampling is SamplingFunction) {
110+
return setting.isFunction;
111+
}
112+
return false;
113+
}
114+
115+
/// Returns true if the saved setting differs from current session.
116+
bool get needsRestart {
117+
final current = currentSessionSamplingSetting;
118+
if (current == null) return true; // Unknown config, restart to apply known
119+
return samplingSetting != current;
120+
}
121+
}
122+
123+
// =============================================================================
124+
// Provider
125+
// =============================================================================
126+
127+
/// Provider for SharedPreferences instance.
128+
///
129+
/// Must be overridden with the actual instance at app startup.
130+
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
131+
throw UnimplementedError(
132+
'sharedPreferencesProvider must be overridden with the actual instance',
133+
);
134+
});
135+
136+
/// Provider for the sampling settings service.
137+
final samplingSettingsServiceProvider = Provider<SamplingSettingsService>(
138+
(ref) {
139+
final prefs = ref.watch(sharedPreferencesProvider);
140+
return SamplingSettingsService(prefs: prefs);
141+
},
142+
);

0 commit comments

Comments
 (0)