Skip to content

Commit

Permalink
fix: Add RN SDK offline support through ConnectionMode. (#361)
Browse files Browse the repository at this point in the history
This adds offline support for the RN SDK through ConnectionMode.

```tsx
  /**
   * Sets the mode to use for connections when the SDK is initialized.
   *
   * Defaults to streaming.
   */
  initialConnectionMode?: ConnectionMode; // in api/LDOptions.ts

  /**
   * Sets the SDK connection mode.
   *
   * @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses 'streaming'.
   */
  setConnectionMode(mode: ConnectionMode): void; // in api/LDClient.ts
```
  • Loading branch information
yusinto authored Feb 2, 2024
1 parent c69b768 commit d97ce82
Show file tree
Hide file tree
Showing 14 changed files with 186 additions and 66 deletions.
1 change: 0 additions & 1 deletion packages/sdk/react-native/example/e2e/starter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ describe('Example', () => {

test('variation', async () => {
await element(by.id('flagKey')).replaceText('my-boolean-flag-2');
await element(by.text(/get flag value/i)).tap();

await waitFor(element(by.text(/my-boolean-flag-2: true/i)))
.toBeVisible()
Expand Down
15 changes: 13 additions & 2 deletions packages/sdk/react-native/example/src/welcome.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';

import { ConnectionMode } from '@launchdarkly/js-client-sdk-common';
import { useBoolVariation, useLDClient } from '@launchdarkly/react-native-client-sdk';

export default function Welcome() {
Expand All @@ -15,6 +16,10 @@ export default function Welcome() {
.catch((e: any) => console.error(`error identifying ${userKey}: ${e}`));
};

const setConnectionMode = (m: ConnectionMode) => {
ldc.setConnectionMode(m);
};

return (
<View style={styles.container}>
<Text>Welcome to LaunchDarkly</Text>
Expand All @@ -40,8 +45,14 @@ export default function Welcome() {
value={flagKey}
testID="flagKey"
/>
<TouchableOpacity style={styles.buttonContainer}>
<Text style={styles.buttonText}>get flag value</Text>
<TouchableOpacity style={styles.buttonContainer} onPress={() => setConnectionMode('offline')}>
<Text style={styles.buttonText}>Set offline</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.buttonContainer}
onPress={() => setConnectionMode('streaming')}
>
<Text style={styles.buttonText}>Set online</Text>
</TouchableOpacity>
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,10 +686,11 @@ describe('given an event processor', () => {
eventProcessor.sendEvent(new InputIdentifyEvent(Context.fromLDContext(user)));
await jest.advanceTimersByTimeAsync(eventProcessorConfig.flushInterval * 1000);

expect(mockConsole).toBeCalledTimes(2);
expect(mockConsole).toHaveBeenNthCalledWith(1, 'debug: [LaunchDarkly] Flushing 1 events');
expect(mockConsole).toHaveBeenCalledTimes(3);
expect(mockConsole).toHaveBeenNthCalledWith(1, 'debug: [LaunchDarkly] Started EventProcessor.');
expect(mockConsole).toHaveBeenNthCalledWith(2, 'debug: [LaunchDarkly] Flushing 1 events');
expect(mockConsole).toHaveBeenNthCalledWith(
2,
3,
'debug: [LaunchDarkly] Flush failed: Error: some error',
);
});
Expand Down
17 changes: 13 additions & 4 deletions packages/shared/common/src/internal/events/EventProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ export default class EventProcessor implements LDEventProcessor {
private flushUsersTimer: any = null;

constructor(
config: EventProcessorOptions,
private readonly config: EventProcessorOptions,
clientContext: ClientContext,
private readonly contextDeduplicator?: LDContextDeduplicator,
private readonly diagnosticsManager?: DiagnosticsManager,
start: boolean = true,
) {
this.capacity = config.eventsCapacity;
this.logger = clientContext.basicConfiguration.logger;
Expand All @@ -118,6 +119,12 @@ export default class EventProcessor implements LDEventProcessor {
config.privateAttributes.map((ref) => new AttributeReference(ref)),
);

if (start) {
this.start();
}
}

start() {
if (this.contextDeduplicator?.flushInterval !== undefined) {
this.flushUsersTimer = setInterval(() => {
this.contextDeduplicator?.flush();
Expand All @@ -131,10 +138,10 @@ export default class EventProcessor implements LDEventProcessor {
// Log errors and swallow them
this.logger?.debug(`Flush failed: ${e}`);
}
}, config.flushInterval * 1000);
}, this.config.flushInterval * 1000);

if (this.diagnosticsManager) {
const initEvent = diagnosticsManager!.createInitEvent();
const initEvent = this.diagnosticsManager!.createInitEvent();
this.postDiagnosticEvent(initEvent);

this.diagnosticsTimer = setInterval(() => {
Expand All @@ -148,8 +155,10 @@ export default class EventProcessor implements LDEventProcessor {
this.deduplicatedUsers = 0;

this.postDiagnosticEvent(statsEvent);
}, config.diagnosticRecordingInterval * 1000);
}, this.config.diagnosticRecordingInterval * 1000);
}

this.logger?.debug('Started EventProcessor.');
}

private postDiagnosticEvent(event: DiagnosticEvent) {
Expand Down
100 changes: 73 additions & 27 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ import {
Platform,
ProcessStreamResponse,
EventName as StreamEventName,
subsystem,
TypeValidators,
} from '@launchdarkly/js-sdk-common';

import { LDClient, type LDOptions } from './api';
import { ConnectionMode, LDClient, type LDOptions } from './api';
import LDEmitter, { EventName } from './api/LDEmitter';
import Configuration from './configuration';
import createDiagnosticsManager from './diagnostics/createDiagnosticsManager';
Expand All @@ -34,9 +33,9 @@ export default class LDClientImpl implements LDClient {
config: Configuration;
context?: LDContext;
diagnosticsManager?: internal.DiagnosticsManager;
eventProcessor: subsystem.LDEventProcessor;
streamer?: internal.StreamingProcessor;
eventProcessor?: internal.EventProcessor;
logger: LDLogger;
streamer?: internal.StreamingProcessor;

private eventFactoryDefault = new EventFactory(false);
private eventFactoryWithReasons = new EventFactory(true);
Expand Down Expand Up @@ -74,10 +73,45 @@ export default class LDClientImpl implements LDClient {
this.config,
platform,
this.diagnosticsManager,
!this.isOffline(),
);
this.emitter = new LDEmitter();
}

async setConnectionMode(mode: ConnectionMode): Promise<void> {
if (this.config.connectionMode === mode) {
this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`);
return Promise.resolve();
}

this.config.connectionMode = mode;
this.logger.debug(`setConnectionMode ${mode}.`);

switch (mode) {
case 'offline':
return this.close();
case 'streaming':
this.eventProcessor?.start();

if (this.context) {
// identify will start streamer
return this.identify(this.context);
}
break;
default:
this.logger.warn(
`Unknown ConnectionMode: ${mode}. Only 'offline' and 'streaming' are supported.`,
);
break;
}

return Promise.resolve();
}

isOffline() {
return this.config.connectionMode === 'offline';
}

allFlags(): LDFlagSet {
const result: LDFlagSet = {};
Object.entries(this.flags).forEach(([k, r]) => {
Expand All @@ -90,16 +124,20 @@ export default class LDClientImpl implements LDClient {

async close(): Promise<void> {
await this.flush();
this.eventProcessor.close();
this.eventProcessor?.close();
this.streamer?.close();
this.logger.debug('Closed eventProcessor and streamer.');
}

async flush(): Promise<{ error?: Error; result: boolean }> {
try {
await this.eventProcessor.flush();
await this.eventProcessor?.flush();
this.logger.debug('Successfully flushed eventProcessor.');
} catch (e) {
this.logger.error(`Error flushing eventProcessor: ${e}.`);
return { error: e as Error, result: false };
}

return { result: true };
}

Expand Down Expand Up @@ -232,7 +270,6 @@ export default class LDClientImpl implements LDClient {
return f ? JSON.parse(f) : undefined;
}

// TODO: implement secure mode
async identify(pristineContext: LDContext, _hash?: string): Promise<void> {
let context = await ensureKey(pristineContext, this.platform);

Expand Down Expand Up @@ -262,19 +299,30 @@ export default class LDClientImpl implements LDClient {
this.emitter.emit('change', context, changedKeys);
}

this.streamer?.close();
this.streamer = new internal.StreamingProcessor(
this.sdkKey,
this.clientContext,
this.createStreamUriPath(context),
this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve),
this.diagnosticsManager,
(e) => {
this.logger.error(e);
this.emitter.emit('error', context, e);
},
);
this.streamer.start();
if (this.isOffline()) {
if (flagsStorage) {
this.logger.debug('Offline identify using storage flags.');
} else {
this.logger.debug('Offline identify no storage. Defaults will be used.');
this.context = context;
this.flags = {};
identifyResolve();
}
} else {
this.streamer?.close();
this.streamer = new internal.StreamingProcessor(
this.sdkKey,
this.clientContext,
this.createStreamUriPath(context),
this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve),
this.diagnosticsManager,
(e) => {
this.logger.error(e);
this.emitter.emit('error', context, e);
},
);
this.streamer.start();
}

return identifyPromise;
}
Expand All @@ -298,13 +346,11 @@ export default class LDClientImpl implements LDClient {
return;
}

this.eventProcessor.sendEvent(
this.eventProcessor?.sendEvent(
this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue),
);
}

// TODO: move variation functions to a separate file to make this file size
// more manageable.
private variationInternal(
flagKey: string,
defaultValue: any,
Expand All @@ -322,11 +368,11 @@ export default class LDClientImpl implements LDClient {
if (!found || found.deleted) {
const defVal = defaultValue ?? null;
const error = new LDClientError(
`Unknown feature flag "${flagKey}"; returning default value ${defVal}`,
`Unknown feature flag "${flagKey}"; returning default value ${defVal}.`,
);
this.logger.error(error);
this.emitter.emit('error', this.context, error);
this.eventProcessor.sendEvent(
this.eventProcessor?.sendEvent(
this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
);
return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue);
Expand All @@ -337,7 +383,7 @@ export default class LDClientImpl implements LDClient {
if (typeChecker) {
const [matched, type] = typeChecker(value);
if (!matched) {
this.eventProcessor.sendEvent(
this.eventProcessor?.sendEvent(
eventFactory.evalEventClient(
flagKey,
defaultValue, // track default value on type errors
Expand All @@ -361,7 +407,7 @@ export default class LDClientImpl implements LDClient {
this.logger.debug('Result value is null in variation');
successDetail.value = defaultValue;
}
this.eventProcessor.sendEvent(
this.eventProcessor?.sendEvent(
eventFactory.evalEventClient(flagKey, value, defaultValue, found, evalContext, reason),
);
return successDetail;
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/sdk-client/src/api/ConnectionMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* The connection mode for the SDK to use.
*
* @remarks
*
* The following connection modes are supported:
*
* offline - When the SDK is set offline it will stop receiving updates and will stop sending
* analytic and diagnostic events.
*
* streaming - The SDK will use a streaming connection to receive updates from LaunchDarkly.
*/
type ConnectionMode = 'offline' | 'streaming';

export default ConnectionMode;
9 changes: 9 additions & 0 deletions packages/shared/sdk-client/src/api/LDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
LDLogger,
} from '@launchdarkly/js-sdk-common';

import ConnectionMode from './ConnectionMode';

/**
* The basic interface for the LaunchDarkly client. Platform-specific SDKs may add some methods of their own.
*
Expand Down Expand Up @@ -213,6 +215,13 @@ export interface LDClient {
*/
on(key: string, callback: (...args: any[]) => void): void;

/**
* Sets the SDK connection mode.
*
* @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses 'streaming'.
*/
setConnectionMode(mode: ConnectionMode): void;

/**
* Determines the string variation of a feature flag.
*
Expand Down
27 changes: 15 additions & 12 deletions packages/shared/sdk-client/src/api/LDOptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common';

import ConnectionMode from './ConnectionMode';
import type { LDInspection } from './LDInspection';

export interface LDOptions {
Expand Down Expand Up @@ -59,17 +60,11 @@ export interface LDOptions {
baseUri?: string;

/**
* TODO: bootstrap
* The initial set of flags to use until the remote set is retrieved.
*
* If `"localStorage"` is specified, the flags will be saved and retrieved from browser local
* storage. Alternatively, an {@link LDFlagSet} can be specified which will be used as the initial
* source of flag values. In the latter case, the flag values will be available via {@link LDClient.variation}
* immediately after calling `initialize()` (normally they would not be available until the
* client signals that it is ready).
*
* For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript).
* @alpha
*/
bootstrap?: 'localStorage' | LDFlagSet;
bootstrap?: LDFlagSet;

/**
* The capacity of the analytics events queue.
Expand Down Expand Up @@ -119,15 +114,23 @@ export interface LDOptions {
flushInterval?: number;

/**
* TODO: secure mode
* The signed context key for Secure Mode.
*
* For more information, see the JavaScript SDK Reference Guide on
* [Secure mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk).
* @alpha
*/
hash?: string;

/**
* Sets the mode to use for connections when the SDK is initialized.
*
* Defaults to streaming.
*/
initialConnectionMode?: ConnectionMode;

/**
* TODO: inspectors
* Inspectors can be used for collecting information for monitoring, analytics, and debugging.
* @alpha
*/
inspectors?: LDInspection[];

Expand Down
Loading

0 comments on commit d97ce82

Please sign in to comment.