Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,59 @@ This is the SDK for Flutter for [https://www.flagsmith.com/](https://www.flagsmi

For full documentation visit [https://docs.flagsmith.com/clients/flutter/](https://docs.flagsmith.com/clients/flutter/)

## Experiments

Once an experiment is running, Flagsmith serves the variations automatically through the flag. Your application
records exposures (when an identity experienced a variation) and conversion events (what your metrics aggregate).

Enable event collection with `enableEvents`. Users must be identified: exposures and conversion events are joined per
identity, so use the same identifier for flags and events.

```dart
final flagsmith = await FlagsmithClient.init(
apiKey: 'YOUR_CLIENT_SIDE_ENVIRONMENT_KEY',
config: const FlagsmithConfig(enableEvents: true),
);
final user = Identity(identifier: 'user_42');
await flagsmith.getFeatureFlags(user: user);

// Evaluate the flag and record an exposure in one call
final flag = await flagsmith.getExperimentFlag('checkout_button', user: user);
// ...render based on flag?.variant / flag?.stateValue

// Record a conversion event; the name must match your metric's event name
flagsmith.trackEvent('purchase', value: 99.5);
```

### Exposures

`getExperimentFlag` evaluates the flag and records a `$flag_exposure` event with the served `variant` as its value. It
is only recorded when the flag exists, is enabled and the identity is enrolled (`flag.experiment?.inExperiment == true`).
Anything else is logged and skipped, so it is safe to call against environments or servers without experiments.

If you evaluate in one place and render in another, call `trackExposureEvent` at the point of display instead:

```dart
final experiment = flag?.experiment;
if (flag != null && experiment != null) {
flagsmith.trackExposureEvent('checkout_button',
value: flag.variant, metadata: {'experiment_id': experiment.id});
}
```

Exposures are deduplicated per identity and variant within a flush window, so recording one more than once is safe.

### Conversion events

`trackEvent` sends a named event, optionally with a `value`, `traits` and `metadata`. Names starting with `$` are
reserved. The event name is case-sensitive and must match the metric's configured event name.

### Flushing

Events are buffered and posted to `eventsURI` every `eventsFlushInterval` ms (10 s) or when `eventsMaxBuffer` (1000)
events are queued. `close()` flushes best-effort; await `flushEvents()` when you need the POST to complete, e.g. before
the app is torn down. Network failures are retried once, then logged and dropped; they never throw.

## Contributing

Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests to us.
Expand Down
1 change: 1 addition & 0 deletions lib/src/core/core.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export 'crud_storage.dart';
export 'datetime_x.dart';
export 'events/event_processor.dart';
export 'exceptions.dart';
export 'extensions/converters.dart';
export 'model/index.dart';
Expand Down
188 changes: 188 additions & 0 deletions lib/src/core/events/event_processor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import 'dart:async';
import 'dart:convert';

import 'package:dio/dio.dart';

import '../../version.dart';

/// Batches experimentation events to `{eventsURI}v1/events`.
///
/// Flushes on a timer, at [maxBuffer], on [flush] and best-effort on [stop].
/// Exposures dedupe within a flush window; a failed POST retries once then drops.
class EventProcessor {
static const String flagExposureEvent = r'$flag_exposure';
static const String eventsPath = 'v1/events';
static const String sdkUserAgentHeader = 'Flagsmith-SDK-User-Agent';
static const String environmentKeyHeader = 'X-Environment-Key';
static const String contentType = 'application/json; charset=utf-8';

final Dio _api;
final String _apiKey;
final String endpoint;
final int flushInterval;
final int maxBuffer;
final int retryBackoff;
final void Function(String message) _log;

final List<Map<String, dynamic>> _buffer = [];
final Set<String> _dedupeKeys = {};
final Set<Future<void>> _inFlight = {};
Timer? _timer;

EventProcessor({
required Dio api,
required String apiKey,
required String eventsURI,
this.flushInterval = 10000,
this.maxBuffer = 1000,
this.retryBackoff = 1000,
void Function(String message)? log,
}) : _api = api,
_apiKey = apiKey,
_log = log ?? _noopLog,
endpoint =
'${eventsURI.endsWith('/') ? eventsURI : '$eventsURI/'}$eventsPath';

static void _noopLog(String _) {}

List<Map<String, dynamic>> get buffer => List.unmodifiable(_buffer);

void trackEvent({
required String event,
String? identifier,
Object? value,
Map<String, dynamic>? traits,
Map<String, dynamic>? metadata,
}) {
_bufferEvent(
event: event,
featureName: null,
identifier: identifier,
value: value,
traits: traits,
metadata: metadata,
dedupe: false,
);
}

void trackExposureEvent({
required String featureName,
required String identifier,
Object? value,
Map<String, dynamic>? traits,
Map<String, dynamic>? metadata,
}) {
_bufferEvent(
event: flagExposureEvent,
featureName: featureName,
identifier: identifier,
value: value,
traits: traits,
metadata: metadata,
dedupe: true,
);
}

void _bufferEvent({
required String event,
required String? featureName,
required String? identifier,
required Object? value,
required Map<String, dynamic>? traits,
required Map<String, dynamic>? metadata,
required bool dedupe,
}) {
final stringValue = value == null ? null : '$value';
if (dedupe) {
// Experiment id is part of the key so a new experiment on the same flag
// and variant within one flush window still records its own exposure.
final key = jsonEncode([
event,
featureName,
identifier,
stringValue,
metadata?['experiment_id']
]);
if (_dedupeKeys.contains(key)) {
return;
}
_dedupeKeys.add(key);
}
_buffer.add(<String, dynamic>{
'event': event,
'feature_name': featureName,
'identifier': identifier,
'value': stringValue,
'traits': traits,
'metadata': <String, dynamic>{
...?metadata,
'sdk_version': sdkVersion,
},
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
if (_buffer.length >= maxBuffer) {
unawaited(flush());
}
}

/// Posts the buffered events and waits for every upload still in flight,
/// including ones started by the timer or the max-buffer trigger, so that
/// awaiting it at teardown means the POSTs have completed. Never throws.
Future<void> flush() async {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major · ⚡ Quick win

flushEvents() can complete before an automatic upload finishes.

Observed: A max-buffer or timer flush clears _buffer before awaiting _postBatch; a following flushEvents() therefore sees an empty buffer and returns while that POST is still in flight.

Predicted: Code that awaits flushEvents() during teardown after an automatic flush could terminate before the event request completes, despite the documented completion guarantee. Track in-flight flush futures and make flush() await them as well as any newly buffered batch; add a delayed-request regression test for both max-buffer and timer flushes.

if (_buffer.isNotEmpty) {
final events = List<Map<String, dynamic>>.from(_buffer);
_buffer.clear();
_dedupeKeys.clear();
late final Future<void> upload;
upload =
_postBatch(events, 0).whenComplete(() => _inFlight.remove(upload));
_inFlight.add(upload);
}
await Future.wait(_inFlight.toList());
}

void start() {
_timer?.cancel();
_timer = null;
if (flushInterval > 0) {
_timer = Timer.periodic(
Duration(milliseconds: flushInterval), (_) => unawaited(flush()));
}
}

/// Cancels the timer and flushes without awaiting; await [flush] for teardown.
void stop() {
_timer?.cancel();
_timer = null;
unawaited(flush());
}

Future<void> _postBatch(
List<Map<String, dynamic>> events, int attempt) async {
try {
final response = await _api.post<dynamic>(
endpoint,
data: <String, dynamic>{'events': events},
options: Options(
contentType: contentType,
headers: <String, dynamic>{
environmentKeyHeader: _apiKey,
sdkUserAgentHeader: getUserAgent(),
},
),
);
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw StateError('unexpected status $status');
}
_log('Events: flush successful (${events.length} events)');
} catch (e) {
if (attempt < 1) {
_log('Events: flush failed, retrying: $e');
await Future<void>.delayed(Duration(milliseconds: retryBackoff));
return _postBatch(events, attempt + 1);
}
_log('Events: flush failed, dropping ${events.length} events: $e');
}
}
}
51 changes: 51 additions & 0 deletions lib/src/core/model/experiment.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:json_annotation/json_annotation.dart';

part 'experiment.g.dart';

/// The running experiment a flag was evaluated under; identity evaluations only.
@JsonSerializable()
class Experiment {
final int id;
final String name;

/// Whether the identity is enrolled. `variant` alone cannot tell.
@JsonKey(name: 'in_experiment', defaultValue: false)
final bool inExperiment;

const Experiment({
required this.id,
required this.name,
this.inExperiment = false,
});

factory Experiment.fromJson(Map<String, dynamic> json) =>
_$ExperimentFromJson(json);

Map<String, dynamic> toJson() => _$ExperimentToJson(this);

@override
String toString() => 'Experiment($id:$name, inExperiment=$inExperiment)';
}

/// `metadata.experiment` -> [Experiment]; null when absent or malformed.
Experiment? experimentFromMetadata(Object? metadata) {
if (metadata is! Map) {
return null;
}
final experiment = metadata['experiment'];
if (experiment is! Map) {
return null;
}
try {
return Experiment.fromJson(Map<String, dynamic>.from(experiment));
} catch (_) {
return null;
}
}

Map<String, dynamic>? experimentToMetadata(Experiment? experiment) {
if (experiment == null) {
return null;
}
return <String, dynamic>{'experiment': experiment.toJson()};
}
22 changes: 22 additions & 0 deletions lib/src/core/model/experiment.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading