diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index cd80000..608fcf1 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.5.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0 with: - spec-ref: v0.5.0 + spec-ref: v0.7.0 language: dart diff --git a/lib/src/flagsmith_client.dart b/lib/src/flagsmith_client.dart index 84bbe49..c8e67dd 100644 --- a/lib/src/flagsmith_client.dart +++ b/lib/src/flagsmith_client.dart @@ -98,8 +98,9 @@ class FlagsmithClient { Future _setupAnalyticsTimer(int analyticsInterval) async { _analyticsTimer?.cancel(); - _analyticsTimer = Timer.periodic( - Duration(milliseconds: analyticsInterval), (_) => syncAnalyticsData()); + // Failures are logged, and the analytics are resent on the next tick. + _analyticsTimer = Timer.periodic(Duration(milliseconds: analyticsInterval), + (_) => syncAnalyticsData().ignore()); } Future?> syncAnalyticsData() async { @@ -379,8 +380,9 @@ class FlagsmithClient { /// EXPERIMENTS /// /// Resolve a flag for [user] (or [cachedUser]) and fire one `$flag_exposure` - /// event with the variant as value. Skipped unless events are enabled, the - /// flag is enabled and `flag.experiment.inExperiment` is true. + /// event with the variant as value and [traits] attached. Skipped unless + /// events are enabled, the flag is enabled and + /// `flag.experiment.inExperiment` is true. /// /// Fetches only when storage holds another identity's flags; /// [reload] overrides this. @@ -426,6 +428,9 @@ class FlagsmithClient { trackExposureEvent(featureName, user: identity, value: flag.variant, + traits: traits == null || traits.isEmpty + ? null + : {for (final trait in traits) trait.key: trait.value}, metadata: {'experiment_id': experiment.id}); return flag; } diff --git a/sdk-spec/steps.dart b/sdk-spec/steps.dart index 5605845..e4d3600 100644 --- a/sdk-spec/steps.dart +++ b/sdk-spec/steps.dart @@ -8,6 +8,18 @@ Future createClient(String environmentKey, String apiUrl) => config: FlagsmithConfig(baseURI: apiUrl), ); +// Create the SDK client with events enabled +Future createClientWithEvents( + String environmentKey, String apiUrl, String eventsApiUrl) => + FlagsmithClient.init( + apiKey: environmentKey, + config: FlagsmithConfig( + baseURI: apiUrl, + enableEvents: true, + eventsURI: eventsApiUrl, + ), + ); + // When the application gets the environment flags Future>> getEnvironmentFlags( FlagsmithClient flagsmith) async => @@ -24,6 +36,40 @@ Future>> getIdentityFlags(FlagsmithClient flagsmith, ], )); +// When the application gets the experiment flag "{feature}" for identity "{identifier}" with traits: +Future>> getExperimentFlag(FlagsmithClient flagsmith, + String feature, String identifier, Map traits) async { + final flag = await flagsmith.getExperimentFlag( + feature, + user: Identity(identifier: identifier), + traits: [ + for (final MapEntry(:key, :value) in traits.entries) + Trait(key: key, value: value), + ], + ); + return asSpecFlags([if (flag != null) flag]); +} + +// When the application tracks event "{event}" for identity "{identifier}" with value {value} and metadata: +void trackEvent(FlagsmithClient flagsmith, String event, String identifier, + Object? value, Map metadata) => + flagsmith.trackEvent( + event, + user: Identity(identifier: identifier), + value: value, + metadata: metadata, + ); + +// When the application tracks an exposure of feature "{feature}" for identity "{identifier}" with value {value} and metadata: +void trackExposureEvent(FlagsmithClient flagsmith, String feature, + String identifier, Object? value, Map metadata) => + flagsmith.trackExposureEvent( + feature, + user: Identity(identifier: identifier), + value: value, + metadata: metadata, + ); + List> asSpecFlags(List flags) => [ for (final flag in flags) { diff --git a/test/fg/flagsmith_analytics_test.dart b/test/fg/flagsmith_analytics_test.dart index d42f2f2..f172ae5 100644 --- a/test/fg/flagsmith_analytics_test.dart +++ b/test/fg/flagsmith_analytics_test.dart @@ -1,7 +1,9 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flagsmith/flagsmith.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:test/test.dart'; import '../shared.dart'; @@ -91,5 +93,48 @@ void main() { expect(fs.flagAnalytics.isEmpty, isFalse); }); + test( + 'When scheduled analytics fail to send, then the app keeps running ' + 'and keeps the analytics', () async { + // Given + final scheduled = await FlagsmithClient.init( + apiKey: apiKey, + seeds: seeds, + config: const FlagsmithConfig( + baseURI: 'https://offline.net/', + caches: true, + analyticsInterval: 10, + ), + ); + addTearDown(scheduled.close); + var failures = 0; + final secondFailure = Completer(); + scheduled.client.interceptors.add(InterceptorsWrapper(onError: (e, h) { + if (e.requestOptions.path == scheduled.config.analyticsURI) { + failures++; + if (failures == 2) { + secondFailure.complete(); + } + } + h.next(e); + })); + setupEmptyAdapter(scheduled, cb: (config, adapter) { + adapter.onPost(config.analyticsURI, (server) { + return server.throws( + 404, + DioException( + requestOptions: RequestOptions(path: config.analyticsURI), + ), + ); + }, data: Matchers.any); + }); + + // When + await scheduled.getFeatureFlagValue('my_feature'); + await secondFailure.future.timeout(const Duration(seconds: 1)); + + // Then + expect(scheduled.flagAnalytics['my_feature'], 1); + }); }); } diff --git a/test/fg/flagsmith_experiments_test.dart b/test/fg/flagsmith_experiments_test.dart index 64df6da..ce7abe4 100644 --- a/test/fg/flagsmith_experiments_test.dart +++ b/test/fg/flagsmith_experiments_test.dart @@ -113,6 +113,27 @@ void main() { expect(fs.eventProcessor!.buffer, isEmpty); }); + test('When identity is enrolled with traits, then exposure carries them', + () async { + // Given + final traits = [ + Trait(key: 'plan', value: 'premium'), + Trait(key: 'age', value: 30), + ]; + + // When + await fs.getExperimentFlag(experimentFeatureName, + user: user, traits: traits); + await fs.flushEvents(); + + // Then + final event = capture.events.single; + expect(event['event'], r'$flag_exposure'); + expect(event['identifier'], user.identifier); + expect(event['value'], experimentVariant); + expect(event['traits'], {'plan': 'premium', 'age': 30}); + }); + test('When identity is not enrolled, then no exposure', () async { final flag = await fs.getExperimentFlag(experimentNotEnrolledFeatureName); expect(flag!.variant, 'control');