Skip to content

Commit

Permalink
feat: prevent fetch on init (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew authored Jan 2, 2025
1 parent e30c4a1 commit 93cd19f
Show file tree
Hide file tree
Showing 8 changed files with 353 additions and 18 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
45 changes: 30 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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`
Expand Down
25 changes: 25 additions & 0 deletions lib/last_update_terms.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> toMap() {
return {
'key': key,
'timestamp': timestamp,
};
}

factory LastUpdateTerms.fromJson(Map<String, dynamic> json) {
return LastUpdateTerms(
key: json['key'],
timestamp: json['timestamp'],
);
}
}
23 changes: 23 additions & 0 deletions lib/unleash_context.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

/// https://docs.getunleash.io/reference/unleash-context
class UnleashContext {
String? userId;
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 80 additions & 2 deletions lib/unleash_proxy_client_flutter.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<void> _init() async {
Expand All @@ -167,6 +188,12 @@ class UnleashClient extends EventEmitter {
toggles = togglesInStorage;
}

if (bootstrap != null) {
await _storeLastRefreshTimestamp();
} else {
lastRefreshTimestamp = await _getLastRefreshTimestamp();
}

clientState = ClientState.initialized;
emit(initializedEvent);

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<int> _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<void> _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<void> _waitForEvent(String eventName) async {
final completer = Completer<void>();
void listener(dynamic value) async {
Expand All @@ -363,7 +438,10 @@ class UnleashClient extends EventEmitter {
}

metrics.start();
await _fetchToggles();

if (!_isUpToDate()) {
await _fetchToggles();
}

if (clientState != ClientState.ready) {
clientState = ClientState.ready;
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions test/unleash_context_test.dart
Original file line number Diff line number Diff line change
@@ -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==");
});
}
Loading

0 comments on commit 93cd19f

Please sign in to comment.