From 06b4578b2ea266e97bc6207f34ff137e74804047 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:54:54 +0000 Subject: [PATCH 1/5] chore(deps): update dependency flagsmith/sdk-spec to v0.7.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 56b56c5ec7f3aba5072ac13d12e3c6de072fcc9f Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:39:10 +0000 Subject: [PATCH 2/5] fix: Keep the app running when scheduled flag analytics fail to send The flag analytics timer left the error of a failed send unhandled, so a network failure or an error response from Flagsmith crashed the app with an uncaught exception. The timer now drops the error, which syncAnalyticsData has already logged, and resends on the next tick. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 36a294b1e2271caaf6e211f0b70a4c34d62eeadf) --- lib/src/flagsmith_client.dart | 5 ++-- test/fg/flagsmith_analytics_test.dart | 41 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/src/flagsmith_client.dart b/lib/src/flagsmith_client.dart index 84bbe49..63981e2 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 { diff --git a/test/fg/flagsmith_analytics_test.dart b/test/fg/flagsmith_analytics_test.dart index d42f2f2..7a081fd 100644 --- a/test/fg/flagsmith_analytics_test.dart +++ b/test/fg/flagsmith_analytics_test.dart @@ -2,6 +2,7 @@ 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 +92,45 @@ 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 attempts = 0; + scheduled.client.interceptors.add(InterceptorsWrapper(onRequest: (o, h) { + if (o.path == scheduled.config.analyticsURI) { + attempts++; + } + h.next(o); + })); + 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 Future.delayed(const Duration(milliseconds: 100)); + + // Then + expect(attempts, greaterThan(1)); + expect(scheduled.flagAnalytics['my_feature'], 1); + }); }); } From 53f3740b213c17cf7c1b5ade4d27f82f6a410dad Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:39:10 +0000 Subject: [PATCH 3/5] feat: Attach traits to exposures recorded by getExperimentFlag An exposure recorded by getExperimentFlag now carries the traits the identity was evaluated with, as the other Flagsmith SDKs do, so that experiment results can be analysed by trait. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit b5f536aa1d97b7976ec6fe2cb091655cbc3e1b36) --- lib/src/flagsmith_client.dart | 8 ++++++-- test/fg/flagsmith_experiments_test.dart | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/src/flagsmith_client.dart b/lib/src/flagsmith_client.dart index 63981e2..c8e67dd 100644 --- a/lib/src/flagsmith_client.dart +++ b/lib/src/flagsmith_client.dart @@ -380,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. @@ -427,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/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'); From 4ffb1fcd0a891550542c9fd9cf7fb7b303ca4db1 Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:39:10 +0000 Subject: [PATCH 4/5] ci: Implement the event steps of SDK spec v0.6.0 Wires the spec's new steps to the SDK's event tracking: creating a client with events enabled, getExperimentFlag, trackEvent and trackExposureEvent. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 180ac9891648f0997fead5e18daab1f5dbd15950) --- sdk-spec/steps.dart | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) 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) { From fc557792b7d51e3368a2d60c500c2c376a90f673 Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:28:34 +0000 Subject: [PATCH 5/5] test: Wait for scheduled analytics to fail twice instead of a fixed delay A fixed 100 ms wait could see fewer scheduled sends than expected. The test now waits for the second failed send, which also ensures the first failure has been handled before the test ends. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit d76c74393116d69abf96103b59f563c9ffe67897) --- test/fg/flagsmith_analytics_test.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/fg/flagsmith_analytics_test.dart b/test/fg/flagsmith_analytics_test.dart index 7a081fd..f172ae5 100644 --- a/test/fg/flagsmith_analytics_test.dart +++ b/test/fg/flagsmith_analytics_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; @@ -106,12 +107,16 @@ void main() { ), ); addTearDown(scheduled.close); - var attempts = 0; - scheduled.client.interceptors.add(InterceptorsWrapper(onRequest: (o, h) { - if (o.path == scheduled.config.analyticsURI) { - attempts++; + 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(o); + h.next(e); })); setupEmptyAdapter(scheduled, cb: (config, adapter) { adapter.onPost(config.analyticsURI, (server) { @@ -126,10 +131,9 @@ void main() { // When await scheduled.getFeatureFlagValue('my_feature'); - await Future.delayed(const Duration(milliseconds: 100)); + await secondFailure.future.timeout(const Duration(seconds: 1)); // Then - expect(attempts, greaterThan(1)); expect(scheduled.flagAnalytics['my_feature'], 1); }); });