From 93cd19f5f72ec9978914a9f4137bf58f1d3b6469 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 2 Jan 2025 13:14:51 +0100 Subject: [PATCH] feat: prevent fetch on init (#56) --- CHANGELOG.md | 4 + README.md | 45 +++-- lib/last_update_terms.dart | 25 +++ lib/unleash_context.dart | 23 +++ lib/unleash_proxy_client_flutter.dart | 82 ++++++++- pubspec.yaml | 2 +- test/unleash_context_test.dart | 15 ++ test/unleash_proxy_client_flutter_test.dart | 175 ++++++++++++++++++++ 8 files changed, 353 insertions(+), 18 deletions(-) create mode 100644 lib/last_update_terms.dart create mode 100644 test/unleash_context_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3867518..f870160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.8.0 + +* Feat: experimental support for skip fetch toggles on start + ## 1.7.0 * Feat: manual control over updateToggles and sendMetrics diff --git a/README.md b/README.md index 2b3f53b..4e545e9 100644 --- a/README.md +++ b/README.md @@ -116,23 +116,24 @@ unleash.setContextFields({'userId': '4141'}); The Unleash SDK takes the following options: -| option | required | default | description | -|-------------------|----------|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| -| url | yes | n/a | The Unleash Proxy URL to connect to. E.g.: `https://examples.com/proxy` | -| clientKey | yes | n/a | The Unleash Proxy Secret to be used | -| appName | yes | n/a | The name of the application using this SDK. Will be used as part of the metrics sent to Unleash Proxy. Will also be part of the Unleash Context. | -| refreshInterval | no | 30 | How often, in seconds, the SDK should check for updated toggle configuration. If set to 0 will disable checking for updates | -| disableRefresh | no | false | If set to true, the client will not check for updated toggle configuration | -| metricsInterval | no | 30 | How often, in seconds, the SDK should send usage metrics back to Unleash Proxy | -| disableMetrics | no | false | Set this option to `true` if you want to disable usage metrics +| option | required | default | description | +|-------------------|----------|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| url | yes | n/a | The Unleash Proxy URL to connect to. E.g.: `https://examples.com/proxy` | +| clientKey | yes | n/a | The Unleash Proxy Secret to be used | +| appName | yes | n/a | The name of the application using this SDK. Will be used as part of the metrics sent to Unleash Proxy. Will also be part of the Unleash Context. | +| refreshInterval | no | 30 | How often, in seconds, the SDK should check for updated toggle configuration. If set to 0 will disable checking for updates | +| disableRefresh | no | false | If set to true, the client will not check for updated toggle configuration | +| metricsInterval | no | 30 | How often, in seconds, the SDK should send usage metrics back to Unleash Proxy | +| disableMetrics | no | false | Set this option to `true` if you want to disable usage metrics | storageProvider | no | `SharedPreferencesStorageProvider` | Allows you to inject a custom storeProvider | -| bootstrap | no | `[]` | Allows you to bootstrap the cached feature toggle configuration. | -| bootstrapOverride | no| `true` | Should the bootstrap automatically override cached data in the local-storage. Will only be used if bootstrap is not an empty array. | -| headerName | no| `Authorization` | Provides possiblity to specify custom header that is passed to Unleash / Unleash Proxy with the `clientKey` | -| customHeaders | no| `{}` | Additional headers to use when making HTTP requests to the Unleash proxy. In case of name collisions with the default headers, the `customHeaders` value will be used. | -| impressionDataAll | no| `false` | Allows you to trigger "impression" events for **all** `getToggle` and `getVariant` invocations. This is particularly useful for "disabled" feature toggles that are not visible to frontend SDKs. | +| bootstrap | no | `null` | Allows you to bootstrap the cached feature toggle configuration. | +| bootstrapOverride | no| `true` | Should the bootstrap automatically override cached data in the local-storage. Will only be used if bootstrap is not an empty array. | +| headerName | no| `Authorization` | Provides possiblity to specify custom header that is passed to Unleash / Unleash Proxy with the `clientKey` | +| customHeaders | no| `{}` | Additional headers to use when making HTTP requests to the Unleash proxy. In case of name collisions with the default headers, the `customHeaders` value will be used. | +| impressionDataAll | no| `false` | Allows you to trigger "impression" events for **all** `getToggle` and `getVariant` invocations. This is particularly useful for "disabled" feature toggles that are not visible to frontend SDKs. | | fetcher | no | `http.get` | Allows you to define your own **fetcher**. Can be used to add certificate pinning or additional http behavior. | | poster | no | `http.post` | Allows you to define your own **poster**. Can be used to add certificate pinning or additional http behavior. | +| experimental | no | `null` | Enabling optional experimentation. `togglesStorageTTL` : How long (Time To Live), in seconds, the toggles in storage are considered valid and should not be fetched on start. If set to 0 will disable expiration checking and will be considered always expired. ### Listen for updates via the events_emitter The client is also an event emitter. This means that your code can subscribe to updates from the client. @@ -174,7 +175,7 @@ This is also useful if you require the toggles to be in a certain state immediat ### How to use it ? Add a `bootstrap` attribute when create a new `UnleashClient`. -There's also a `bootstrapOverride` attribute which is by default is `true`. +There's also a `bootstrapOverride` attribute which by default is `true`. ```dart final unleash = UnleashClient( @@ -199,6 +200,20 @@ final unleash = UnleashClient( You can opt out of the Unleash feature flag auto-refresh mechanism and metrics update by settings the `refreshInterval` and/or `metricsInterval` options to `0`. In this case, it becomes your responsibility to call `updateToggles` and/or `sendMetrics` methods. +## Experimental: skip fetching toggles on start + +If you start multiple clients sharing the same storage provider (e.g. a default `SharedPreferencesStorageProvider`) you might want to skip fetching toggles on start for all but one of the clients. +This can be achieved by setting the `togglesStorageTTL` to a non-zero value. +E.g in the example below, the toggles will be considered valid for 60 seconds and will not be fetched on start if they already exist in storage. +We recommend to set `togglesStorageTTL` to a value greater than the `refreshInterval`. + +```dart + final anotherUnleash = UnleashClient( + // ... + experimental: const ExperimentalConfig(togglesStorageTTL: 60) + ); +``` + ## Release guide * Run tests: `flutter test` * Format code: `dart format lib test` diff --git a/lib/last_update_terms.dart b/lib/last_update_terms.dart new file mode 100644 index 0000000..1736150 --- /dev/null +++ b/lib/last_update_terms.dart @@ -0,0 +1,25 @@ +class LastUpdateTerms { + String key; + int timestamp; + + LastUpdateTerms({required this.key, required this.timestamp}); + + @override + String toString() { + return '{key: $key, timestamp: $timestamp}'; + } + + Map toMap() { + return { + 'key': key, + 'timestamp': timestamp, + }; + } + + factory LastUpdateTerms.fromJson(Map json) { + return LastUpdateTerms( + key: json['key'], + timestamp: json['timestamp'], + ); + } +} diff --git a/lib/unleash_context.dart b/lib/unleash_context.dart index b197c97..9f0f605 100644 --- a/lib/unleash_context.dart +++ b/lib/unleash_context.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + /// https://docs.getunleash.io/reference/unleash-context class UnleashContext { String? userId; @@ -30,6 +32,27 @@ class UnleashContext { return params; } + String getKey() { + final buffer = StringBuffer(); + + if (userId != null) { + buffer.write('userId=$userId;'); + } + if (sessionId != null) { + buffer.write('sessionId=$sessionId;'); + } + if (remoteAddress != null) { + buffer.write('remoteAddress=$remoteAddress;'); + } + + properties.forEach((key, value) { + buffer.write('$key=$value;'); + }); + + final bytes = utf8.encode(buffer.toString()); + return base64.encode(bytes); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/lib/unleash_proxy_client_flutter.dart b/lib/unleash_proxy_client_flutter.dart index 59e42e2..618e5e4 100644 --- a/lib/unleash_proxy_client_flutter.dart +++ b/lib/unleash_proxy_client_flutter.dart @@ -1,5 +1,7 @@ library unleash_proxy_client_flutter; +import 'dart:convert'; + import 'package:http/http.dart' as http; import 'dart:async'; import 'package:events_emitter/events_emitter.dart'; @@ -15,6 +17,7 @@ import 'package:unleash_proxy_client_flutter/metrics.dart'; import 'event_id_generator.dart'; import 'http_toggle_client.dart'; +import 'last_update_terms.dart'; enum ClientState { initializing, @@ -24,11 +27,18 @@ enum ClientState { const storageKey = '_unleash_repo'; const sessionStorageKey = '_unleash_sessionId'; +const lastUpdateKey = '_unleash_repoLastUpdateTimestamp'; String storageWithApp(String appName, String key) { return '$appName.$key'; } +class ExperimentalConfig { + final int? togglesStorageTTL; + + const ExperimentalConfig({this.togglesStorageTTL}); +} + /// Main entry point to Flutter Unleash Proxy (https://docs.getunleash.io/reference/unleash-proxy) client class UnleashClient extends EventEmitter { /// Unleash Proxy URL (https://docs.getunleash.io/reference/unleash-proxy) @@ -113,6 +123,10 @@ class UnleashClient extends EventEmitter { /// Internal indicator if the client has been started var started = false; + ExperimentalConfig? experimental; + + int lastRefreshTimestamp = 0; + UnleashClient( {required this.url, required this.clientKey, @@ -132,7 +146,8 @@ class UnleashClient extends EventEmitter { this.disableRefresh = false, this.headerName = 'Authorization', this.customHeaders = const {}, - this.impressionDataAll = false}) { + this.impressionDataAll = false, + this.experimental}) { _init(); metrics = Metrics( appName: appName, @@ -147,6 +162,12 @@ class UnleashClient extends EventEmitter { if (bootstrap != null) { toggles = bootstrap; } + if (experimental != null) { + final ttl = experimental?.togglesStorageTTL; + if (ttl != null && ttl > 0) { + experimental = ExperimentalConfig(togglesStorageTTL: ttl * 1000); + } + } } Future _init() async { @@ -167,6 +188,12 @@ class UnleashClient extends EventEmitter { toggles = togglesInStorage; } + if (bootstrap != null) { + await _storeLastRefreshTimestamp(); + } else { + lastRefreshTimestamp = await _getLastRefreshTimestamp(); + } + clientState = ClientState.initialized; emit(initializedEvent); @@ -212,8 +239,12 @@ class UnleashClient extends EventEmitter { await actualStorageProvider.save( storageWithApp(appName, storageKey), response.body); toggles = parseToggles(response.body); + await _storeLastRefreshTimestamp(); emit(updateEvent); } + if (response.statusCode == 304) { + await _storeLastRefreshTimestamp(); + } if (response.statusCode > 399) { emit(errorEvent, { "type": 'HttpError', @@ -345,6 +376,50 @@ class UnleashClient extends EventEmitter { } } + bool _isTogglesStorageTTLEnabled() { + return experimental?.togglesStorageTTL != null && + experimental!.togglesStorageTTL! > 0; + } + + bool _isUpToDate() { + if (!_isTogglesStorageTTLEnabled()) { + return false; + } + + final now = clock().millisecondsSinceEpoch; + final ttl = experimental?.togglesStorageTTL ?? 0; + + return lastRefreshTimestamp > 0 && + (lastRefreshTimestamp <= now) && + (now - lastRefreshTimestamp < ttl); + } + + Future _getLastRefreshTimestamp() async { + if (_isTogglesStorageTTLEnabled()) { + final lastRefresh = await actualStorageProvider + .get(storageWithApp(appName, lastUpdateKey)); + final lastRefreshDecoded = lastRefresh != null + ? LastUpdateTerms.fromJson(jsonDecode(lastRefresh)) + : null; + final contextHash = context.getKey(); + if (lastRefreshDecoded != null && lastRefreshDecoded.key == contextHash) { + return lastRefreshDecoded.timestamp; + } + return 0; + } + return 0; + } + + Future _storeLastRefreshTimestamp() async { + if (_isTogglesStorageTTLEnabled()) { + lastRefreshTimestamp = clock().millisecondsSinceEpoch; + final lastUpdateValue = LastUpdateTerms( + key: context.getKey(), timestamp: lastRefreshTimestamp); + await actualStorageProvider.save(storageWithApp(appName, lastUpdateKey), + jsonEncode(lastUpdateValue.toMap())); + } + } + Future _waitForEvent(String eventName) async { final completer = Completer(); void listener(dynamic value) async { @@ -363,7 +438,10 @@ class UnleashClient extends EventEmitter { } metrics.start(); - await _fetchToggles(); + + if (!_isUpToDate()) { + await _fetchToggles(); + } if (clientState != ClientState.ready) { clientState = ClientState.ready; diff --git a/pubspec.yaml b/pubspec.yaml index b6a1b69..cfb0b45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A Flutter/Dart client that can be used together with the unleash-pr homepage: https://github.com/Unleash/unleash_proxy_client_flutter repository: https://github.com/Unleash/unleash_proxy_client_flutter issue_tracker: https://github.com/Unleash/unleash_proxy_client_flutter -version: 1.7.0 +version: 1.8.0 environment: sdk: ">=2.18.0 <4.0.0" diff --git a/test/unleash_context_test.dart b/test/unleash_context_test.dart new file mode 100644 index 0000000..02c4f4c --- /dev/null +++ b/test/unleash_context_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unleash_proxy_client_flutter/unleash_context.dart'; + +void main() { + test('returns consistent key for same values', () { + final context1 = UnleashContext( + userId: 'user1', + sessionId: 'session1', + remoteAddress: '192.168.1.1', + properties: {'key1': 'value1'}); + + expect(context1.getKey(), + "dXNlcklkPXVzZXIxO3Nlc3Npb25JZD1zZXNzaW9uMTtyZW1vdGVBZGRyZXNzPTE5Mi4xNjguMS4xO2tleTE9dmFsdWUxOw=="); + }); +} diff --git a/test/unleash_proxy_client_flutter_test.dart b/test/unleash_proxy_client_flutter_test.dart index 39214a9..3ac98ca 100644 --- a/test/unleash_proxy_client_flutter_test.dart +++ b/test/unleash_proxy_client_flutter_test.dart @@ -329,6 +329,181 @@ void main() { expect(getMock.calledTimes, 1); }); + test('skip initial fetch when TTL not exceeded and 200 code', () async { + final getMock = GetMock(); + final sharedStorageProvider = InMemoryStorageProvider(); + final unleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: getMock, + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + + await unleash.start(); + + final anotherGetMock = GetMock(); + final anotherUnleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: anotherGetMock, + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + + await anotherUnleash.start(); + + expect(anotherUnleash.isEnabled('flutter-on'), true); + expect(anotherUnleash.isEnabled('flutter-off'), false); + expect(anotherGetMock.calledTimes, 0); + }); + + test('skip initial fetch when TTL not exceeded and 304 code', () async { + final getMock = GetMock(status: 304); + final sharedStorageProvider = InMemoryStorageProvider(); + final unleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: getMock, + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + + await unleash.start(); + + final anotherGetMock = GetMock(status: 304); + final anotherUnleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: anotherGetMock, + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + + await anotherUnleash.start(); + + expect(anotherGetMock.calledTimes, 0); + }); + + test('skip initial fetch when bootstrap is provided and TTL not expired', + () async { + final getMock = GetMock(); + final storageProvider = InMemoryStorageProvider(); + final unleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: storageProvider, + fetcher: getMock, + bootstrap: { + 'flutter-on': ToggleConfig( + enabled: true, + impressionData: false, + variant: Variant( + enabled: true, + name: 'variant-name', + payload: Payload(type: "string", value: "someValue"))) + }, + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + + await unleash.start(); + + expect(unleash.isEnabled('flutter-on'), true); + expect(getMock.calledTimes, 0); + }); + + test('do not skip initial fetch when context is different', () async { + final getMock = GetMock(); + final sharedStorageProvider = InMemoryStorageProvider(); + final unleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: getMock, + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + unleash.updateContext( + UnleashContext(properties: {'customKey': 'customValue'})); + + await unleash.start(); + + final anotherGetMock = GetMock(); + final anotherUnleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: anotherGetMock, + experimental: const ExperimentalConfig(togglesStorageTTL: 10), + ); + unleash.updateContext(UnleashContext( + properties: {'customKey': 'anotherCustomValue'})); // different context + + await anotherUnleash.start(); + + expect(anotherGetMock.calledTimes, 1); + }); + + test('do not skip initial fetch when TTL exceeded', () async { + final getMock = GetMock(); + final sharedStorageProvider = InMemoryStorageProvider(); + final originalTime = DateTime.utc(2000); + final unleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + clock: () => originalTime, + fetcher: getMock); + + await unleash.start(); + + final anotherGetMock = GetMock(); + final anotherUnleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: anotherGetMock, + clock: () => originalTime.add(const Duration(seconds: 11)), + experimental: const ExperimentalConfig(togglesStorageTTL: 10)); + + await anotherUnleash.start(); + + expect(anotherUnleash.isEnabled('flutter-on'), true); + expect(anotherUnleash.isEnabled('flutter-off'), false); + expect(anotherGetMock.calledTimes, 1); + }); + + test('do not skip initial fetch when TTL is 0', () async { + final getMock = GetMock(); + final sharedStorageProvider = InMemoryStorageProvider(); + final originalTime = DateTime.utc(2000); + final unleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + clock: () => originalTime, + fetcher: getMock); + + await unleash.start(); + + final anotherGetMock = GetMock(); + final anotherUnleash = UnleashClient( + url: url, + clientKey: 'proxy-123', + appName: 'flutter-test', + storageProvider: sharedStorageProvider, + fetcher: anotherGetMock, + clock: () => originalTime, + experimental: const ExperimentalConfig(togglesStorageTTL: 0)); + + await anotherUnleash.start(); + + expect(anotherGetMock.calledTimes, 1); + }); + test('can store toggles in memory storage', () async { final getMock = GetMock(); final storageProvider = InMemoryStorageProvider();