From b95bbaf798267f97c0733eabff8d6c06f795281f Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 27 Jun 2023 12:54:15 -0700 Subject: [PATCH 01/57] chore: initial commit --- package.json | 1 + packages/shared/sdk-client/LICENSE | 13 + packages/shared/sdk-client/README.md | 1 + packages/shared/sdk-client/api/LDClientDom.ts | 276 ++++++++++++++++++ packages/shared/sdk-client/api/LDOptions.ts | 234 +++++++++++++++ packages/shared/sdk-client/jest.config.js | 7 + packages/shared/sdk-client/package.json | 54 ++++ .../shared/sdk-client/src/LDClientDomImpl.ts | 66 +++++ .../shared/sdk-client/tsconfig.eslint.json | 5 + packages/shared/sdk-client/tsconfig.json | 18 ++ packages/shared/sdk-client/tsconfig.ref.json | 7 + 11 files changed, 682 insertions(+) create mode 100644 packages/shared/sdk-client/LICENSE create mode 100644 packages/shared/sdk-client/README.md create mode 100644 packages/shared/sdk-client/api/LDClientDom.ts create mode 100644 packages/shared/sdk-client/api/LDOptions.ts create mode 100644 packages/shared/sdk-client/jest.config.js create mode 100644 packages/shared/sdk-client/package.json create mode 100644 packages/shared/sdk-client/src/LDClientDomImpl.ts create mode 100644 packages/shared/sdk-client/tsconfig.eslint.json create mode 100644 packages/shared/sdk-client/tsconfig.json create mode 100644 packages/shared/sdk-client/tsconfig.ref.json diff --git a/package.json b/package.json index e5a3f7904..4bb128831 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@launchdarkly/js-core", "workspaces": [ "packages/shared/common", + "packages/shared/sdk-client", "packages/shared/sdk-server", "packages/shared/sdk-server-edge", "packages/shared/akamai-edgeworker-sdk", diff --git a/packages/shared/sdk-client/LICENSE b/packages/shared/sdk-client/LICENSE new file mode 100644 index 000000000..d238a2b01 --- /dev/null +++ b/packages/shared/sdk-client/LICENSE @@ -0,0 +1,13 @@ +Copyright 2022 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/packages/shared/sdk-client/README.md b/packages/shared/sdk-client/README.md new file mode 100644 index 000000000..679c48c22 --- /dev/null +++ b/packages/shared/sdk-client/README.md @@ -0,0 +1 @@ +# sdk-client diff --git a/packages/shared/sdk-client/api/LDClientDom.ts b/packages/shared/sdk-client/api/LDClientDom.ts new file mode 100644 index 000000000..574f0ac38 --- /dev/null +++ b/packages/shared/sdk-client/api/LDClientDom.ts @@ -0,0 +1,276 @@ +import { LDContext, LDEvaluationDetail, LDFlagSet, LDFlagValue } from '@launchdarkly/js-sdk-common'; + +/** + * The basic interface for the LaunchDarkly client. Platform-specific SDKs may add some methods of their own. + * + * @see https://docs.launchdarkly.com/sdk/client-side/javascript + * + * @ignore (don't need to show this separately in TypeDoc output; all methods will be shown in LDClient) + */ +export interface LDClientDom { + /** + * Returns a Promise that tracks the client's initialization state. + * + * The returned Promise will be resolved once the client has either successfully initialized + * or failed to initialize (e.g. due to an invalid environment key or a server error). It will + * never be rejected. + * + * ``` + * // using a Promise then() handler + * client.waitUntilReady().then(() => { + * doSomethingWithClient(); + * }); + * + * // using async/await + * await client.waitUntilReady(); + * doSomethingWithClient(); + * ``` + * + * If you want to distinguish between these success and failure conditions, use + * {@link waitForInitialization} instead. + * + * If you prefer to use event listeners ({@link on}) rather than Promises, you can listen on the + * client for a `"ready"` event, which will be fired in either case. + * + * @returns + * A Promise that will be resolved once the client is no longer trying to initialize. + */ + waitUntilReady(): Promise; + + /** + * Returns a Promise that tracks the client's initialization state. + * + * The Promise will be resolved if the client successfully initializes, or rejected if client + * initialization has irrevocably failed (for instance, if it detects that the SDK key is invalid). + * + * ``` + * // using Promise then() and catch() handlers + * client.waitForInitialization().then(() => { + * doSomethingWithSuccessfullyInitializedClient(); + * }).catch(err => { + * doSomethingForFailedStartup(err); + * }); + * + * // using async/await + * try { + * await client.waitForInitialization(); + * doSomethingWithSuccessfullyInitializedClient(); + * } catch (err) { + * doSomethingForFailedStartup(err); + * } + * ``` + * + * It is important that you handle the rejection case; otherwise it will become an unhandled Promise + * rejection, which is a serious error on some platforms. The Promise is not created unless you + * request it, so if you never call `waitForInitialization()` then you do not have to worry about + * unhandled rejections. + * + * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` + * indicates success, and `"failed"` indicates failure. + * + * @returns + * A Promise that will be resolved if the client initializes successfully, or rejected if it + * fails. + */ + waitForInitialization(): Promise; + + /** + * Identifies a context to LaunchDarkly. + * + * Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state, + * which is set at initialization time. You only need to call `identify()` if the context has changed + * since then. + * + * Changing the current context also causes all feature flag values to be reloaded. Until that has + * finished, calls to {@link variation} will still return flag values for the previous context. You can + * use a callback or a Promise to determine when the new flag values are available. + * + * @param context + * The context properties. Must contain at least the `key` property. + * @param hash + * The signed context key if you are using [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). + * @param onDone + * A function which will be called as soon as the flag values for the new context are available, + * with two parameters: an error value (if any), and an {@link LDFlagSet} containing the new values + * (which can also be obtained by calling {@link variation}). If the callback is omitted, you will + * receive a Promise instead. + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolve once the flag + * values for the new context are available, providing an {@link LDFlagSet} containing the new values + * (which can also be obtained by calling {@link variation}). + */ + identify( + context: LDContext, + hash?: string, + onDone?: (err: Error | null, flags: LDFlagSet | null) => void + ): Promise; + + /** + * Returns the client's current context. + * + * This is the context that was most recently passed to {@link identify}, or, if {@link identify} has never + * been called, the initial context specified when the client was created. + */ + getContext(): LDContext; + + /** + * Flushes all pending analytics events. + * + * Normally, batches of events are delivered in the background at intervals determined by the + * `flushInterval` property of {@link LDOptions}. Calling `flush()` triggers an immediate delivery. + * + * @param onDone + * A function which will be called when the flush completes. If omitted, you + * will receive a Promise instead. + * + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolves once + * flushing is finished. Note that the Promise will be rejected if the HTTP request + * fails, so be sure to attach a rejection handler to it. + */ + flush(onDone?: () => void): Promise; + + /** + * Determines the variation of a feature flag for the current context. + * + * In the client-side JavaScript SDKs, this is always a fast synchronous operation because all of + * the feature flag values for the current context have already been loaded into memory. + * + * @param key + * The unique key of the feature flag. + * @param defaultValue + * The default value of the flag, to be used if the value is not available from LaunchDarkly. + * @returns + * The flag's value. + */ + variation(key: string, defaultValue?: LDFlagValue): LDFlagValue; + + /** + * Determines the variation of a feature flag for a context, along with information about how it was + * calculated. + * + * Note that this will only work if you have set `evaluationExplanations` to true in {@link LDOptions}. + * Otherwise, the `reason` property of the result will be null. + * + * The `reason` property of the result will also be included in analytics events, if you are + * capturing detailed event data for this flag. + * + * For more information, see the [SDK reference guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#javascript). + * + * @param key + * The unique key of the feature flag. + * @param defaultValue + * The default value of the flag, to be used if the value is not available from LaunchDarkly. + * + * @returns + * An {@link LDEvaluationDetail} object containing the value and explanation. + */ + variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail; + + /** + * Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClientDom.on}). + * + * This can also be set as the `streaming` property of {@link LDOptions}. + */ + setStreaming(value?: boolean): void; + + /** + * Registers an event listener. + * + * The following event names (keys) are used by the client: + * + * - `"ready"`: The client has finished starting up. This event will be sent regardless + * of whether it successfully connected to LaunchDarkly, or encountered an error + * and had to give up; to distinguish between these cases, see below. + * - `"initialized"`: The client successfully started up and has valid feature flag + * data. This will always be accompanied by `"ready"`. + * - `"failed"`: The client encountered an error that prevented it from connecting to + * LaunchDarkly, such as an invalid environment ID. All flag evaluations will + * therefore receive default values. This will always be accompanied by `"ready"`. + * - `"error"`: General event for any kind of error condition during client operation. + * The callback parameter is an Error object. If you do not listen for "error" + * events, then the errors will be logged with `console.log()`. + * - `"change"`: The client has received new feature flag data. This can happen either + * because you have switched contexts with {@link identify}, or because the client has a + * stream connection and has received a live change to a flag value (see below). + * The callback parameter is an {@link LDFlagChangeset}. + * - `"change:FLAG-KEY"`: The client has received a new value for a specific flag + * whose key is `FLAG-KEY`. The callback receives two parameters: the current (new) + * flag value, and the previous value. This is always accompanied by a general + * `"change"` event as described above; you can listen for either or both. + * + * The `"change"` and `"change:FLAG-KEY"` events have special behavior: by default, the + * client will open a streaming connection to receive live changes if and only if + * you are listening for one of these events. This behavior can be overridden by + * setting `streaming` in {@link LDOptions} or calling {@link LDClientDom.setStreaming}. + * + * @param key + * The name of the event for which to listen. + * @param callback + * The function to execute when the event fires. The callback may or may not + * receive parameters, depending on the type of event. + * @param context + * The `this` context to use for the callback. + */ + on(key: string, callback: (...args: any[]) => void, context?: any): void; + + /** + * Deregisters an event listener. See {@link on} for the available event types. + * + * @param key + * The name of the event for which to stop listening. + * @param callback + * The function to deregister. + * @param context + * The `this` context for the callback, if one was specified for {@link on}. + */ + off(key: string, callback: (...args: any[]) => void, context?: any): void; + + /** + * Track page events to use in goals or A/B tests. + * + * LaunchDarkly automatically tracks pageviews and clicks that are specified in the + * Goals section of their dashboard. This can be used to track custom goals or other + * events that do not currently have goals. + * + * @param key + * The name of the event, which may correspond to a goal in A/B tests. + * @param data + * Additional information to associate with the event. + * @param metricValue + * An optional numeric value that can be used by the LaunchDarkly experimentation + * feature in numeric custom metrics. Can be omitted if this event is used by only + * non-numeric metrics. This field will also be returned as part of the custom event + * for Data Export. + */ + track(key: string, data?: any, metricValue?: number): void; + + /** + * Returns a map of all available flags to the current context's values. + * + * @returns + * An object in which each key is a feature flag key and each value is the flag value. + * Note that there is no way to specify a default value for each flag as there is with + * {@link variation}, so any flag that cannot be evaluated will have a null value. + */ + allFlags(): LDFlagSet; + + /** + * Shuts down the client and releases its resources, after delivering any pending analytics + * events. After the client is closed, all calls to {@link variation} will return default values, + * and it will not make any requests to LaunchDarkly. + * + * @param onDone + * A function which will be called when the operation completes. If omitted, you + * will receive a Promise instead. + * + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolves once + * closing is finished. It will never be rejected. + */ + close(onDone?: () => void): Promise; +} diff --git a/packages/shared/sdk-client/api/LDOptions.ts b/packages/shared/sdk-client/api/LDOptions.ts new file mode 100644 index 000000000..2781c41e4 --- /dev/null +++ b/packages/shared/sdk-client/api/LDOptions.ts @@ -0,0 +1,234 @@ +import { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common'; + +export interface LDOptions { + /** + * An object that will perform logging for the client. + * + * If not specified, the default is to use `basicLogger`. + */ + logger?: LDLogger; + + /** + * 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). + */ + bootstrap?: 'localStorage' | LDFlagSet; + + /** + * The base URL for the LaunchDarkly server. + * + * Most users should use the default value. + */ + baseUrl?: string; + + /** + * The base URL for the LaunchDarkly events server. + * + * Most users should use the default value. + */ + eventsUrl?: string; + + /** + * The base URL for the LaunchDarkly streaming server. + * + * Most users should use the default value. + */ + streamUrl?: string; + + /** + * Whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). + * + * This is equivalent to calling `client.setStreaming()` with the same value. + */ + streaming?: boolean; + + /** + * Whether or not to use the REPORT verb to fetch flag settings. + * + * If this is true, flag settings will be fetched with a REPORT request + * including a JSON entity body with the context object. + * + * Otherwise (by default) a GET request will be issued with the context passed as + * a base64 URL-encoded path parameter. + * + * Do not use unless advised by LaunchDarkly. + */ + useReport?: boolean; + + /** + * Whether or not to include custom HTTP headers when requesting flags from LaunchDarkly. + * + * These are used to send metadata about the SDK (such as the version). They + * are also used to send the application.id and application.version set in + * the options. + * + * This defaults to true (custom headers will be sent). One reason you might + * want to set it to false is that the presence of custom headers causes + * browsers to make an extra OPTIONS request (a CORS preflight check) before + * each flag request, which could affect performance. + */ + sendLDHeaders?: boolean; + + /** + * A transform function for dynamic configuration of HTTP headers. + * + * This method will run last in the header generation sequence, so the function should have + * all system generated headers in case those also need to be modified. + */ + requestHeaderTransform?: (headers: Map) => Map; + + /** + * Whether LaunchDarkly should provide additional information about how flag values were + * calculated. + * + * The additional information will then be available through the client's + * {@link LDClient.variationDetail} method. Since this increases the size of network requests, + * such information is not sent unless you set this option to true. + */ + evaluationReasons?: boolean; + + /** + * Whether to send analytics events back to LaunchDarkly. By default, this is true. + */ + sendEvents?: boolean; + + /** + * Whether all context attributes (except the context key) should be marked as private, and + * not sent to LaunchDarkly in analytics events. + * + * By default, this is false. + */ + allAttributesPrivate?: boolean; + + /** + * Specifies a list of attribute names (either built-in or custom) which should be marked as + * private, and not sent to LaunchDarkly in analytics events. You can also specify this on a + * per-context basis with {@link LDContextMeta.privateAttributes}. + * + * Any contexts sent to LaunchDarkly with this configuration active will have attributes with + * these names removed in analytic events. This is in addition to any attributes that were + * marked as private for an individual context with {@link LDContextMeta.privateAttributes}. + * Setting {@link LDOptions.allAttributesPrivate} to true overrides this. + * + * If and only if a parameter starts with a slash, it is interpreted as a slash-delimited path + * that can denote a nested property within a JSON object. For instance, "/address/street" means + * that if there is an attribute called "address" that is a JSON object, and one of the object's + * properties is "street", the "street" property will be redacted from the analytics data but + * other properties within "address" will still be sent. This syntax also uses the JSON Pointer + * convention of escaping a literal slash character as "~1" and a tilde as "~0". + */ + privateAttributes?: Array; + + /** + * Whether analytics events should be sent only when you call variation (true), or also when you + * call allFlags (false). + * + * By default, this is false (events will be sent in both cases). + */ + sendEventsOnlyForVariation?: boolean; + + /** + * The capacity of the analytics events queue. + * + * The client buffers up to this many events in memory before flushing. If the capacity is exceeded + * before the queue is flushed, events will be discarded. Increasing the capacity means that events + * are less likely to be discarded, at the cost of consuming more memory. Note that in regular usage + * flag evaluations do not produce individual events, only summary counts, so you only need a large + * capacity if you are generating a large number of click, pageview, or identify events (or if you + * are using the event debugger). + * + * The default value is 100. + */ + eventCapacity?: number; + + /** + * The interval in between flushes of the analytics events queue, in milliseconds. + * + * The default value is 2000ms. + */ + flushInterval?: number; + + /** + * How long (in milliseconds) to wait after a failure of the stream connection before trying to + * reconnect. + * + * This only applies if streaming has been enabled by setting {@link streaming} to true or + * subscribing to `"change"` events. The default is 1000ms. + */ + streamReconnectDelay?: number; + + /** + * Set to true to opt out of sending diagnostics data. + * + * Unless `diagnosticOptOut` is set to true, the client will send some diagnostics data to the LaunchDarkly + * servers in order to assist in the development of future SDK improvements. These diagnostics consist of + * an initial payload containing some details of SDK in use, the SDK's configuration, and the platform the + * SDK is being run on, as well as payloads sent periodically with information on irregular occurrences such + * as dropped events. + */ + diagnosticOptOut?: boolean; + + /** + * The interval at which periodic diagnostic data is sent, in milliseconds. + * + * The default is 900000 (every 15 minutes) and the minimum value is 6000. See {@link diagnosticOptOut} + * for more information on the diagnostics data being sent. + */ + diagnosticRecordingInterval?: number; + + /** + * For use by wrapper libraries to set an identifying name for the wrapper being used. + * + * This will be sent as diagnostic information to the LaunchDarkly servers to allow recording + * metrics on the usage of these wrapper libraries. + */ + wrapperName?: string; + + /** + * For use by wrapper libraries to set version to be included alongside `wrapperName`. + * + * If `wrapperName` is unset, this field will be ignored. + */ + wrapperVersion?: string; + + /** + * Information about the application where the LaunchDarkly SDK is running. + */ + application?: { + /** + * A unique identifier representing the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `authentication-service` + */ + id?: string; + + /** + * A unique identifier representing the version of the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix) + */ + version?: string; + }; + + /** + * Inspectors can be used for collecting information for monitoring, analytics, and debugging. + */ + inspectors?: LDInspection[]; +} diff --git a/packages/shared/sdk-client/jest.config.js b/packages/shared/sdk-client/jest.config.js new file mode 100644 index 000000000..f106eb3bc --- /dev/null +++ b/packages/shared/sdk-client/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/__tests__/**/*test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json new file mode 100644 index 000000000..abc87d06a --- /dev/null +++ b/packages/shared/sdk-client/package.json @@ -0,0 +1,54 @@ +{ + "name": "@launchdarkly/js-client-sdk-common", + "version": "0.0.1", + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/sdk-server", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "description": "LaunchDarkly Client SDK for JavaScript - common code", + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "analytics", + "client" + ], + "scripts": { + "doc": "../../../scripts/build-doc.sh .", + "test": "npx jest --ci", + "build": "npx tsc", + "clean": "npx tsc --build --clean", + "lint": "npx eslint . --ext .ts", + "lint:fix": "yarn run lint -- --fix" + }, + "license": "Apache-2.0", + "//": "Pinned semver because 7.5.0 introduced require('util') without the node: prefix", + "dependencies": { + "@launchdarkly/js-sdk-common": "1.0.2", + "semver": "7.4.0" + }, + "devDependencies": { + "@types/jest": "^29.5.2", + "@types/semver": "^7.5.0", + "@typescript-eslint/eslint-plugin": "^5.60.1", + "@typescript-eslint/parser": "^5.60.1", + "eslint": "^8.43.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "jest-diff": "^29.5.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^2.8.8", + "ts-jest": "^29.1.0", + "typedoc": "0.23.26", + "typescript": "^4.6.3" + } +} diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts new file mode 100644 index 000000000..a33ebbe90 --- /dev/null +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -0,0 +1,66 @@ +import { + LDContext, + LDEvaluationDetail, + LDFlagSet, + LDFlagValue, + Platform, +} from '@launchdarkly/js-sdk-common'; +import { LDClientDom } from '../api/LDClientDom'; +import { LDOptions } from '@launchdarkly/js-server-sdk-common'; + +export default class LDClientDomImpl implements LDClientDomImpl { + constructor( + private sdkKey: string, + private platform: Platform, + options: LDOptions + // callbacks: LDClientCallbacks + ) {} + + allFlags(): LDFlagSet { + return undefined; + } + + close(onDone?: () => void): Promise { + return Promise.resolve(undefined); + } + + flush(onDone?: () => void): Promise { + return Promise.resolve(undefined); + } + + getContext(): LDContext { + return undefined; + } + + identify( + context: LDContext, + hash?: string, + onDone?: (err: Error | null, flags: LDFlagSet | null) => void + ): Promise { + return Promise.resolve(undefined); + } + + off(key: string, callback: (...args: any[]) => void, context?: any): void {} + + on(key: string, callback: (...args: any[]) => void, context?: any): void {} + + setStreaming(value?: boolean): void {} + + track(key: string, data?: any, metricValue?: number): void {} + + variation(key: string, defaultValue?: LDFlagValue): LDFlagValue { + return undefined; + } + + variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail { + return undefined; + } + + waitForInitialization(): Promise { + return Promise.resolve(undefined); + } + + waitUntilReady(): Promise { + return Promise.resolve(undefined); + } +} diff --git a/packages/shared/sdk-client/tsconfig.eslint.json b/packages/shared/sdk-client/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/shared/sdk-client/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json new file mode 100644 index 000000000..19e8b1e59 --- /dev/null +++ b/packages/shared/sdk-client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "es6", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "stripInternal": true + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/packages/shared/sdk-client/tsconfig.ref.json b/packages/shared/sdk-client/tsconfig.ref.json new file mode 100644 index 000000000..0c86b2c55 --- /dev/null +++ b/packages/shared/sdk-client/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true + } +} From 4bf4d3cb9dc917e97e57e4e9c37e3cd96f40f957 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 5 Jul 2023 14:30:15 -0700 Subject: [PATCH 02/57] chore: added createSafeLogger --- .../common/src/logging/createSafeLogger.ts | 16 +++++++++++++ packages/shared/common/src/logging/index.ts | 3 ++- .../shared/sdk-client/src/LDClientDomImpl.ts | 23 +++++++++++-------- .../sdk-client/{ => src}/api/LDClientDom.ts | 0 .../sdk-client/{ => src}/api/LDOptions.ts | 0 5 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 packages/shared/common/src/logging/createSafeLogger.ts rename packages/shared/sdk-client/{ => src}/api/LDClientDom.ts (100%) rename packages/shared/sdk-client/{ => src}/api/LDOptions.ts (100%) diff --git a/packages/shared/common/src/logging/createSafeLogger.ts b/packages/shared/common/src/logging/createSafeLogger.ts new file mode 100644 index 000000000..0c3220ac5 --- /dev/null +++ b/packages/shared/common/src/logging/createSafeLogger.ts @@ -0,0 +1,16 @@ +import { format } from 'util'; +import { LDLogger } from '../api'; +import BasicLogger from './BasicLogger'; +import SafeLogger from './SafeLogger'; + +const createSafeLogger = (logger?: LDLogger) => { + const basicLogger = new BasicLogger({ + level: 'info', + // eslint-disable-next-line no-console + destination: console.error, + formatter: format, + }); + return logger ? new SafeLogger(logger, basicLogger) : basicLogger; +}; + +export default createSafeLogger(); diff --git a/packages/shared/common/src/logging/index.ts b/packages/shared/common/src/logging/index.ts index 911683cc1..ee031f1f0 100644 --- a/packages/shared/common/src/logging/index.ts +++ b/packages/shared/common/src/logging/index.ts @@ -1,4 +1,5 @@ import BasicLogger from './BasicLogger'; +import createSafeLogger from './createSafeLogger'; import SafeLogger from './SafeLogger'; -export { BasicLogger, SafeLogger }; +export { BasicLogger, SafeLogger, createSafeLogger }; diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index a33ebbe90..32fc69c23 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -1,20 +1,23 @@ import { + createSafeLogger, LDContext, LDEvaluationDetail, LDFlagSet, LDFlagValue, + LDLogger, Platform, } from '@launchdarkly/js-sdk-common'; -import { LDClientDom } from '../api/LDClientDom'; -import { LDOptions } from '@launchdarkly/js-server-sdk-common'; - -export default class LDClientDomImpl implements LDClientDomImpl { - constructor( - private sdkKey: string, - private platform: Platform, - options: LDOptions - // callbacks: LDClientCallbacks - ) {} +import { LDClientDom } from './api/LDClientDom'; +import { LDOptions } from './api/LDOptions'; + +export default class LDClientDomImpl implements LDClientDom { + logger?: LDLogger; + + constructor(clientSideId: string, context: LDContext, options: LDOptions, platform: Platform) { + const { logger } = options; + + this.logger = createSafeLogger(logger); + } allFlags(): LDFlagSet { return undefined; diff --git a/packages/shared/sdk-client/api/LDClientDom.ts b/packages/shared/sdk-client/src/api/LDClientDom.ts similarity index 100% rename from packages/shared/sdk-client/api/LDClientDom.ts rename to packages/shared/sdk-client/src/api/LDClientDom.ts diff --git a/packages/shared/sdk-client/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts similarity index 100% rename from packages/shared/sdk-client/api/LDOptions.ts rename to packages/shared/sdk-client/src/api/LDOptions.ts From 10fb3c20be084ef9f0c5bb5adff62c59f6fe24b6 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 11 Jul 2023 12:33:53 -0700 Subject: [PATCH 03/57] chore: add LDInspection types. Band aid ts errors for now. Fix export of createSafeLogger. --- .../common/src/logging/createSafeLogger.ts | 2 +- .../shared/sdk-client/src/LDClientDomImpl.ts | 13 ++- .../shared/sdk-client/src/api/LDInspection.ts | 105 ++++++++++++++++++ .../shared/sdk-client/src/api/LDOptions.ts | 3 +- 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 packages/shared/sdk-client/src/api/LDInspection.ts diff --git a/packages/shared/common/src/logging/createSafeLogger.ts b/packages/shared/common/src/logging/createSafeLogger.ts index 0c3220ac5..aec065dd4 100644 --- a/packages/shared/common/src/logging/createSafeLogger.ts +++ b/packages/shared/common/src/logging/createSafeLogger.ts @@ -13,4 +13,4 @@ const createSafeLogger = (logger?: LDLogger) => { return logger ? new SafeLogger(logger, basicLogger) : basicLogger; }; -export default createSafeLogger(); +export default createSafeLogger; diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index 32fc69c23..0469657af 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -20,7 +20,7 @@ export default class LDClientDomImpl implements LDClientDom { } allFlags(): LDFlagSet { - return undefined; + return {}; } close(onDone?: () => void): Promise { @@ -32,7 +32,7 @@ export default class LDClientDomImpl implements LDClientDom { } getContext(): LDContext { - return undefined; + return { kind: 'user', key: 'test-context-1' }; } identify( @@ -40,7 +40,7 @@ export default class LDClientDomImpl implements LDClientDom { hash?: string, onDone?: (err: Error | null, flags: LDFlagSet | null) => void ): Promise { - return Promise.resolve(undefined); + return Promise.resolve({}); } off(key: string, callback: (...args: any[]) => void, context?: any): void {} @@ -56,7 +56,12 @@ export default class LDClientDomImpl implements LDClientDom { } variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail { - return undefined; + const defaultDetail = { + value: defaultValue, + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + }; + return defaultDetail; } waitForInitialization(): Promise { diff --git a/packages/shared/sdk-client/src/api/LDInspection.ts b/packages/shared/sdk-client/src/api/LDInspection.ts new file mode 100644 index 000000000..125a9116c --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDInspection.ts @@ -0,0 +1,105 @@ +import { LDContext, LDEvaluationDetail } from '@launchdarkly/js-sdk-common'; + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag usage. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ + +export interface LDInspectionFlagUsedHandler { + type: 'flag-used'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * This method is called when a flag is accessed via a variation method, or it can be called based on actions in + * wrapper SDKs which have different methods of tracking when a flag was accessed. It is not called when a call is made + * to allFlags. + */ + method: (flagKey: string, flagDetail: LDEvaluationDetail, context: LDContext) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag data. In order to understand the + * current flag state it should be combined with {@link LDInspectionFlagValueChangedHandler}. + * This interface will get the initial flag information, and + * {@link LDInspectionFlagValueChangedHandler} will provide changes to individual flags. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagDetailsChangedHandler { + type: 'flag-details-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * This method is called when the flags in the store are replaced with new flags. It will contain all flags + * regardless of if they have been evaluated. + */ + method: (details: Record) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect changes to flag data, but does not provide the initial + * data. It can be combined with {@link LDInspectionFlagValuesChangedHandler} to track the + * entire flag state. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagDetailChangedHandler { + type: 'flag-detail-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * This method is called when a flag is updated. It will not be called + * when all flags are updated. + */ + method: (flagKey: string, detail: LDEvaluationDetail) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to track current identity state of the SDK. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionIdentifyHandler { + type: 'client-identity-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * This method will be called when an identify operation completes. + */ + method: (context: LDContext) => void; +} + +export type LDInspection = + | LDInspectionFlagUsedHandler + | LDInspectionFlagDetailsChangedHandler + | LDInspectionFlagDetailChangedHandler + | LDInspectionIdentifyHandler; diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 2781c41e4..263dad6a8 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,4 +1,5 @@ -import { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common'; +import type { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common'; +import type { LDInspection } from './LDInspection'; export interface LDOptions { /** From d29a840bbeed9a78e95341812219daf007b95b7e Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 12 Jul 2023 16:02:00 -0700 Subject: [PATCH 04/57] chore: added TypedEventTarget --- .../src/api/platform/TypedEventTarget.ts | 35 +++++++++++++++++++ .../shared/common/src/logging/SafeLogger.ts | 2 +- .../shared/common/src/utils/VoidFunction.ts | 1 + packages/shared/common/src/utils/index.ts | 3 +- .../shared/sdk-client/src/LDClientDomImpl.ts | 8 +++++ 5 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/shared/common/src/api/platform/TypedEventTarget.ts create mode 100644 packages/shared/common/src/utils/VoidFunction.ts diff --git a/packages/shared/common/src/api/platform/TypedEventTarget.ts b/packages/shared/common/src/api/platform/TypedEventTarget.ts new file mode 100644 index 000000000..c2d49fd8b --- /dev/null +++ b/packages/shared/common/src/api/platform/TypedEventTarget.ts @@ -0,0 +1,35 @@ +import { VoidFunction } from '../../utils'; + +export type SdkEvent = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed'; + +/** + * Needs WebApi EventTarget. + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget + * + * In react-native use event-target-shim to polyfill EventTarget. This is safe + * because the react-native repo uses it too. + * https://github.com/mysticatea/event-target-shim + */ +export default class TypedEventTarget extends EventTarget { + private listeners: Map = new Map(); + + public on(e: SdkEvent, listener: Function, extra: any) { + const listenerExtra = () => listener(...arguments, extra); + + if (!this.listeners.has(e)) { + this.listeners.set(e, [listenerExtra]); + } else { + this.listeners.get(e)?.push(listenerExtra); + } + + super.addEventListener(e, listenerExtra); + } + + public off(e: SdkEvent) { + this.listeners.get(e)?.forEach((l) => super.removeEventListener(e, l)); + } + + public emit(e: SdkEvent): boolean { + return super.dispatchEvent(new Event(e)); + } +} diff --git a/packages/shared/common/src/logging/SafeLogger.ts b/packages/shared/common/src/logging/SafeLogger.ts index ec67a43e8..8b7b84289 100644 --- a/packages/shared/common/src/logging/SafeLogger.ts +++ b/packages/shared/common/src/logging/SafeLogger.ts @@ -1,4 +1,4 @@ -import { LDLogger } from '../api'; +import type { LDLogger } from '../api'; import { TypeValidators } from '../validators'; const loggerRequirements = { diff --git a/packages/shared/common/src/utils/VoidFunction.ts b/packages/shared/common/src/utils/VoidFunction.ts new file mode 100644 index 000000000..835bc84b2 --- /dev/null +++ b/packages/shared/common/src/utils/VoidFunction.ts @@ -0,0 +1 @@ +export type VoidFunction = () => void; diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index fb04a0d2f..a6a867598 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,4 +1,5 @@ import noop from './noop'; +import { VoidFunction } from './VoidFunction'; // eslint-disable-next-line import/prefer-default-export -export { noop }; +export { noop, VoidFunction }; diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index 0469657af..ec1ab74a7 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -13,6 +13,14 @@ import { LDOptions } from './api/LDOptions'; export default class LDClientDomImpl implements LDClientDom { logger?: LDLogger; + /** + * Immediately return an LDClient instance. No async or remote calls. + * + * @param clientSideId + * @param context + * @param options + * @param platform + */ constructor(clientSideId: string, context: LDContext, options: LDOptions, platform: Platform) { const { logger } = options; From cb891abf9e9741bc929089dd4d856b3e9fbf4c0b Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 13 Jul 2023 12:09:26 -0700 Subject: [PATCH 05/57] chore: move TypedEventTarget to sdk-client. It's incomplete. --- .../src/api/platform/TypedEventTarget.ts | 35 ------------ .../sdk-client/src/api/TypedEventTarget.ts | 57 +++++++++++++++++++ packages/shared/sdk-client/tsconfig.json | 2 +- 3 files changed, 58 insertions(+), 36 deletions(-) delete mode 100644 packages/shared/common/src/api/platform/TypedEventTarget.ts create mode 100644 packages/shared/sdk-client/src/api/TypedEventTarget.ts diff --git a/packages/shared/common/src/api/platform/TypedEventTarget.ts b/packages/shared/common/src/api/platform/TypedEventTarget.ts deleted file mode 100644 index c2d49fd8b..000000000 --- a/packages/shared/common/src/api/platform/TypedEventTarget.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { VoidFunction } from '../../utils'; - -export type SdkEvent = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed'; - -/** - * Needs WebApi EventTarget. - * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget - * - * In react-native use event-target-shim to polyfill EventTarget. This is safe - * because the react-native repo uses it too. - * https://github.com/mysticatea/event-target-shim - */ -export default class TypedEventTarget extends EventTarget { - private listeners: Map = new Map(); - - public on(e: SdkEvent, listener: Function, extra: any) { - const listenerExtra = () => listener(...arguments, extra); - - if (!this.listeners.has(e)) { - this.listeners.set(e, [listenerExtra]); - } else { - this.listeners.get(e)?.push(listenerExtra); - } - - super.addEventListener(e, listenerExtra); - } - - public off(e: SdkEvent) { - this.listeners.get(e)?.forEach((l) => super.removeEventListener(e, l)); - } - - public emit(e: SdkEvent): boolean { - return super.dispatchEvent(new Event(e)); - } -} diff --git a/packages/shared/sdk-client/src/api/TypedEventTarget.ts b/packages/shared/sdk-client/src/api/TypedEventTarget.ts new file mode 100644 index 000000000..d9f79cda1 --- /dev/null +++ b/packages/shared/sdk-client/src/api/TypedEventTarget.ts @@ -0,0 +1,57 @@ +export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed'; + +// export type EventListener = (evt: Event) => void; + +// rn does not have CustomEvent so this is our own polyfill +export class CustomEvent extends Event { + detail: any[]; + + constructor(e: string, ...rest: any[]) { + super(e); + this.detail = rest; + } +} + +/** + * Needs WebApi EventTarget. + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget + * + * In react-native use event-target-shim to polyfill EventTarget. This is safe + * because the react-native repo uses it too. + * https://github.com/mysticatea/event-target-shim + */ +export default class TypedEventTarget extends EventTarget { + private listeners: Map = new Map(); + + /** + * Cache all listeners in a Map so we can remove them later + * @param e EventName + * @param listener The event handler + * @private + */ + private saveListener(e: EventName, listener: Function) { + if (!this.listeners.has(e)) { + this.listeners.set(e, [listener]); + } else { + this.listeners.get(e)?.push(listener); + } + } + + public on(name: EventName, listener: Function) { + this.on(new CustomEvent(name)); + } + + public on(e: CustomEvent, listener: Function) { + const x = () => listener(...listener.arguments, ...e.detail); + this.saveListener(e.type as EventName, x); + super.addEventListener(e.type, x); + } + + public off(e: EventName) { + this.listeners.get(e)?.forEach((l) => super.removeEventListener(e, l)); + } + + public emit(e: EventName, ...rest: any[]): boolean { + return super.dispatchEvent(new CustomEvent(e, ...rest)); + } +} diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json index 19e8b1e59..b1088a2d8 100644 --- a/packages/shared/sdk-client/tsconfig.json +++ b/packages/shared/sdk-client/tsconfig.json @@ -3,7 +3,7 @@ "rootDir": "src", "outDir": "dist", "target": "es6", - "lib": ["es6"], + "lib": ["es6", "DOM"], "module": "commonjs", "strict": true, "noImplicitOverride": true, From 147c5da9f2ed937f971cf18af62a0cd4425c24e4 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 13 Jul 2023 12:39:39 -0700 Subject: [PATCH 06/57] chore: complete EventTarget dom implementation --- .../sdk-client/src/api/LDEventTarget.ts | 48 ++++++++++++++++ .../sdk-client/src/api/TypedEventTarget.ts | 57 ------------------- 2 files changed, 48 insertions(+), 57 deletions(-) create mode 100644 packages/shared/sdk-client/src/api/LDEventTarget.ts delete mode 100644 packages/shared/sdk-client/src/api/TypedEventTarget.ts diff --git a/packages/shared/sdk-client/src/api/LDEventTarget.ts b/packages/shared/sdk-client/src/api/LDEventTarget.ts new file mode 100644 index 000000000..95a87f940 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDEventTarget.ts @@ -0,0 +1,48 @@ +export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed'; + +/** + * Needs WebApi EventTarget. + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget + * + * In react-native use event-target-shim to polyfill EventTarget. This is safe + * because the react-native repo uses it too. + * https://github.com/mysticatea/event-target-shim + */ +export default class LDEventTarget extends EventTarget { + private listeners: Map = new Map(); + + /** + * Cache all listeners in a Map so we can remove them later + * @param e EventName + * @param listener The event handler + * @private + */ + private saveListener(name: EventName, listener: EventListener) { + if (!this.listeners.has(name)) { + this.listeners.set(name, [listener]); + } else { + this.listeners.get(name)?.push(listener); + } + } + + public on(name: EventName, listener: Function) { + const customListener = (e: Event) => { + const { detail } = e as CustomEvent; + + // invoke listener with additional args from CustomEvent.detail + listener(...listener.arguments, ...detail); + }; + this.saveListener(name, customListener); + + super.addEventListener(name, customListener); + } + + public off(name: EventName) { + this.listeners.get(name)?.forEach((l) => super.removeEventListener(name, l)); + } + + public emit(name: EventName, ...detail: any[]): boolean { + const c = new CustomEvent(name, { detail }); + return super.dispatchEvent(c); + } +} diff --git a/packages/shared/sdk-client/src/api/TypedEventTarget.ts b/packages/shared/sdk-client/src/api/TypedEventTarget.ts deleted file mode 100644 index d9f79cda1..000000000 --- a/packages/shared/sdk-client/src/api/TypedEventTarget.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed'; - -// export type EventListener = (evt: Event) => void; - -// rn does not have CustomEvent so this is our own polyfill -export class CustomEvent extends Event { - detail: any[]; - - constructor(e: string, ...rest: any[]) { - super(e); - this.detail = rest; - } -} - -/** - * Needs WebApi EventTarget. - * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget - * - * In react-native use event-target-shim to polyfill EventTarget. This is safe - * because the react-native repo uses it too. - * https://github.com/mysticatea/event-target-shim - */ -export default class TypedEventTarget extends EventTarget { - private listeners: Map = new Map(); - - /** - * Cache all listeners in a Map so we can remove them later - * @param e EventName - * @param listener The event handler - * @private - */ - private saveListener(e: EventName, listener: Function) { - if (!this.listeners.has(e)) { - this.listeners.set(e, [listener]); - } else { - this.listeners.get(e)?.push(listener); - } - } - - public on(name: EventName, listener: Function) { - this.on(new CustomEvent(name)); - } - - public on(e: CustomEvent, listener: Function) { - const x = () => listener(...listener.arguments, ...e.detail); - this.saveListener(e.type as EventName, x); - super.addEventListener(e.type, x); - } - - public off(e: EventName) { - this.listeners.get(e)?.forEach((l) => super.removeEventListener(e, l)); - } - - public emit(e: EventName, ...rest: any[]): boolean { - return super.dispatchEvent(new CustomEvent(e, ...rest)); - } -} From f539c7a52f780f8dce0098c86fdc41e8ace14abd Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 18 Jul 2023 15:02:58 -0700 Subject: [PATCH 07/57] chore: improved LDEmitter (#207) * Improved emitter design * Improved jest config * Added unit tests --- .../sdk-client/jest-setupFilesAfterEnv.ts | 1 + packages/shared/sdk-client/jest.config.js | 7 -- packages/shared/sdk-client/jest.config.json | 10 +++ packages/shared/sdk-client/package.json | 27 ++++--- .../sdk-client/src/api/LDEmitter.test.ts | 77 +++++++++++++++++++ .../shared/sdk-client/src/api/LDEmitter.ts | 58 ++++++++++++++ .../sdk-client/src/api/LDEventTarget.ts | 48 ------------ packages/shared/sdk-client/tsconfig.json | 1 + 8 files changed, 162 insertions(+), 67 deletions(-) create mode 100644 packages/shared/sdk-client/jest-setupFilesAfterEnv.ts delete mode 100644 packages/shared/sdk-client/jest.config.js create mode 100644 packages/shared/sdk-client/jest.config.json create mode 100644 packages/shared/sdk-client/src/api/LDEmitter.test.ts create mode 100644 packages/shared/sdk-client/src/api/LDEmitter.ts delete mode 100644 packages/shared/sdk-client/src/api/LDEventTarget.ts diff --git a/packages/shared/sdk-client/jest-setupFilesAfterEnv.ts b/packages/shared/sdk-client/jest-setupFilesAfterEnv.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/packages/shared/sdk-client/jest-setupFilesAfterEnv.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/shared/sdk-client/jest.config.js b/packages/shared/sdk-client/jest.config.js deleted file mode 100644 index f106eb3bc..000000000 --- a/packages/shared/sdk-client/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - transform: { '^.+\\.ts?$': 'ts-jest' }, - testMatch: ['**/__tests__/**/*test.ts?(x)'], - testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - collectCoverageFrom: ['src/**/*.ts'], -}; diff --git a/packages/shared/sdk-client/jest.config.json b/packages/shared/sdk-client/jest.config.json new file mode 100644 index 000000000..65ddc27dd --- /dev/null +++ b/packages/shared/sdk-client/jest.config.json @@ -0,0 +1,10 @@ +{ + "transform": { "^.+\\.ts?$": "ts-jest" }, + "testMatch": ["**/*.test.ts?(x)"], + "testPathIgnorePatterns": ["node_modules", "example", "dist"], + "modulePathIgnorePatterns": ["dist"], + "testEnvironment": "jsdom", + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], + "collectCoverageFrom": ["src/**/*.ts"], + "setupFilesAfterEnv": ["./jest-setupFilesAfterEnv.ts"] +} diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index abc87d06a..c923e095d 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -30,25 +30,28 @@ "//": "Pinned semver because 7.5.0 introduced require('util') without the node: prefix", "dependencies": { "@launchdarkly/js-sdk-common": "1.0.2", - "semver": "7.4.0" + "semver": "7.5.4" }, "devDependencies": { - "@types/jest": "^29.5.2", + "@testing-library/dom": "^9.3.1", + "@testing-library/jest-dom": "^5.16.5", + "@types/jest": "^29.5.3", "@types/semver": "^7.5.0", - "@typescript-eslint/eslint-plugin": "^5.60.1", - "@typescript-eslint/parser": "^5.60.1", - "eslint": "^8.43.0", + "@typescript-eslint/eslint-plugin": "^6.1.0", + "@typescript-eslint/parser": "^6.1.0", + "eslint": "^8.45.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.27.5", - "eslint-plugin-prettier": "^4.2.1", - "jest": "^29.5.0", - "jest-diff": "^29.5.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.6.1", + "jest-diff": "^29.6.1", + "jest-environment-jsdom": "^29.6.1", "launchdarkly-js-test-helpers": "^2.2.0", - "prettier": "^2.8.8", - "ts-jest": "^29.1.0", + "prettier": "^3.0.0", + "ts-jest": "^29.1.1", "typedoc": "0.23.26", - "typescript": "^4.6.3" + "typescript": "^5.1.6" } } diff --git a/packages/shared/sdk-client/src/api/LDEmitter.test.ts b/packages/shared/sdk-client/src/api/LDEmitter.test.ts new file mode 100644 index 000000000..cba0d4bcd --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDEmitter.test.ts @@ -0,0 +1,77 @@ +import LDEmitter from './LDEmitter'; + +describe('LDEmitter', () => { + const error = { type: 'network', message: 'unreachable' }; + let emitter: LDEmitter; + + beforeEach(() => { + jest.resetAllMocks(); + emitter = new LDEmitter(); + }); + + test('subscribe and handle', () => { + const errorHandler1 = jest.fn(); + const errorHandler2 = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('error', errorHandler2); + emitter.emit('error', error); + + expect(errorHandler1).toHaveBeenCalledWith(error); + expect(errorHandler2).toHaveBeenCalledWith(error); + }); + + test('unsubscribe and handle', () => { + const errorHandler1 = jest.fn(); + const errorHandler2 = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('error', errorHandler2); + emitter.off('error'); + emitter.emit('error', error); + + expect(errorHandler1).not.toHaveBeenCalled(); + expect(errorHandler2).not.toHaveBeenCalled(); + expect(emitter.listenerCount('error')).toEqual(0); + }); + + test('unsubscribing an event should not affect other events', () => { + const errorHandler = jest.fn(); + const changeHandler = jest.fn(); + + emitter.on('error', errorHandler); + emitter.on('change', changeHandler); + emitter.off('error'); // unsubscribe error handler + emitter.emit('error', error); + emitter.emit('change'); + + // change handler should still be affective + expect(changeHandler).toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + test('eventNames', () => { + const errorHandler1 = jest.fn(); + const changeHandler = jest.fn(); + const readyHandler = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('change', changeHandler); + emitter.on('ready', readyHandler); + + expect(emitter.eventNames()).toEqual(['error', 'change', 'ready']); + }); + + test('listener count', () => { + const errorHandler1 = jest.fn(); + const errorHandler2 = jest.fn(); + const changeHandler = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('error', errorHandler2); + emitter.on('change', changeHandler); + + expect(emitter.listenerCount('error')).toEqual(2); + expect(emitter.listenerCount('change')).toEqual(1); + }); +}); diff --git a/packages/shared/sdk-client/src/api/LDEmitter.ts b/packages/shared/sdk-client/src/api/LDEmitter.ts new file mode 100644 index 000000000..5d587f874 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDEmitter.ts @@ -0,0 +1,58 @@ +export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed' | 'error'; + +/** + * This is an event emitter using the standard built-in EventTarget web api. + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget + * + * In react-native use event-target-shim to polyfill EventTarget. This is safe + * because the react-native repo uses it too. + * https://github.com/mysticatea/event-target-shim + */ +export default class LDEmitter { + private et: EventTarget = new EventTarget(); + private listeners: Map = new Map(); + + /** + * Cache all listeners in a Map so we can remove them later + * @param name string event name + * @param listener function to handle the event + * @private + */ + private saveListener(name: EventName, listener: EventListener) { + if (!this.listeners.has(name)) { + this.listeners.set(name, [listener]); + } else { + this.listeners.get(name)?.push(listener); + } + } + + on(name: EventName, listener: Function) { + const customListener = (e: Event) => { + const { detail } = e as CustomEvent; + + // invoke listener with args from CustomEvent + listener(...detail); + }; + this.saveListener(name, customListener); + this.et.addEventListener(name, customListener); + } + + off(name: EventName) { + this.listeners.get(name)?.forEach((l) => { + this.et.removeEventListener(name, l); + }); + this.listeners.delete(name); + } + + emit(name: EventName, ...detail: any[]) { + this.et.dispatchEvent(new CustomEvent(name, { detail })); + } + + eventNames(): string[] { + return [...this.listeners.keys()]; + } + + listenerCount(name: EventName): number { + return this.listeners.get(name)?.length ?? 0; + } +} diff --git a/packages/shared/sdk-client/src/api/LDEventTarget.ts b/packages/shared/sdk-client/src/api/LDEventTarget.ts deleted file mode 100644 index 95a87f940..000000000 --- a/packages/shared/sdk-client/src/api/LDEventTarget.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed'; - -/** - * Needs WebApi EventTarget. - * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget - * - * In react-native use event-target-shim to polyfill EventTarget. This is safe - * because the react-native repo uses it too. - * https://github.com/mysticatea/event-target-shim - */ -export default class LDEventTarget extends EventTarget { - private listeners: Map = new Map(); - - /** - * Cache all listeners in a Map so we can remove them later - * @param e EventName - * @param listener The event handler - * @private - */ - private saveListener(name: EventName, listener: EventListener) { - if (!this.listeners.has(name)) { - this.listeners.set(name, [listener]); - } else { - this.listeners.get(name)?.push(listener); - } - } - - public on(name: EventName, listener: Function) { - const customListener = (e: Event) => { - const { detail } = e as CustomEvent; - - // invoke listener with additional args from CustomEvent.detail - listener(...listener.arguments, ...detail); - }; - this.saveListener(name, customListener); - - super.addEventListener(name, customListener); - } - - public off(name: EventName) { - this.listeners.get(name)?.forEach((l) => super.removeEventListener(name, l)); - } - - public emit(name: EventName, ...detail: any[]): boolean { - const c = new CustomEvent(name, { detail }); - return super.dispatchEvent(c); - } -} diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json index b1088a2d8..dd1ef125d 100644 --- a/packages/shared/sdk-client/tsconfig.json +++ b/packages/shared/sdk-client/tsconfig.json @@ -14,5 +14,6 @@ "declarationMap": true, // enables importers to jump to source "stripInternal": true }, + "include": ["src", "./jest-setupFilesAfterEnv.ts"], "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] } From 4f416b3128b507fd846c94ac4f1655adbba67ec3 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 19 Jul 2023 12:26:03 -0700 Subject: [PATCH 08/57] fix: eslint errors --- .eslintrc.js | 7 +++++++ packages/shared/sdk-client/package.json | 4 ++-- packages/shared/sdk-client/src/LDClientDomImpl.ts | 4 +++- packages/shared/sdk-client/src/api/LDEmitter.ts | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 6dd1a6cc9..5436b3e12 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,5 +12,12 @@ module.exports = { rules: { 'prettier/prettier': ['error'], 'class-methods-use-this': 'off', + 'import/no-extraneous-dependencies': [ + 'error', + { + // solves '@testing-library/jest-dom' should be listed in the project's dependencies, not devDependencies + devDependencies: ['**/jest*.ts'], + }, + ], }, }; diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index c923e095d..1eab984ba 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -24,10 +24,10 @@ "build": "npx tsc", "clean": "npx tsc --build --clean", "lint": "npx eslint . --ext .ts", - "lint:fix": "yarn run lint -- --fix" + "lint:fix": "yarn run lint -- --fix", + "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'" }, "license": "Apache-2.0", - "//": "Pinned semver because 7.5.0 introduced require('util') without the node: prefix", "dependencies": { "@launchdarkly/js-sdk-common": "1.0.2", "semver": "7.5.4" diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index ec1ab74a7..1cf286fc5 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -1,3 +1,5 @@ +// temporarily allow unused vars for the duration of the migration +/* eslint-disable @typescript-eslint/no-unused-vars */ import { createSafeLogger, LDContext, @@ -46,7 +48,7 @@ export default class LDClientDomImpl implements LDClientDom { identify( context: LDContext, hash?: string, - onDone?: (err: Error | null, flags: LDFlagSet | null) => void + onDone?: (err: Error | null, flags: LDFlagSet | null) => void, ): Promise { return Promise.resolve({}); } diff --git a/packages/shared/sdk-client/src/api/LDEmitter.ts b/packages/shared/sdk-client/src/api/LDEmitter.ts index 5d587f874..d0da6112a 100644 --- a/packages/shared/sdk-client/src/api/LDEmitter.ts +++ b/packages/shared/sdk-client/src/api/LDEmitter.ts @@ -10,6 +10,7 @@ export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | */ export default class LDEmitter { private et: EventTarget = new EventTarget(); + private listeners: Map = new Map(); /** From 5c9226fe4c7f6da846443387f24eca14f52cb410 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 19 Jul 2023 12:30:10 -0700 Subject: [PATCH 09/57] chore: add prettier and check commands --- packages/shared/sdk-client/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index 1eab984ba..952bc4652 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -25,7 +25,8 @@ "clean": "npx tsc --build --clean", "lint": "npx eslint . --ext .ts", "lint:fix": "yarn run lint -- --fix", - "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'" + "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", + "check": "yarn && yarn prettier && yarn lint && tsc && yarn test" }, "license": "Apache-2.0", "dependencies": { From b78c95f005b157af1e2ee11c1999f82734fac125 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 24 Jul 2023 14:02:35 -0700 Subject: [PATCH 10/57] feat: Add migrationVariation method. (#212) --- .../__tests__/LDClient.migrations.test.ts | 105 ++++++++++++++++++ packages/shared/sdk-server/package.json | 2 +- .../shared/sdk-server/src/LDClientImpl.ts | 27 ++++- .../shared/sdk-server/src/api/LDClient.ts | 23 ++++ .../src/api/data/LDMigrationStage.ts | 12 ++ .../shared/sdk-server/src/api/data/index.ts | 1 + 6 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts create mode 100644 packages/shared/sdk-server/src/api/data/LDMigrationStage.ts diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts new file mode 100644 index 000000000..a24a0214e --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -0,0 +1,105 @@ +import { LDClientImpl, LDMigrationStage } from '../src'; +import { LDClientCallbacks } from '../src/LDClientImpl'; +import TestData from '../src/integrations/test_data/TestData'; +import basicPlatform from './evaluation/mocks/platform'; + +/** + * Basic callback handler that records errors for tests. + */ +export default function makeCallbacks(): [Error[], LDClientCallbacks] { + const errors: Error[] = []; + return [ + errors, + { + onError: (error) => { + errors.push(error); + }, + onFailed: () => {}, + onReady: () => {}, + onUpdate: () => {}, + hasEventListeners: () => true, + }, + ]; +} + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + let callbacks: LDClientCallbacks; + let errors: Error[]; + + beforeEach(async () => { + td = new TestData(); + [errors, callbacks] = makeCallbacks(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + }, + callbacks + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it.each(['off', 'dualwrite', 'shadow', 'live', 'rampdown', 'complete'])( + 'handles valid migration stages: %p', + async (value) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(value)); + // Get a default value that is not the value under test. + const defaultValue = Object.values(LDMigrationStage).find((item) => item !== value); + // Verify the pre-condition that the default value is not the value under test. + expect(defaultValue).not.toEqual(value); + const res = await client.variationMigration( + flagKey, + { key: 'test-key' }, + defaultValue as LDMigrationStage + ); + expect(res).toEqual(value); + } + ); + + it.each([ + LDMigrationStage.Off, + LDMigrationStage.DualWrite, + LDMigrationStage.Shadow, + LDMigrationStage.Live, + LDMigrationStage.Rampdown, + LDMigrationStage.Complete, + ])('returns the default value if the flag does not exist: default = %p', async (stage) => { + const res = await client.variationMigration('no-flag', { key: 'test-key' }, stage); + + expect(res).toEqual(stage); + }); + + it('produces an error event for a migration flag with an incorrect value', async () => { + const flagKey = 'bad-migration'; + td.update(td.flag(flagKey).valueForAll('potato')); + const res = await client.variationMigration(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + expect(res).toEqual(LDMigrationStage.Off); + expect(errors.length).toEqual(1); + expect(errors[0].message).toEqual( + 'Unrecognized MigrationState for "bad-migration"; returning default value.' + ); + }); + + it('includes an error in the node callback', (done) => { + const flagKey = 'bad-migration'; + td.update(td.flag(flagKey).valueForAll('potato')); + client.variationMigration(flagKey, { key: 'test-key' }, LDMigrationStage.Off, (err, value) => { + const error = err as Error; + expect(error.message).toEqual( + 'Unrecognized MigrationState for "bad-migration"; returning default value.' + ); + expect(value).toEqual(LDMigrationStage.Off); + done(); + }); + }); +}); diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index 0e44d1d6c..2080bca76 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -24,7 +24,7 @@ "build": "npx tsc", "clean": "npx tsc --build --clean", "lint": "npx eslint . --ext .ts", - "lint:fix": "yarn run lint -- --fix" + "lint:fix": "yarn run lint --fix" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 2367dc0e3..51a045235 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -10,7 +10,15 @@ import { subsystem, internal, } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDFlagsStateOptions, LDOptions, LDStreamProcessor, LDFlagsState } from './api'; +import { + LDClient, + LDFlagsStateOptions, + LDOptions, + LDStreamProcessor, + LDFlagsState, + LDMigrationStage, + IsMigrationStage, +} from './api'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -260,6 +268,23 @@ export default class LDClientImpl implements LDClient { return res.detail; } + async variationMigration( + key: string, + context: LDContext, + defaultValue: LDMigrationStage, + callback?: (err: any, res: LDMigrationStage) => void + ): Promise { + const stringValue = await this.variation(key, context, defaultValue as string); + if (!IsMigrationStage(stringValue)) { + const error = new Error(`Unrecognized MigrationState for "${key}"; returning default value.`); + this.onError(error); + callback?.(error, defaultValue); + return defaultValue; + } + callback?.(null, stringValue as LDMigrationStage); + return stringValue as LDMigrationStage; + } + async allFlagsState( context: LDContext, options?: LDFlagsStateOptions, diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index e36e5843f..76fab37f5 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -1,6 +1,7 @@ import { LDContext, LDEvaluationDetail, LDFlagValue } from '@launchdarkly/js-sdk-common'; import { LDFlagsStateOptions } from './data/LDFlagsStateOptions'; import { LDFlagsState } from './data/LDFlagsState'; +import { LDMigrationStage } from './data/LDMigrationStage'; /** * The LaunchDarkly SDK client object. @@ -119,6 +120,28 @@ export interface LDClient { callback?: (err: any, res: LDEvaluationDetail) => void ): Promise; + /** + * TKTK: Should use a common description. + * + * If the evaluated value of the flag cannot be converted to an LDMigrationStage, then an error + * event will be raised. + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @param callback A Node-style callback to receive the result (as an {@link LDMigrationStage}). + * @returns + * A Promise which will be resolved with the result (as an{@link LDMigrationStage}). + */ + variationMigration( + key: string, + context: LDContext, + defaultValue: LDMigrationStage, + callback?: (err: any, res: LDMigrationStage) => void + ): Promise; + /** * Builds an object that encapsulates the state of all feature flags for a given context. * This includes the flag values and also metadata that can be used on the front end. This diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts new file mode 100644 index 000000000..c582a4f7c --- /dev/null +++ b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts @@ -0,0 +1,12 @@ +export enum LDMigrationStage { + Off = 'off', + DualWrite = 'dualwrite', + Shadow = 'shadow', + Live = 'live', + Rampdown = 'rampdown', + Complete = 'complete', +} + +export function IsMigrationStage(value: string): boolean { + return Object.values(LDMigrationStage).includes(value as LDMigrationStage); +} diff --git a/packages/shared/sdk-server/src/api/data/index.ts b/packages/shared/sdk-server/src/api/data/index.ts index c0bd06c1e..cb75a1bc7 100644 --- a/packages/shared/sdk-server/src/api/data/index.ts +++ b/packages/shared/sdk-server/src/api/data/index.ts @@ -1,2 +1,3 @@ export * from './LDFlagsStateOptions'; export * from './LDFlagsState'; +export * from './LDMigrationStage'; From b48f84785f670ea1e5c937bc016f9a3e29a49bb5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 26 Jul 2023 12:25:01 -0700 Subject: [PATCH 11/57] feat: Add migration configuration and basic migration. (#213) --- .../__tests__/LDClient.migrations.test.ts | 2 +- .../sdk-server/__tests__/Migration.test.ts | 514 ++++++++++++++++++ packages/shared/sdk-server/src/Migration.ts | 247 +++++++++ .../shared/sdk-server/src/api/LDMigration.ts | 87 +++ .../src/api/data/LDMigrationStage.ts | 2 +- .../src/api/options/LDMigrationOptions.ts | 127 +++++ .../sdk-server/src/api/options/index.ts | 1 + 7 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/Migration.test.ts create mode 100644 packages/shared/sdk-server/src/Migration.ts create mode 100644 packages/shared/sdk-server/src/api/LDMigration.ts create mode 100644 packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index a24a0214e..7d6c3af70 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -71,7 +71,7 @@ describe('given an LDClient with test data', () => { LDMigrationStage.DualWrite, LDMigrationStage.Shadow, LDMigrationStage.Live, - LDMigrationStage.Rampdown, + LDMigrationStage.RampDown, LDMigrationStage.Complete, ])('returns the default value if the flag does not exist: default = %p', async (stage) => { const res = await client.variationMigration('no-flag', { key: 'test-key' }, stage); diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts new file mode 100644 index 000000000..22cc76b1c --- /dev/null +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -0,0 +1,514 @@ +import { + LDClientImpl, + LDConcurrentExecution, + LDErrorTracking, + LDExecutionOrdering, + LDLatencyTracking, + LDMigrationStage, + LDSerialExecution, +} from '../src'; +import { LDClientCallbacks } from '../src/LDClientImpl'; +import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import { TestData } from '../src/integrations'; +import basicPlatform from './evaluation/mocks/platform'; +import makeCallbacks from './makeCallbacks'; + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + let callbacks: LDClientCallbacks; + + beforeEach(async () => { + td = new TestData(); + callbacks = makeCallbacks(false); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + }, + callbacks + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + /** Custom matcher for write results. */ + expect.extend({ + toMatchMigrationResult(received, expected) { + const { authoritative, nonAuthoritative } = expected; + const { authoritative: actualAuth, nonAuthoritative: actualNonAuth } = received; + + if (authoritative.origin !== actualAuth.origin) { + return { + pass: false, + message: () => + `Expected authoritative origin: ${authoritative.origin}, but received: ${actualAuth.origin}`, + }; + } + if (authoritative.success !== actualAuth.success) { + return { + pass: false, + message: () => `Expected authoritative success, but received error: ${actualAuth.error}`, + }; + } + if (authoritative.success) { + if (actualAuth.result !== authoritative.result) { + return { + pass: false, + message: () => + `Expected authoritative result: ${authoritative.result}, received: ${actualAuth.result}`, + }; + } + } else if (actualAuth.error?.message !== authoritative.error?.message) { + return { + pass: false, + message: () => + `Expected authoritative error: ${authoritative.error?.message}, received: ${actualAuth.error?.message}`, + }; + } + if (nonAuthoritative) { + if (!actualNonAuth) { + return { + pass: false, + message: () => `Expected no authoritative result, but did not receive one.`, + }; + } + if (nonAuthoritative.origin !== actualNonAuth.origin) { + return { + pass: false, + message: () => + `Expected non-authoritative origin: ${nonAuthoritative.origin}, but received: ${actualNonAuth.origin}`, + }; + } + if (nonAuthoritative.success !== actualNonAuth.success) { + return { + pass: false, + message: () => + `Expected authoritative success, but received error: ${actualNonAuth.error}`, + }; + } + if (nonAuthoritative.success) { + if (actualNonAuth.result !== nonAuthoritative.result) { + return { + pass: false, + message: () => + `Expected non-authoritative result: ${nonAuthoritative.result}, received: ${actualNonAuth.result}`, + }; + } + } else if (actualNonAuth.error?.message !== nonAuthoritative.error?.message) { + return { + pass: false, + message: () => + `Expected nonauthoritative error: ${nonAuthoritative.error?.message}, error: ${actualNonAuth.error?.message}`, + }; + } + } else if (actualNonAuth) { + return { + pass: false, + message: () => `Expected no non-authoritative result, received: ${actualNonAuth}`, + }; + } + return { pass: true, message: () => '' }; + }, + }); + + describe.each([ + [new LDSerialExecution(LDExecutionOrdering.Fixed), 'serial fixed'], + [new LDSerialExecution(LDExecutionOrdering.Random), 'serial random'], + [new LDConcurrentExecution(), 'concurrent'], + ])('given different execution methods: %p %p', (execution) => { + it.each([ + [ + LDMigrationStage.Off, + 'old', + { + authoritative: { origin: 'old', result: true, success: true }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.DualWrite, + 'old', + { + authoritative: { origin: 'old', result: true, success: true }, + nonAuthoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.Shadow, + 'old', + { + authoritative: { origin: 'old', result: true, success: true }, + nonAuthoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.Live, + 'new', + { + nonAuthoritative: { origin: 'old', result: true, success: true }, + authoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.RampDown, + 'new', + { + nonAuthoritative: { origin: 'old', result: true, success: true }, + authoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.Complete, + 'new', + { + authoritative: { origin: 'new', result: false, success: true }, + nonAuthoritative: undefined, + }, + ], + ])( + 'uses the correct authoritative source: %p, read: %p, write: %j.', + async (stage, readValue, writeMatch) => { + const migration = new Migration(client, { + execution, + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => LDMigrationSuccess(false), + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const read = await migration.read(flagKey, { key: 'test-key' }, defaultStage!); + expect(read.success).toBeTruthy(); + expect(read.origin).toEqual(readValue); + // Type guards needed for typescript. + if (read.success) { + expect(read.result).toEqual(readValue); + } + + const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); + // @ts-ignore Extended without writing types. + expect(write).toMatchMigrationResult(writeMatch); + } + ); + }); + + it.each([ + [LDMigrationStage.Off, 'old'], + [LDMigrationStage.DualWrite, 'old'], + [LDMigrationStage.Shadow, 'old'], + [LDMigrationStage.Live, 'new'], + [LDMigrationStage.RampDown, 'new'], + [LDMigrationStage.Complete, 'new'], + ])('handles read errors for stage: %p', async (stage, authority) => { + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationError(new Error('new')), + writeNew: async () => LDMigrationSuccess(false), + readOld: async () => LDMigrationError(new Error('old')), + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const read = await migration.read(flagKey, { key: 'test-key' }, defaultStage!); + expect(read.success).toBeFalsy(); + expect(read.origin).toEqual(authority); + // Type guards needed for typescript. + if (!read.success) { + expect(read.error.message).toEqual(authority); + } + }); + + it.each([ + [LDMigrationStage.Off, 'old'], + [LDMigrationStage.DualWrite, 'old'], + [LDMigrationStage.Shadow, 'old'], + [LDMigrationStage.Live, 'new'], + [LDMigrationStage.RampDown, 'new'], + [LDMigrationStage.Complete, 'new'], + ])('handles exceptions for stage: %p', async (stage, authority) => { + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => { + throw new Error('new'); + }, + writeNew: async () => LDMigrationSuccess(false), + readOld: async () => { + throw new Error('old'); + }, + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const read = await migration.read(flagKey, { key: 'test-key' }, defaultStage!); + expect(read.success).toBeFalsy(); + expect(read.origin).toEqual(authority); + // Type guards needed for typescript. + if (!read.success) { + expect(read.error.message).toEqual(authority); + } + }); + + it.each([ + [ + LDMigrationStage.Off, + 'old', + true, + false, + { + authoritative: { origin: 'old', success: false, error: new Error('old') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.DualWrite, + 'old', + true, + false, + { + authoritative: { origin: 'old', success: false, error: new Error('old') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.Shadow, + 'old', + true, + false, + { + authoritative: { origin: 'old', success: false, error: new Error('old') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.Live, + 'new', + false, + true, + { + authoritative: { origin: 'new', success: false, error: new Error('new') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.RampDown, + 'new', + false, + true, + { + authoritative: { origin: 'new', success: false, error: new Error('new') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.Complete, + 'new', + false, + true, + { + authoritative: { origin: 'new', success: false, error: new Error('new') }, + nonAuthoritative: undefined, + }, + ], + ])( + 'stops writes on error: %p, %p, %p, %p', + async (stage, origin, oldWrite, newWrite, writeMatch) => { + let oldWriteCalled = false; + let newWriteCalled = false; + + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => { + newWriteCalled = true; + return LDMigrationError(new Error('new')); + }, + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => { + oldWriteCalled = true; + return LDMigrationError(new Error('old')); + }, + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); + // @ts-ignore + expect(write).toMatchMigrationResult(writeMatch); + + expect(oldWriteCalled).toEqual(oldWrite); + expect(newWriteCalled).toEqual(newWrite); + } + ); + + it.each([ + [LDMigrationStage.Off, 'old', true, false], + [LDMigrationStage.DualWrite, 'old', true, false], + [LDMigrationStage.Shadow, 'old', true, false], + [LDMigrationStage.Live, 'new', false, true], + [LDMigrationStage.RampDown, 'new', false, true], + [LDMigrationStage.Complete, 'new', false, true], + ])('stops writes on exception: %p, %p, %p, %p', async (stage, origin, oldWrite, newWrite) => { + let oldWriteCalled = false; + let newWriteCalled = false; + + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => { + newWriteCalled = true; + throw new Error('new'); + }, + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => { + oldWriteCalled = true; + throw new Error('old'); + }, + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); + expect(write.authoritative.success).toBeFalsy(); + expect(write.authoritative.origin).toEqual(origin); + if (!write.authoritative.success) { + expect(write.authoritative.error.message).toEqual(origin); + } + expect(oldWriteCalled).toEqual(oldWrite); + expect(newWriteCalled).toEqual(newWrite); + }); + + it('handles the case where the authoritative write succeeds, but the non-authoritative fails', async () => { + const migrationA = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => { + throw new Error('new'); + }, + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.DualWrite)); + + const writeA = await migrationA.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeA).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'old', + }, + nonAuthoritative: { + success: false, + error: new Error('new'), + origin: 'new', + }, + }); + + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.Shadow)); + + const writeB = await migrationA.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeB).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'old', + }, + nonAuthoritative: { + success: false, + error: new Error('new'), + origin: 'new', + }, + }); + + const migrationB = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => LDMigrationSuccess(true), + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => { + throw new Error('old'); + }, + }); + + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.Live)); + + const writeC = await migrationB.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeC).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'new', + }, + nonAuthoritative: { + success: false, + error: new Error('old'), + origin: 'old', + }, + }); + + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.RampDown)); + + const writeD = await migrationB.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeD).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'new', + }, + nonAuthoritative: { + success: false, + error: new Error('old'), + origin: 'old', + }, + }); + }); +}); diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts new file mode 100644 index 000000000..f837503ef --- /dev/null +++ b/packages/shared/sdk-server/src/Migration.ts @@ -0,0 +1,247 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; +import { LDClient, LDMigrationStage } from './api'; +import { + LDMigrationOptions, + LDSerialExecution, + LDConcurrentExecution, + LDExecution, + LDExecutionOrdering, + LDMethodResult, +} from './api/options/LDMigrationOptions'; +import { LDMigration, LDMigrationReadResult, LDMigrationWriteResult } from './api/LDMigration'; + +type MultipleReadResult = { + fromOld: LDMethodResult; + fromNew: LDMethodResult; +}; + +async function safeCall( + method: () => Promise> +): Promise> { + try { + // Awaiting to allow catching. + const res = await method(); + return res; + } catch (error: any) { + return { + success: false, + error, + }; + } +} + +async function readSequentialRandom( + config: LDMigrationOptions +): Promise> { + // This number is not used for a purpose requiring cryptographic security. + const randomIndex = Math.floor(Math.random() * 2); + + // Effectively flip a coin and do it on one order or the other. + if (randomIndex === 0) { + const fromOld = await safeCall(() => config.readOld()); + const fromNew = await safeCall(() => config.readNew()); + return { fromOld, fromNew }; + } + const fromNew = await safeCall(() => config.readNew()); + const fromOld = await safeCall(() => config.readOld()); + return { fromOld, fromNew }; +} + +async function readSequentialFixed( + config: LDMigrationOptions +): Promise> { + const fromOld = await safeCall(() => config.readOld()); + const fromNew = await safeCall(() => config.readNew()); + return { fromOld, fromNew }; +} + +async function readConcurrent( + config: LDMigrationOptions +): Promise> { + const fromOldPromise = safeCall(() => config.readOld()); + const fromNewPromise = safeCall(() => config.readNew()); + + const [fromOld, fromNew] = await Promise.all([fromOldPromise, fromNewPromise]); + + return { fromOld, fromNew }; +} + +async function read( + config: LDMigrationOptions, + execution: LDSerialExecution | LDConcurrentExecution +): Promise> { + if (execution.type === LDExecution.Serial) { + const serial = execution as LDSerialExecution; + if (serial.ordering === LDExecutionOrdering.Fixed) { + return readSequentialFixed(config); + } + return readSequentialRandom(config); + } + return readConcurrent(config); +} + +export function LDMigrationSuccess(result: TResult): LDMethodResult { + return { + success: true, + result, + }; +} + +export function LDMigrationError(error: Error): { success: false; error: Error } { + return { + success: false, + error, + }; +} + +export default class Migration + implements LDMigration +{ + private readonly execution: LDSerialExecution | LDConcurrentExecution; + + private readonly readTable: { + [index: string]: ( + config: LDMigrationOptions + ) => Promise>; + } = { + [LDMigrationStage.Off]: async ( + config: LDMigrationOptions + ) => ({ origin: 'old', ...(await safeCall(() => config.readOld())) }), + [LDMigrationStage.DualWrite]: async ( + config: LDMigrationOptions + ) => ({ origin: 'old', ...(await safeCall(() => config.readOld())) }), + [LDMigrationStage.Shadow]: async ( + config: LDMigrationOptions + ) => { + const { fromOld } = await read(config, this.execution); + + // TODO: Consistency check. + + return { origin: 'old', ...fromOld }; + }, + [LDMigrationStage.Live]: async ( + config: LDMigrationOptions + ) => { + const { fromNew } = await read(config, this.execution); + + // TODO: Consistency check. + + return { origin: 'new', ...fromNew }; + }, + [LDMigrationStage.RampDown]: async ( + config: LDMigrationOptions + ) => ({ origin: 'new', ...(await safeCall(() => config.readNew())) }), + [LDMigrationStage.Complete]: async ( + config: LDMigrationOptions + ) => ({ origin: 'new', ...(await safeCall(() => config.readNew())) }), + }; + + private readonly writeTable: { + [index: string]: ( + config: LDMigrationOptions + ) => Promise>; + } = { + [LDMigrationStage.Off]: async ( + config: LDMigrationOptions + ) => ({ authoritative: { origin: 'old', ...(await safeCall(() => config.writeOld())) } }), + [LDMigrationStage.DualWrite]: async ( + config: LDMigrationOptions + ) => { + const fromOld = await safeCall(() => config.writeOld()); + if (!fromOld.success) { + return { + authoritative: { origin: 'old', ...fromOld }, + }; + } + + const fromNew = await safeCall(() => config.writeNew()); + + return { + authoritative: { origin: 'old', ...fromOld }, + nonAuthoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.Shadow]: async ( + config: LDMigrationOptions + ) => { + const fromOld = await safeCall(() => config.writeOld()); + if (!fromOld.success) { + return { + authoritative: { origin: 'old', ...fromOld }, + }; + } + + const fromNew = await safeCall(() => config.writeNew()); + + return { + authoritative: { origin: 'old', ...fromOld }, + nonAuthoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.Live]: async ( + config: LDMigrationOptions + ) => { + const fromNew = await safeCall(() => config.writeNew()); + if (!fromNew.success) { + return { authoritative: { origin: 'new', ...fromNew } }; + } + + const fromOld = await safeCall(() => config.writeOld()); + + return { + nonAuthoritative: { origin: 'old', ...fromOld }, + authoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.RampDown]: async ( + config: LDMigrationOptions + ) => { + const fromNew = await safeCall(() => config.writeNew()); + if (!fromNew.success) { + return { authoritative: { origin: 'new', ...fromNew } }; + } + + const fromOld = await safeCall(() => config.writeOld()); + + return { + nonAuthoritative: { origin: 'old', ...fromOld }, + authoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.Complete]: async ( + config: LDMigrationOptions + ) => ({ authoritative: { origin: 'new', ...(await safeCall(() => config.writeNew())) } }), + }; + + constructor( + private readonly client: LDClient, + private readonly config: + | LDMigrationOptions + | LDMigrationOptions + ) { + if (config.execution) { + this.execution = config.execution; + } else { + this.execution = new LDConcurrentExecution(); + } + } + + async read( + key: string, + context: LDContext, + defaultStage: LDMigrationStage + ): Promise> { + const stage = await this.client.variationMigration(key, context, defaultStage); + return this.readTable[stage](this.config); + } + + async write( + key: string, + context: LDContext, + defaultStage: LDMigrationStage + ): Promise> { + const stage = await this.client.variationMigration(key, context, defaultStage); + + return this.writeTable[stage](this.config); + } +} diff --git a/packages/shared/sdk-server/src/api/LDMigration.ts b/packages/shared/sdk-server/src/api/LDMigration.ts new file mode 100644 index 000000000..9eb063d8a --- /dev/null +++ b/packages/shared/sdk-server/src/api/LDMigration.ts @@ -0,0 +1,87 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; +import { LDMigrationStage } from './data/LDMigrationStage'; + +/** + * Specifies the origin of the result or error. + * + * Results from `readOld` or `writeOld` will be 'old'. + * Results from `readNew` or `writeNew` will be 'new'. + */ +export type LDMigrationOrigin = 'old' | 'new'; + +/** + * Result of a component of an LDMigration. + * + * Should not need to be used by a consumer of this API directly. + */ +export type LDMigrationResult = + | { + success: true; + origin: LDMigrationOrigin; + result: TResult; + } + | { + success: false; + origin: LDMigrationOrigin; + error: any; + }; + +/** + * Result of a migration read operation. + */ +export type LDMigrationReadResult = LDMigrationResult; + +/** + * Result of a migration write operation. + * + * Authoritative writes are done before non-authoritative, so the authoritative + * field should contain either an error or a result. + * + * If the authoritative write fails, then the non-authoritative operation will + * not be executed. When this happens the nonAuthoritative field will not be + * populated. + * + * When the non-authoritative operation is executed, then it will result in + * either a result or an error and the field will be populated as such. + */ +export type LDMigrationWriteResult = { + authoritative: LDMigrationResult; + nonAuthoritative?: LDMigrationResult; +}; + +/** + * Interface for a migration. + * + * TKTK + */ +export interface LDMigration { + /** + * TKTK + * + * @param key The key of the flag controlling the migration. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default migration step. Used if the value is not available from + * LaunchDarkly. + */ + read( + key: string, + context: LDContext, + defaultValue: LDMigrationStage + ): Promise>; + + /** + * TKTK + * + * @param key The key of the flag controlling the migration. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default migration step. Used if the value is not available from + * LaunchDarkly. + */ + write( + key: string, + context: LDContext, + defaultValue: LDMigrationStage + ): Promise>; +} diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts index c582a4f7c..b262ddce7 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts @@ -3,7 +3,7 @@ export enum LDMigrationStage { DualWrite = 'dualwrite', Shadow = 'shadow', Live = 'live', - Rampdown = 'rampdown', + RampDown = 'rampdown', Complete = 'complete', } diff --git a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts new file mode 100644 index 000000000..9f829cb45 --- /dev/null +++ b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts @@ -0,0 +1,127 @@ +/* eslint-disable max-classes-per-file */ +// Disabling max classes per file as these are tag classes without +// logic implementation. + +/** + * When execution is sequential this enum is used to control if execution + * should be in a fixed or random order. + */ +export enum LDExecutionOrdering { + Fixed, + Random, +} + +/** + * Tag used to determine if execution should be serial or concurrent. + * Callers should not need to use this directly. + */ +export enum LDExecution { + /** + * Execution will be serial. One read method will be executed fully before + * the other read method. + */ + Serial, + /** + * Execution will be concurrent. The execution of the read methods will be + * started and then resolved concurrently. + */ + Concurrent, +} + +/** + * Settings for latency tracking. + */ +export enum LDLatencyTracking { + Enabled, + Disabled, +} + +/** + * Settings for error tracking. + */ +export enum LDErrorTracking { + Enabled, + Disabled, +} + +/** + * Migration methods may return an LDMethodResult. + * The implementation includes methods for creating results conveniently. + * + * An implementation may also throw an exception to represent an error. + */ +export type LDMethodResult = + | { + success: true; + result: TResult; + } + | { + success: false; + error: any; + }; + +/** + * Configuration class for configuring serial execution of a migration. + */ +export class LDSerialExecution { + readonly type: LDExecution = LDExecution.Serial; + + constructor(public readonly ordering: LDExecutionOrdering) {} +} + +/** + * Configuration class for configuring concurrent execution of a migration. + */ +export class LDConcurrentExecution { + readonly type: LDExecution = LDExecution.Concurrent; +} + +/** + * Configuration for a migration. + */ +export interface LDMigrationOptions { + /** + * Configure how the migration should execute. If omitted the execution will + * be concurrent. + */ + execution?: LDSerialExecution | LDConcurrentExecution; + + /** + * Configure the latency tracking for the migration. + * + * Defaults to {@link LDLatencyTracking.Enabled}. + */ + latencyTracking?: LDLatencyTracking; + + /** + * Configure the error tracking for the migration. + * + * Defaults to {@link LDErrorTracking.Enabled}. + */ + errorTracking?: LDErrorTracking; + + /** + * TKTK + */ + readNew: () => Promise>; + + /** + * TKTK + */ + writeNew: () => Promise>; + + /** + * TKTK + */ + readOld: () => Promise>; + + /** + * TKTK + */ + writeOld: () => Promise>; + + /** + * TKTK + */ + check?: (a: TMigrationRead, b: TMigrationRead) => boolean; +} diff --git a/packages/shared/sdk-server/src/api/options/index.ts b/packages/shared/sdk-server/src/api/options/index.ts index a3467b9cc..1e7b63de7 100644 --- a/packages/shared/sdk-server/src/api/options/index.ts +++ b/packages/shared/sdk-server/src/api/options/index.ts @@ -2,3 +2,4 @@ export * from './LDBigSegmentsOptions'; export * from './LDOptions'; export * from './LDProxyOptions'; export * from './LDTLSOptions'; +export * from './LDMigrationOptions'; From 72c0f53ac3c0232e199fea2dc586b612513384ee Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:01:30 -0700 Subject: [PATCH 12/57] feat: Add support for payloads to read and write methods. (#215) --- .../sdk-server/__tests__/Migration.test.ts | 49 ++++- packages/shared/sdk-server/src/Migration.ts | 207 +++++++++++------- .../shared/sdk-server/src/api/LDMigration.ts | 13 +- .../src/api/options/LDMigrationOptions.ts | 15 +- 4 files changed, 193 insertions(+), 91 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts index 22cc76b1c..aca8bebd1 100644 --- a/packages/shared/sdk-server/__tests__/Migration.test.ts +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -123,7 +123,7 @@ describe('given an LDClient with test data', () => { [new LDSerialExecution(LDExecutionOrdering.Random), 'serial random'], [new LDConcurrentExecution(), 'concurrent'], ])('given different execution methods: %p %p', (execution) => { - it.each([ + describe.each([ [ LDMigrationStage.Off, 'old', @@ -172,9 +172,8 @@ describe('given an LDClient with test data', () => { nonAuthoritative: undefined, }, ], - ])( - 'uses the correct authoritative source: %p, read: %p, write: %j.', - async (stage, readValue, writeMatch) => { + ])('given each migration step: %p, read: %p, write: %j.', (stage, readValue, writeMatch) => { + it('uses the correct authoritative source', async () => { const migration = new Migration(client, { execution, latencyTracking: LDLatencyTracking.Disabled, @@ -202,8 +201,46 @@ describe('given an LDClient with test data', () => { const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); // @ts-ignore Extended without writing types. expect(write).toMatchMigrationResult(writeMatch); - } - ); + }); + + it('correctly forwards the payload for read and write operations', async () => { + let receivedReadPayload: string | undefined; + let receivedWritePayload: string | undefined; + const migration = new Migration(client, { + execution, + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async (payload) => { + receivedReadPayload = payload; + return LDMigrationSuccess('new'); + }, + writeNew: async (payload) => { + receivedWritePayload = payload; + return LDMigrationSuccess(false); + }, + readOld: async (payload) => { + receivedReadPayload = payload; + return LDMigrationSuccess('old'); + }, + writeOld: async (payload) => { + receivedWritePayload = payload; + return LDMigrationSuccess(true); + }, + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + const payloadRead = Math.random().toString(10); + const payloadWrite = Math.random().toString(10); + await migration.read(flagKey, { key: 'test-key' }, LDMigrationStage.Off, payloadRead); + + await migration.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off, payloadWrite); + + expect(receivedReadPayload).toEqual(payloadRead); + expect(receivedWritePayload).toEqual(payloadWrite); + }); + }); }); it.each([ diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index f837503ef..9a455088f 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -30,54 +30,93 @@ async function safeCall( } } -async function readSequentialRandom( - config: LDMigrationOptions +async function readSequentialRandom< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput +>( + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, + payload?: TMigrationReadInput ): Promise> { // This number is not used for a purpose requiring cryptographic security. const randomIndex = Math.floor(Math.random() * 2); // Effectively flip a coin and do it on one order or the other. if (randomIndex === 0) { - const fromOld = await safeCall(() => config.readOld()); - const fromNew = await safeCall(() => config.readNew()); + const fromOld = await safeCall(() => config.readOld(payload)); + const fromNew = await safeCall(() => config.readNew(payload)); return { fromOld, fromNew }; } - const fromNew = await safeCall(() => config.readNew()); - const fromOld = await safeCall(() => config.readOld()); + const fromNew = await safeCall(() => config.readNew(payload)); + const fromOld = await safeCall(() => config.readOld(payload)); return { fromOld, fromNew }; } -async function readSequentialFixed( - config: LDMigrationOptions +async function readSequentialFixed< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput +>( + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, + payload?: TMigrationReadInput ): Promise> { - const fromOld = await safeCall(() => config.readOld()); - const fromNew = await safeCall(() => config.readNew()); + const fromOld = await safeCall(() => config.readOld(payload)); + const fromNew = await safeCall(() => config.readNew(payload)); return { fromOld, fromNew }; } -async function readConcurrent( - config: LDMigrationOptions +async function readConcurrent< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput +>( + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, + payload?: TMigrationReadInput ): Promise> { - const fromOldPromise = safeCall(() => config.readOld()); - const fromNewPromise = safeCall(() => config.readNew()); + const fromOldPromise = safeCall(() => config.readOld(payload)); + const fromNewPromise = safeCall(() => config.readNew(payload)); const [fromOld, fromNew] = await Promise.all([fromOldPromise, fromNewPromise]); return { fromOld, fromNew }; } -async function read( - config: LDMigrationOptions, - execution: LDSerialExecution | LDConcurrentExecution +async function read( + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, + execution: LDSerialExecution | LDConcurrentExecution, + payload?: TMigrationReadInput ): Promise> { if (execution.type === LDExecution.Serial) { const serial = execution as LDSerialExecution; if (serial.ordering === LDExecutionOrdering.Fixed) { - return readSequentialFixed(config); + return readSequentialFixed(config, payload); } - return readSequentialRandom(config); + return readSequentialRandom(config, payload); } - return readConcurrent(config); + return readConcurrent(config, payload); } export function LDMigrationSuccess(result: TResult): LDMethodResult { @@ -94,130 +133,142 @@ export function LDMigrationError(error: Error): { success: false; error: Error } }; } -export default class Migration - implements LDMigration +export default class Migration< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput = any, + TMigrationWriteInput = any +> implements + LDMigration { private readonly execution: LDSerialExecution | LDConcurrentExecution; private readonly readTable: { [index: string]: ( - config: LDMigrationOptions + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, + payload?: TMigrationReadInput ) => Promise>; } = { - [LDMigrationStage.Off]: async ( - config: LDMigrationOptions - ) => ({ origin: 'old', ...(await safeCall(() => config.readOld())) }), - [LDMigrationStage.DualWrite]: async ( - config: LDMigrationOptions - ) => ({ origin: 'old', ...(await safeCall(() => config.readOld())) }), - [LDMigrationStage.Shadow]: async ( - config: LDMigrationOptions - ) => { - const { fromOld } = await read(config, this.execution); + [LDMigrationStage.Off]: async (config, payload) => ({ + origin: 'old', + ...(await safeCall(() => config.readOld(payload))), + }), + [LDMigrationStage.DualWrite]: async (config, payload) => ({ + origin: 'old', + ...(await safeCall(() => config.readOld(payload))), + }), + [LDMigrationStage.Shadow]: async (config, payload) => { + const { fromOld } = await read(config, this.execution, payload); // TODO: Consistency check. return { origin: 'old', ...fromOld }; }, - [LDMigrationStage.Live]: async ( - config: LDMigrationOptions - ) => { - const { fromNew } = await read(config, this.execution); + [LDMigrationStage.Live]: async (config, payload) => { + const { fromNew } = await read(config, this.execution, payload); // TODO: Consistency check. return { origin: 'new', ...fromNew }; }, - [LDMigrationStage.RampDown]: async ( - config: LDMigrationOptions - ) => ({ origin: 'new', ...(await safeCall(() => config.readNew())) }), - [LDMigrationStage.Complete]: async ( - config: LDMigrationOptions - ) => ({ origin: 'new', ...(await safeCall(() => config.readNew())) }), + [LDMigrationStage.RampDown]: async (config, payload) => ({ + origin: 'new', + ...(await safeCall(() => config.readNew(payload))), + }), + [LDMigrationStage.Complete]: async (config, payload) => ({ + origin: 'new', + ...(await safeCall(() => config.readNew(payload))), + }), }; private readonly writeTable: { [index: string]: ( - config: LDMigrationOptions + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, + payload?: TMigrationWriteInput ) => Promise>; } = { - [LDMigrationStage.Off]: async ( - config: LDMigrationOptions - ) => ({ authoritative: { origin: 'old', ...(await safeCall(() => config.writeOld())) } }), - [LDMigrationStage.DualWrite]: async ( - config: LDMigrationOptions - ) => { - const fromOld = await safeCall(() => config.writeOld()); + [LDMigrationStage.Off]: async (config, payload) => ({ + authoritative: { origin: 'old', ...(await safeCall(() => config.writeOld(payload))) }, + }), + [LDMigrationStage.DualWrite]: async (config, payload) => { + const fromOld = await safeCall(() => config.writeOld(payload)); if (!fromOld.success) { return { authoritative: { origin: 'old', ...fromOld }, }; } - const fromNew = await safeCall(() => config.writeNew()); + const fromNew = await safeCall(() => config.writeNew(payload)); return { authoritative: { origin: 'old', ...fromOld }, nonAuthoritative: { origin: 'new', ...fromNew }, }; }, - [LDMigrationStage.Shadow]: async ( - config: LDMigrationOptions - ) => { - const fromOld = await safeCall(() => config.writeOld()); + [LDMigrationStage.Shadow]: async (config, payload) => { + const fromOld = await safeCall(() => config.writeOld(payload)); if (!fromOld.success) { return { authoritative: { origin: 'old', ...fromOld }, }; } - const fromNew = await safeCall(() => config.writeNew()); + const fromNew = await safeCall(() => config.writeNew(payload)); return { authoritative: { origin: 'old', ...fromOld }, nonAuthoritative: { origin: 'new', ...fromNew }, }; }, - [LDMigrationStage.Live]: async ( - config: LDMigrationOptions - ) => { - const fromNew = await safeCall(() => config.writeNew()); + [LDMigrationStage.Live]: async (config, payload) => { + const fromNew = await safeCall(() => config.writeNew(payload)); if (!fromNew.success) { return { authoritative: { origin: 'new', ...fromNew } }; } - const fromOld = await safeCall(() => config.writeOld()); + const fromOld = await safeCall(() => config.writeOld(payload)); return { nonAuthoritative: { origin: 'old', ...fromOld }, authoritative: { origin: 'new', ...fromNew }, }; }, - [LDMigrationStage.RampDown]: async ( - config: LDMigrationOptions - ) => { - const fromNew = await safeCall(() => config.writeNew()); + [LDMigrationStage.RampDown]: async (config, payload) => { + const fromNew = await safeCall(() => config.writeNew(payload)); if (!fromNew.success) { return { authoritative: { origin: 'new', ...fromNew } }; } - const fromOld = await safeCall(() => config.writeOld()); + const fromOld = await safeCall(() => config.writeOld(payload)); return { nonAuthoritative: { origin: 'old', ...fromOld }, authoritative: { origin: 'new', ...fromNew }, }; }, - [LDMigrationStage.Complete]: async ( - config: LDMigrationOptions - ) => ({ authoritative: { origin: 'new', ...(await safeCall(() => config.writeNew())) } }), + [LDMigrationStage.Complete]: async (config, payload) => ({ + authoritative: { origin: 'new', ...(await safeCall(() => config.writeNew(payload))) }, + }), }; constructor( private readonly client: LDClient, - private readonly config: - | LDMigrationOptions - | LDMigrationOptions + private readonly config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + > ) { if (config.execution) { this.execution = config.execution; @@ -229,19 +280,21 @@ export default class Migration async read( key: string, context: LDContext, - defaultStage: LDMigrationStage + defaultStage: LDMigrationStage, + payload?: TMigrationReadInput ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); - return this.readTable[stage](this.config); + return this.readTable[stage](this.config, payload); } async write( key: string, context: LDContext, - defaultStage: LDMigrationStage + defaultStage: LDMigrationStage, + payload?: TMigrationWriteInput ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); - return this.writeTable[stage](this.config); + return this.writeTable[stage](this.config, payload); } } diff --git a/packages/shared/sdk-server/src/api/LDMigration.ts b/packages/shared/sdk-server/src/api/LDMigration.ts index 9eb063d8a..21458cde1 100644 --- a/packages/shared/sdk-server/src/api/LDMigration.ts +++ b/packages/shared/sdk-server/src/api/LDMigration.ts @@ -54,7 +54,12 @@ export type LDMigrationWriteResult = { * * TKTK */ -export interface LDMigration { +export interface LDMigration< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput +> { /** * TKTK * @@ -67,7 +72,8 @@ export interface LDMigration { read( key: string, context: LDContext, - defaultValue: LDMigrationStage + defaultValue: LDMigrationStage, + payload?: TMigrationReadInput ): Promise>; /** @@ -82,6 +88,7 @@ export interface LDMigration { write( key: string, context: LDContext, - defaultValue: LDMigrationStage + defaultValue: LDMigrationStage, + payload?: TMigrationWriteInput ): Promise>; } diff --git a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts index 9f829cb45..7846efee4 100644 --- a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts @@ -79,7 +79,12 @@ export class LDConcurrentExecution { /** * Configuration for a migration. */ -export interface LDMigrationOptions { +export interface LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput +> { /** * Configure how the migration should execute. If omitted the execution will * be concurrent. @@ -103,22 +108,22 @@ export interface LDMigrationOptions { /** * TKTK */ - readNew: () => Promise>; + readNew: (payload?: TMigrationReadInput) => Promise>; /** * TKTK */ - writeNew: () => Promise>; + writeNew: (payload?: TMigrationWriteInput) => Promise>; /** * TKTK */ - readOld: () => Promise>; + readOld: (payload?: TMigrationReadInput) => Promise>; /** * TKTK */ - writeOld: () => Promise>; + writeOld: (payload?: TMigrationWriteInput) => Promise>; /** * TKTK From 36fef4ffc1ad8193c8fd0fe537e3dc7632eb2ac3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 28 Jul 2023 14:02:39 -0700 Subject: [PATCH 13/57] feat: Add support for exclude from summaries. (#216) --- .../internal/events/EventSummarizer.test.ts | 14 ++++++++++++++ packages/shared/common/package.json | 2 +- .../common/src/internal/events/EventSummarizer.ts | 2 +- .../common/src/internal/events/InputEvalEvent.ts | 9 ++++++++- .../shared/sdk-server/src/evaluation/data/Flag.ts | 2 ++ .../shared/sdk-server/src/events/EventFactory.ts | 3 ++- 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts b/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts index bb4bfa91d..1fd713a9f 100644 --- a/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventSummarizer.test.ts @@ -25,6 +25,20 @@ describe('given an event summarizer', () => { expect(beforeSummary).toEqual(afterSummary); }); + it('does nothing for an event with excludeFromSummaries set to true', () => { + const event = { + kind: 'feature', + creationDate: 2000, + key: 'key', + context, + excludeFromSummaries: true, + }; + const beforeSummary = summarizer.getSummary(); + summarizer.summarizeEvent(event as any); + const afterSummary = summarizer.getSummary(); + expect(beforeSummary).toEqual(afterSummary); + }); + it('sets start and end dates for feature events', () => { const event1 = { kind: 'feature', diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 9cf60215e..391719034 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -24,7 +24,7 @@ "build": "npx tsc", "clean": "npx tsc --build --clean", "lint": "npx eslint . --ext .ts", - "lint:fix": "yarn run lint -- --fix" + "lint:fix": "yarn run lint --fix" }, "license": "Apache-2.0", "devDependencies": { diff --git a/packages/shared/common/src/internal/events/EventSummarizer.ts b/packages/shared/common/src/internal/events/EventSummarizer.ts index 77ca7ce5e..9b15c7469 100644 --- a/packages/shared/common/src/internal/events/EventSummarizer.ts +++ b/packages/shared/common/src/internal/events/EventSummarizer.ts @@ -52,7 +52,7 @@ export default class EventSummarizer { private contextKinds: Record> = {}; summarizeEvent(event: InputEvent) { - if (isFeature(event)) { + if (isFeature(event) && !event.excludeFromSummaries) { const countKey = counterKey(event); const counter = this.counters[countKey]; let kinds = this.contextKinds[event.key]; diff --git a/packages/shared/common/src/internal/events/InputEvalEvent.ts b/packages/shared/common/src/internal/events/InputEvalEvent.ts index 9d9f19663..9f6ab07e4 100644 --- a/packages/shared/common/src/internal/events/InputEvalEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvalEvent.ts @@ -24,6 +24,8 @@ export default class InputEvalEvent { public readonly version?: number; + public readonly excludeFromSummaries?: boolean; + constructor( withReasons: boolean, context: Context, @@ -35,7 +37,8 @@ export default class InputEvalEvent { trackEvents?: boolean, prereqOf?: string, reason?: LDEvaluationReason, - debugEventsUntilDate?: number + debugEventsUntilDate?: number, + excludeFromSummaries?: boolean ) { this.creationDate = Date.now(); this.context = context; @@ -66,5 +69,9 @@ export default class InputEvalEvent { if (debugEventsUntilDate !== undefined) { this.debugEventsUntilDate = debugEventsUntilDate; } + + if (excludeFromSummaries !== undefined) { + this.excludeFromSummaries = excludeFromSummaries; + } } } diff --git a/packages/shared/sdk-server/src/evaluation/data/Flag.ts b/packages/shared/sdk-server/src/evaluation/data/Flag.ts index 3dacc0dc7..73835c2ae 100644 --- a/packages/shared/sdk-server/src/evaluation/data/Flag.ts +++ b/packages/shared/sdk-server/src/evaluation/data/Flag.ts @@ -25,4 +25,6 @@ export interface Flag extends Versioned { trackEvents?: boolean; trackEventsFallthrough?: boolean; debugEventsUntilDate?: number; + excludeFromSummaries?: boolean; + sampleWeight?: number; } diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index 24d6fb729..e7f952442 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -28,7 +28,8 @@ export default class EventFactory { flag.trackEvents || addExperimentData, prereqOfFlag?.key, this.withReasons || addExperimentData ? detail.reason : undefined, - flag.debugEventsUntilDate + flag.debugEventsUntilDate, + flag.excludeFromSummaries ); } From bee4e74297c37723dbc10a54ebd295b2b8442f8f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 31 Jul 2023 10:02:54 -0700 Subject: [PATCH 14/57] chore: Prettier. --- .../src/internal/events/InputEvalEvent.ts | 2 +- .../__tests__/LDClient.migrations.test.ts | 12 +++---- .../sdk-server/__tests__/Migration.test.ts | 6 ++-- .../shared/sdk-server/src/LDClientImpl.ts | 12 +++++-- packages/shared/sdk-server/src/Migration.ts | 35 ++++++++++--------- .../shared/sdk-server/src/api/LDClient.ts | 4 +-- .../shared/sdk-server/src/api/LDMigration.ts | 7 ++-- .../src/api/options/LDMigrationOptions.ts | 2 +- .../sdk-server/src/events/EventFactory.ts | 2 +- 9 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/shared/common/src/internal/events/InputEvalEvent.ts b/packages/shared/common/src/internal/events/InputEvalEvent.ts index 1f61889bc..078e7cceb 100644 --- a/packages/shared/common/src/internal/events/InputEvalEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvalEvent.ts @@ -38,7 +38,7 @@ export default class InputEvalEvent { prereqOf?: string, reason?: LDEvaluationReason, debugEventsUntilDate?: number, - excludeFromSummaries?: boolean + excludeFromSummaries?: boolean, ) { this.creationDate = Date.now(); this.context = context; diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index 7d6c3af70..5f3bce6a9 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -1,6 +1,6 @@ import { LDClientImpl, LDMigrationStage } from '../src'; -import { LDClientCallbacks } from '../src/LDClientImpl'; import TestData from '../src/integrations/test_data/TestData'; +import { LDClientCallbacks } from '../src/LDClientImpl'; import basicPlatform from './evaluation/mocks/platform'; /** @@ -38,7 +38,7 @@ describe('given an LDClient with test data', () => { updateProcessor: td.getFactory(), sendEvents: false, }, - callbacks + callbacks, ); await client.waitForInitialization(); @@ -60,10 +60,10 @@ describe('given an LDClient with test data', () => { const res = await client.variationMigration( flagKey, { key: 'test-key' }, - defaultValue as LDMigrationStage + defaultValue as LDMigrationStage, ); expect(res).toEqual(value); - } + }, ); it.each([ @@ -86,7 +86,7 @@ describe('given an LDClient with test data', () => { expect(res).toEqual(LDMigrationStage.Off); expect(errors.length).toEqual(1); expect(errors[0].message).toEqual( - 'Unrecognized MigrationState for "bad-migration"; returning default value.' + 'Unrecognized MigrationState for "bad-migration"; returning default value.', ); }); @@ -96,7 +96,7 @@ describe('given an LDClient with test data', () => { client.variationMigration(flagKey, { key: 'test-key' }, LDMigrationStage.Off, (err, value) => { const error = err as Error; expect(error.message).toEqual( - 'Unrecognized MigrationState for "bad-migration"; returning default value.' + 'Unrecognized MigrationState for "bad-migration"; returning default value.', ); expect(value).toEqual(LDMigrationStage.Off); done(); diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts index aca8bebd1..49966164f 100644 --- a/packages/shared/sdk-server/__tests__/Migration.test.ts +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -7,9 +7,9 @@ import { LDMigrationStage, LDSerialExecution, } from '../src'; +import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; -import { TestData } from '../src/integrations'; import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; @@ -28,7 +28,7 @@ describe('given an LDClient with test data', () => { updateProcessor: td.getFactory(), sendEvents: false, }, - callbacks + callbacks, ); await client.waitForInitialization(); @@ -408,7 +408,7 @@ describe('given an LDClient with test data', () => { expect(oldWriteCalled).toEqual(oldWrite); expect(newWriteCalled).toEqual(newWrite); - } + }, ); it.each([ diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 22e7a21a7..827c12570 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -12,7 +12,15 @@ import { subsystem, } from '@launchdarkly/js-sdk-common'; -import { IsMigrationStage, LDClient, LDFlagsState, LDFlagsStateOptions, LDMigrationStage, LDOptions, LDStreamProcessor } from './api'; +import { + IsMigrationStage, + LDClient, + LDFlagsState, + LDFlagsStateOptions, + LDMigrationStage, + LDOptions, + LDStreamProcessor, +} from './api'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -266,7 +274,7 @@ export default class LDClientImpl implements LDClient { key: string, context: LDContext, defaultValue: LDMigrationStage, - callback?: (err: any, res: LDMigrationStage) => void + callback?: (err: any, res: LDMigrationStage) => void, ): Promise { const stringValue = await this.variation(key, context, defaultValue as string); if (!IsMigrationStage(stringValue)) { diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index 9a455088f..f3cb7f5e1 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -1,14 +1,15 @@ import { LDContext } from '@launchdarkly/js-sdk-common'; + import { LDClient, LDMigrationStage } from './api'; +import { LDMigration, LDMigrationReadResult, LDMigrationWriteResult } from './api/LDMigration'; import { - LDMigrationOptions, - LDSerialExecution, LDConcurrentExecution, LDExecution, LDExecutionOrdering, LDMethodResult, + LDMigrationOptions, + LDSerialExecution, } from './api/options/LDMigrationOptions'; -import { LDMigration, LDMigrationReadResult, LDMigrationWriteResult } from './api/LDMigration'; type MultipleReadResult = { fromOld: LDMethodResult; @@ -16,7 +17,7 @@ type MultipleReadResult = { }; async function safeCall( - method: () => Promise> + method: () => Promise>, ): Promise> { try { // Awaiting to allow catching. @@ -34,7 +35,7 @@ async function readSequentialRandom< TMigrationRead, TMigrationWrite, TMigrationReadInput, - TMigrationWriteInput + TMigrationWriteInput, >( config: LDMigrationOptions< TMigrationRead, @@ -42,7 +43,7 @@ async function readSequentialRandom< TMigrationReadInput, TMigrationWriteInput >, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ): Promise> { // This number is not used for a purpose requiring cryptographic security. const randomIndex = Math.floor(Math.random() * 2); @@ -62,7 +63,7 @@ async function readSequentialFixed< TMigrationRead, TMigrationWrite, TMigrationReadInput, - TMigrationWriteInput + TMigrationWriteInput, >( config: LDMigrationOptions< TMigrationRead, @@ -70,7 +71,7 @@ async function readSequentialFixed< TMigrationReadInput, TMigrationWriteInput >, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ): Promise> { const fromOld = await safeCall(() => config.readOld(payload)); const fromNew = await safeCall(() => config.readNew(payload)); @@ -81,7 +82,7 @@ async function readConcurrent< TMigrationRead, TMigrationWrite, TMigrationReadInput, - TMigrationWriteInput + TMigrationWriteInput, >( config: LDMigrationOptions< TMigrationRead, @@ -89,7 +90,7 @@ async function readConcurrent< TMigrationReadInput, TMigrationWriteInput >, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ): Promise> { const fromOldPromise = safeCall(() => config.readOld(payload)); const fromNewPromise = safeCall(() => config.readNew(payload)); @@ -107,7 +108,7 @@ async function read, execution: LDSerialExecution | LDConcurrentExecution, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ): Promise> { if (execution.type === LDExecution.Serial) { const serial = execution as LDSerialExecution; @@ -137,7 +138,7 @@ export default class Migration< TMigrationRead, TMigrationWrite, TMigrationReadInput = any, - TMigrationWriteInput = any + TMigrationWriteInput = any, > implements LDMigration { @@ -151,7 +152,7 @@ export default class Migration< TMigrationReadInput, TMigrationWriteInput >, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ) => Promise>; } = { [LDMigrationStage.Off]: async (config, payload) => ({ @@ -194,7 +195,7 @@ export default class Migration< TMigrationReadInput, TMigrationWriteInput >, - payload?: TMigrationWriteInput + payload?: TMigrationWriteInput, ) => Promise>; } = { [LDMigrationStage.Off]: async (config, payload) => ({ @@ -268,7 +269,7 @@ export default class Migration< TMigrationWrite, TMigrationReadInput, TMigrationWriteInput - > + >, ) { if (config.execution) { this.execution = config.execution; @@ -281,7 +282,7 @@ export default class Migration< key: string, context: LDContext, defaultStage: LDMigrationStage, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); return this.readTable[stage](this.config, payload); @@ -291,7 +292,7 @@ export default class Migration< key: string, context: LDContext, defaultStage: LDMigrationStage, - payload?: TMigrationWriteInput + payload?: TMigrationWriteInput, ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index 85e0a4b2e..62747d683 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -1,8 +1,8 @@ import { LDContext, LDEvaluationDetail, LDFlagValue } from '@launchdarkly/js-sdk-common'; import { LDFlagsState } from './data/LDFlagsState'; -import { LDMigrationStage } from './data/LDMigrationStage'; import { LDFlagsStateOptions } from './data/LDFlagsStateOptions'; +import { LDMigrationStage } from './data/LDMigrationStage'; /** * The LaunchDarkly SDK client object. @@ -140,7 +140,7 @@ export interface LDClient { key: string, context: LDContext, defaultValue: LDMigrationStage, - callback?: (err: any, res: LDMigrationStage) => void + callback?: (err: any, res: LDMigrationStage) => void, ): Promise; /** diff --git a/packages/shared/sdk-server/src/api/LDMigration.ts b/packages/shared/sdk-server/src/api/LDMigration.ts index 21458cde1..b19085a62 100644 --- a/packages/shared/sdk-server/src/api/LDMigration.ts +++ b/packages/shared/sdk-server/src/api/LDMigration.ts @@ -1,4 +1,5 @@ import { LDContext } from '@launchdarkly/js-sdk-common'; + import { LDMigrationStage } from './data/LDMigrationStage'; /** @@ -58,7 +59,7 @@ export interface LDMigration< TMigrationRead, TMigrationWrite, TMigrationReadInput, - TMigrationWriteInput + TMigrationWriteInput, > { /** * TKTK @@ -73,7 +74,7 @@ export interface LDMigration< key: string, context: LDContext, defaultValue: LDMigrationStage, - payload?: TMigrationReadInput + payload?: TMigrationReadInput, ): Promise>; /** @@ -89,6 +90,6 @@ export interface LDMigration< key: string, context: LDContext, defaultValue: LDMigrationStage, - payload?: TMigrationWriteInput + payload?: TMigrationWriteInput, ): Promise>; } diff --git a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts index 7846efee4..3e7477165 100644 --- a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts @@ -83,7 +83,7 @@ export interface LDMigrationOptions< TMigrationRead, TMigrationWrite, TMigrationReadInput, - TMigrationWriteInput + TMigrationWriteInput, > { /** * Configure how the migration should execute. If omitted the execution will diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index 8bfd9fdc4..daf5121df 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -30,7 +30,7 @@ export default class EventFactory { prereqOfFlag?.key, this.withReasons || addExperimentData ? detail.reason : undefined, flag.debugEventsUntilDate, - flag.excludeFromSummaries + flag.excludeFromSummaries, ); } From 42f48b351a8bbd5abbbf929e6f8c8aa62c7b4ce7 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 1 Aug 2023 13:21:40 -0700 Subject: [PATCH 15/57] fix: ran prettier --- packages/shared/common/src/logging/createSafeLogger.ts | 1 + packages/shared/sdk-client/src/LDClientDomImpl.ts | 2 ++ packages/shared/sdk-client/src/api/LDClientDom.ts | 2 +- packages/shared/sdk-client/src/api/LDOptions.ts | 1 + packages/shared/sdk-client/tsconfig.json | 2 +- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shared/common/src/logging/createSafeLogger.ts b/packages/shared/common/src/logging/createSafeLogger.ts index aec065dd4..6169b18d7 100644 --- a/packages/shared/common/src/logging/createSafeLogger.ts +++ b/packages/shared/common/src/logging/createSafeLogger.ts @@ -1,4 +1,5 @@ import { format } from 'util'; + import { LDLogger } from '../api'; import BasicLogger from './BasicLogger'; import SafeLogger from './SafeLogger'; diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index 1cf286fc5..c4b7095c6 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -1,4 +1,5 @@ // temporarily allow unused vars for the duration of the migration + /* eslint-disable @typescript-eslint/no-unused-vars */ import { createSafeLogger, @@ -9,6 +10,7 @@ import { LDLogger, Platform, } from '@launchdarkly/js-sdk-common'; + import { LDClientDom } from './api/LDClientDom'; import { LDOptions } from './api/LDOptions'; diff --git a/packages/shared/sdk-client/src/api/LDClientDom.ts b/packages/shared/sdk-client/src/api/LDClientDom.ts index 574f0ac38..b09dc3dff 100644 --- a/packages/shared/sdk-client/src/api/LDClientDom.ts +++ b/packages/shared/sdk-client/src/api/LDClientDom.ts @@ -102,7 +102,7 @@ export interface LDClientDom { identify( context: LDContext, hash?: string, - onDone?: (err: Error | null, flags: LDFlagSet | null) => void + onDone?: (err: Error | null, flags: LDFlagSet | null) => void, ): Promise; /** diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 263dad6a8..efb925a76 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,4 +1,5 @@ import type { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common'; + import type { LDInspection } from './LDInspection'; export interface LDOptions { diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json index dd1ef125d..621613945 100644 --- a/packages/shared/sdk-client/tsconfig.json +++ b/packages/shared/sdk-client/tsconfig.json @@ -14,6 +14,6 @@ "declarationMap": true, // enables importers to jump to source "stripInternal": true }, - "include": ["src", "./jest-setupFilesAfterEnv.ts"], + "include": ["src"], "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] } From 2a1eb6fdf27222475f04b98c23d360cd53b2cb7a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 1 Aug 2023 13:48:05 -0700 Subject: [PATCH 16/57] feat: Add migration operation input event and tracker. (#214) --- .../__tests__/LDClient.migrations.test.ts | 19 +- .../__tests__/MigrationOpTracker.test.ts | 185 ++++++++++++++++++ .../shared/sdk-server/src/LDClientImpl.ts | 36 +++- packages/shared/sdk-server/src/Migration.ts | 4 +- .../sdk-server/src/MigrationOpTracker.ts | 125 ++++++++++++ .../shared/sdk-server/src/api/LDClient.ts | 7 +- .../src/api/data/LDMigrationDetail.ts | 54 +++++ .../src/api/data/LDMigrationOpEvent.ts | 47 +++++ .../shared/sdk-server/src/api/data/index.ts | 2 + 9 files changed, 449 insertions(+), 30 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts create mode 100644 packages/shared/sdk-server/src/MigrationOpTracker.ts create mode 100644 packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts create mode 100644 packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index 5f3bce6a9..460c3c333 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -62,7 +62,7 @@ describe('given an LDClient with test data', () => { { key: 'test-key' }, defaultValue as LDMigrationStage, ); - expect(res).toEqual(value); + expect(res.value).toEqual(value); }, ); @@ -76,30 +76,17 @@ describe('given an LDClient with test data', () => { ])('returns the default value if the flag does not exist: default = %p', async (stage) => { const res = await client.variationMigration('no-flag', { key: 'test-key' }, stage); - expect(res).toEqual(stage); + expect(res.value).toEqual(stage); }); it('produces an error event for a migration flag with an incorrect value', async () => { const flagKey = 'bad-migration'; td.update(td.flag(flagKey).valueForAll('potato')); const res = await client.variationMigration(flagKey, { key: 'test-key' }, LDMigrationStage.Off); - expect(res).toEqual(LDMigrationStage.Off); + expect(res.value).toEqual(LDMigrationStage.Off); expect(errors.length).toEqual(1); expect(errors[0].message).toEqual( 'Unrecognized MigrationState for "bad-migration"; returning default value.', ); }); - - it('includes an error in the node callback', (done) => { - const flagKey = 'bad-migration'; - td.update(td.flag(flagKey).valueForAll('potato')); - client.variationMigration(flagKey, { key: 'test-key' }, LDMigrationStage.Off, (err, value) => { - const error = err as Error; - expect(error.message).toEqual( - 'Unrecognized MigrationState for "bad-migration"; returning default value.', - ); - expect(value).toEqual(LDMigrationStage.Off); - done(); - }); - }); }); diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts new file mode 100644 index 000000000..080044b9e --- /dev/null +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -0,0 +1,185 @@ +import { Context } from '@launchdarkly/js-sdk-common'; + +import { LDConsistencyCheck, LDMigrationStage } from '../src'; +import MigrationOpTracker from '../src/MigrationOpTracker'; + +it('does not generate an event if an op is not set', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + expect(tracker.createEvent()).toBeUndefined(); +}); + +it('does not generate an event for an invalid context', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'kind', key: '' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + // Set the op otherwise that would prevent an event as well. + tracker.op('write'); + + expect(tracker.createEvent()).toBeUndefined(); +}); + +it('generates an event if the minimal requirements are met.', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + tracker.op('write'); + + expect(tracker.createEvent()).toMatchObject({ + contextKeys: { user: 'bob' }, + evaluation: { default: 'off', key: 'flag', reason: { kind: 'FALLTHROUGH' }, value: 'off' }, + kind: 'migration_op', + measurements: [], + operation: 'write', + }); +}); + +it('includes errors if at least one is set', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + tracker.error('old'); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'error', + values: { + old: 1, + new: 0, + }, + }); + + const trackerB = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + trackerB.op('read'); + trackerB.error('new'); + + const eventB = trackerB.createEvent(); + expect(eventB?.measurements).toContainEqual({ + key: 'error', + values: { + old: 0, + new: 1, + }, + }); +}); + +it('includes latency if at least one measurement exists', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + tracker.latency('old', 100); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'latency', + values: { + old: 100, + }, + }); + + const trackerB = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + trackerB.op('read'); + trackerB.latency('new', 150); + + const eventB = trackerB.createEvent(); + expect(eventB?.measurements).toContainEqual({ + key: 'latency', + values: { + new: 150, + }, + }); +}); + +it('includes if the result was consistent', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + tracker.consistency(LDConsistencyCheck.Consistent); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'consistent', + value: 1, + samplingOdds: 0, + }); +}); + +it('includes if the result was inconsistent', () => { + const tracker = new MigrationOpTracker( + 'flag', + Context.fromLDContext({ kind: 'user', key: 'bob' }), + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + tracker.consistency(LDConsistencyCheck.Inconsistent); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'consistent', + value: 0, + samplingOdds: 0, + }); +}); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 827c12570..2751d8735 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -17,6 +17,7 @@ import { LDClient, LDFlagsState, LDFlagsStateOptions, + LDMigrationDetail, LDMigrationStage, LDOptions, LDStreamProcessor, @@ -45,6 +46,7 @@ import EventSender from './events/EventSender'; import isExperiment from './events/isExperiment'; import NullEventProcessor from './events/NullEventProcessor'; import FlagsStateBuilder from './FlagsStateBuilder'; +import MigrationOpTracker from './MigrationOpTracker'; import Configuration from './options/Configuration'; import AsyncStoreFacade from './store/AsyncStoreFacade'; import VersionedDataKinds from './store/VersionedDataKinds'; @@ -274,17 +276,35 @@ export default class LDClientImpl implements LDClient { key: string, context: LDContext, defaultValue: LDMigrationStage, - callback?: (err: any, res: LDMigrationStage) => void, - ): Promise { - const stringValue = await this.variation(key, context, defaultValue as string); - if (!IsMigrationStage(stringValue)) { + ): Promise { + const convertedContext = Context.fromLDContext(context); + const detail = await this.variationDetail(key, context, defaultValue as string); + if (!IsMigrationStage(detail.value)) { const error = new Error(`Unrecognized MigrationState for "${key}"; returning default value.`); this.onError(error); - callback?.(error, defaultValue); - return defaultValue; + const reason = { + kind: 'ERROR', + errorKind: 'WRONG_TYPE', + }; + return { + value: defaultValue, + reason, + tracker: new MigrationOpTracker(key, convertedContext, defaultValue, defaultValue, reason), + }; } - callback?.(null, stringValue as LDMigrationStage); - return stringValue as LDMigrationStage; + return { + ...detail, + value: detail.value as LDMigrationStage, + tracker: new MigrationOpTracker( + key, + convertedContext, + defaultValue, + defaultValue, + detail.reason, + // Can be null for compatibility reasons. + detail.variationIndex === null ? undefined : detail.variationIndex, + ), + }; } async allFlagsState( diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index f3cb7f5e1..adc98265a 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -285,7 +285,7 @@ export default class Migration< payload?: TMigrationReadInput, ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); - return this.readTable[stage](this.config, payload); + return this.readTable[stage.value](this.config, payload); } async write( @@ -296,6 +296,6 @@ export default class Migration< ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); - return this.writeTable[stage](this.config, payload); + return this.writeTable[stage.value](this.config, payload); } } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts new file mode 100644 index 000000000..ff3d55a16 --- /dev/null +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -0,0 +1,125 @@ +import { Context, LDEvaluationReason } from '@launchdarkly/js-sdk-common'; + +import { LDMigrationStage, LDMigrationTracker } from './api'; +import { + LDConsistencyCheck, + LDMigrationMeasurement, + LDMigrationOp, + LDMigrationOpEvent, +} from './api/data'; +import { LDMigrationOrigin } from './api/LDMigration'; + +function isPopulated(data: number): boolean { + return !Number.isNaN(data); +} + +export default class MigrationOpTracker implements LDMigrationTracker { + private errors = { + old: false, + new: false, + }; + + private consistencyCheck?: LDConsistencyCheck; + + private latencyMeasurement = { + old: NaN, + new: NaN, + }; + + private operation?: LDMigrationOp; + + constructor( + private readonly flagKey: string, + private readonly context: Context, + private readonly defaultStage: LDMigrationStage, + private readonly stage: LDMigrationStage, + private readonly reason: LDEvaluationReason, + private readonly variation?: number, + ) {} + + op(op: LDMigrationOp) { + this.operation = op; + } + + error(origin: LDMigrationOrigin) { + this.errors[origin] = true; + } + + consistency(result: LDConsistencyCheck) { + this.consistencyCheck = result; + } + + latency(origin: LDMigrationOrigin, value: number) { + this.latencyMeasurement[origin] = value; + } + + createEvent(): LDMigrationOpEvent | undefined { + if (this.operation && this.context.valid) { + const measurements: LDMigrationMeasurement[] = []; + + this.populateConsistency(measurements); + this.populateLatency(measurements); + this.populateErrors(measurements); + + return { + kind: 'migration_op', + operation: this.operation, + creationDate: Date.now(), + contextKeys: this.context.kindsAndKeys, + evaluation: { + key: this.flagKey, + value: this.stage, + default: this.defaultStage, + reason: this.reason, + variation: this.variation, + }, + measurements, + }; + } + return undefined; + } + + private populateConsistency(measurements: LDMigrationMeasurement[]) { + if ( + this.consistencyCheck !== undefined && + this.consistencyCheck !== LDConsistencyCheck.NotChecked + ) { + measurements.push({ + key: 'consistent', + value: this.consistencyCheck, + // TODO: Needs to come from someplace. + samplingOdds: 0, + }); + } + } + + private populateErrors(measurements: LDMigrationMeasurement[]) { + if (this.errors.new || this.errors.old) { + measurements.push({ + key: 'error', + values: { + old: this.errors.old ? 1 : 0, + new: this.errors.new ? 1 : 0, + }, + }); + } + } + + private populateLatency(measurements: LDMigrationMeasurement[]) { + const newIsPopulated = isPopulated(this.latencyMeasurement.new); + const oldIsPopulated = isPopulated(this.latencyMeasurement.old); + if (newIsPopulated || oldIsPopulated) { + const values: { old?: number; new?: number } = {}; + if (newIsPopulated) { + values.new = this.latencyMeasurement.new; + } + if (oldIsPopulated) { + values.old = this.latencyMeasurement.old; + } + measurements.push({ + key: 'latency', + values, + }); + } + } +} diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index 62747d683..8b523ef09 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -1,5 +1,6 @@ import { LDContext, LDEvaluationDetail, LDFlagValue } from '@launchdarkly/js-sdk-common'; +import { LDMigrationDetail } from './data'; import { LDFlagsState } from './data/LDFlagsState'; import { LDFlagsStateOptions } from './data/LDFlagsStateOptions'; import { LDMigrationStage } from './data/LDMigrationStage'; @@ -132,16 +133,14 @@ export interface LDClient { * register this context with LaunchDarkly if the context does not already exist. * @param defaultValue The default value of the flag, to be used if the value is not available * from LaunchDarkly. - * @param callback A Node-style callback to receive the result (as an {@link LDMigrationStage}). * @returns - * A Promise which will be resolved with the result (as an{@link LDMigrationStage}). + * A Promise which will be resolved with the result (as an{@link LDMigrationDetail}). */ variationMigration( key: string, context: LDContext, defaultValue: LDMigrationStage, - callback?: (err: any, res: LDMigrationStage) => void, - ): Promise; + ): Promise; /** * Builds an object that encapsulates the state of all feature flags for a given context. diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts new file mode 100644 index 000000000..e35e045c1 --- /dev/null +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -0,0 +1,54 @@ +import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; + +import { LDMigrationOrigin } from '../LDMigration'; +import { LDMigrationOp, LDMigrationOpEvent } from './LDMigrationOpEvent'; +import { LDMigrationStage } from './LDMigrationStage'; + +/** + * Used for reporting the state of a consistency check. + */ +export enum LDConsistencyCheck { + Inconsistent = 0, + Consistent = 1, + NotChecked = 2, +} + +/** + * Used to track information related to a migration operation. + * + * TKTK + */ +export interface LDMigrationTracker { + op(op: LDMigrationOp): void; + error(origin: LDMigrationOrigin): void; + consistency(result: LDConsistencyCheck): void; + latency(origin: LDMigrationOrigin, value: number): void; + createEvent(): LDMigrationOpEvent | undefined; +} + +/** + * Detailed information about a migration variation. + */ +export interface LDMigrationDetail { + /** + * The result of the flag evaluation. This will be either one of the flag's variations or + * the default value that was passed to `LDClient.variationDetail`. + */ + value: LDMigrationStage; + + /** + * The index of the returned value within the flag's list of variations, e.g. 0 for the + * first variation-- or `null` if the default value was returned. + */ + variationIndex?: number | null; + + /** + * An object describing the main factor that influenced the flag evaluation value. + */ + reason: LDEvaluationReason; + + /** + * A tracker which which can be used to generate analytics for the migration. + */ + tracker: LDMigrationTracker; +} diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts new file mode 100644 index 000000000..3b88a0570 --- /dev/null +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -0,0 +1,47 @@ +import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; + +export type LDMigrationOp = 'read' | 'write'; + +/** + * Component of an LDMigrationOpEvent which tracks information about the + * evaluation of the migration flag. + */ +interface LDMigrationEvaluation { + key: string; + value: any; + default: any; + variation?: number; + reason: LDEvaluationReason; +} + +/** + * Types of measurements supported by an LDMigrationOpEvent. + */ +export type LDMigrationMeasurement = + | { + key: 'latency' | 'error'; + values: { + old?: number; + new?: number; + }; + } + | { + key: 'consistent'; + value: number; + samplingOdds: number; + }; + +/** + * Event used to track information about a migration operation. + * + * Generally this event should not be created directly and instead an + * {@link MigrationOpTracker} should be used to generate it. + */ +export interface LDMigrationOpEvent { + kind: 'migration_op'; + operation: LDMigrationOp; + creationDate: number; + contextKeys: Record; + evaluation: LDMigrationEvaluation; + measurements: LDMigrationMeasurement[]; +} diff --git a/packages/shared/sdk-server/src/api/data/index.ts b/packages/shared/sdk-server/src/api/data/index.ts index cb75a1bc7..0aeca0e14 100644 --- a/packages/shared/sdk-server/src/api/data/index.ts +++ b/packages/shared/sdk-server/src/api/data/index.ts @@ -1,3 +1,5 @@ export * from './LDFlagsStateOptions'; export * from './LDFlagsState'; export * from './LDMigrationStage'; +export * from './LDMigrationDetail'; +export * from './LDMigrationOpEvent'; From 87095c9669e59fd9d7c2a840c51fc74c419b7aa2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 1 Aug 2023 16:06:27 -0700 Subject: [PATCH 17/57] feat: Implement migration op event and connect to tracking. (#218) --- packages/shared/common/src/Context.ts | 5 +- .../src/internal/events/EventProcessor.ts | 12 +- .../common/src/internal/events/InputEvent.ts | 3 +- .../internal/events/InputMigrationEvent.ts | 13 + .../common/src/internal/events/guards.ts | 5 + .../common/src/internal/events/index.ts | 10 +- packages/shared/common/src/validators.ts | 15 + .../__tests__/MigratioOpEvent.test.ts | 381 ++++++++++++++++++ .../sdk-server/__tests__/Migration.test.ts | 34 +- .../__tests__/MigrationOpTracker.test.ts | 32 +- .../shared/sdk-server/src/LDClientImpl.ts | 14 +- packages/shared/sdk-server/src/Migration.ts | 360 +++++++++-------- .../src/MigrationOpEventConversion.ts | 34 ++ .../sdk-server/src/MigrationOpTracker.ts | 8 +- .../shared/sdk-server/src/api/LDClient.ts | 9 +- .../src/api/options/LDMigrationOptions.ts | 24 +- 16 files changed, 724 insertions(+), 235 deletions(-) create mode 100644 packages/shared/common/src/internal/events/InputMigrationEvent.ts create mode 100644 packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts create mode 100644 packages/shared/sdk-server/src/MigrationOpEventConversion.ts diff --git a/packages/shared/common/src/Context.ts b/packages/shared/common/src/Context.ts index f71d4a148..d371aaaf1 100644 --- a/packages/shared/common/src/Context.ts +++ b/packages/shared/common/src/Context.ts @@ -15,9 +15,6 @@ import { TypeValidators } from './validators'; // This is to reduce work on the hot-path. Later, for event processing, deeper // cloning of the context will be done. -// Validates a kind excluding check that it isn't "kind". -const KindValidator = TypeValidators.stringMatchingRegex(/^(\w|\.|-)+$/); - // When no kind is specified, then this kind will be used. const DEFAULT_KIND = 'user'; @@ -98,7 +95,7 @@ function isContextCommon( * @returns true if the kind is valid. */ function validKind(kind: string) { - return KindValidator.is(kind) && kind !== 'kind'; + return TypeValidators.Kind.is(kind); } /** diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index ade263dcc..870a4fc35 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -7,7 +7,7 @@ import AttributeReference from '../../AttributeReference'; import ContextFilter from '../../ContextFilter'; import ClientContext from '../../options/ClientContext'; import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; -import { isFeature, isIdentify } from './guards'; +import { isFeature, isIdentify, isMigration } from './guards'; import InputEvent from './InputEvent'; import LDInvalidSDKKeyError from './LDInvalidSDKKeyError'; @@ -196,6 +196,16 @@ export default class EventProcessor implements LDEventProcessor { return; } + if (isMigration(inputEvent)) { + // The contents of the migration op event have been validated + // before this point, so we can just send it. + // TODO: Implement sampling odds. + this.enqueue({ + ...inputEvent, + }); + return; + } + this.summarizer.summarizeEvent(inputEvent); const isFeatureEvent = isFeature(inputEvent); diff --git a/packages/shared/common/src/internal/events/InputEvent.ts b/packages/shared/common/src/internal/events/InputEvent.ts index f3962d358..2ffa15f51 100644 --- a/packages/shared/common/src/internal/events/InputEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvent.ts @@ -1,6 +1,7 @@ import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; +import InputMigrationEvent from './InputMigrationEvent'; -type InputEvent = InputEvalEvent | InputCustomEvent | InputIdentifyEvent; +type InputEvent = InputEvalEvent | InputCustomEvent | InputIdentifyEvent | InputMigrationEvent; export default InputEvent; diff --git a/packages/shared/common/src/internal/events/InputMigrationEvent.ts b/packages/shared/common/src/internal/events/InputMigrationEvent.ts new file mode 100644 index 000000000..cebc9b440 --- /dev/null +++ b/packages/shared/common/src/internal/events/InputMigrationEvent.ts @@ -0,0 +1,13 @@ +// Migration events are not currently supported by client-side SDKs, so this +// shared implementation contains minimal typing. If/When migration events are +// to be supported by client-side SDKs the appropriate types would be moved +// to the common implementation. + +export default interface InputMigrationEvent { + kind: 'migration_op'; + operation: string; + creationDate: number; + contextKeys: Record; + evaluation: any; + measurements: any[]; +} diff --git a/packages/shared/common/src/internal/events/guards.ts b/packages/shared/common/src/internal/events/guards.ts index 715f80859..de5fa6e86 100644 --- a/packages/shared/common/src/internal/events/guards.ts +++ b/packages/shared/common/src/internal/events/guards.ts @@ -1,6 +1,7 @@ import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; +import InputMigrationEvent from './InputMigrationEvent'; export function isFeature(u: any): u is InputEvalEvent { return u.kind === 'feature'; @@ -13,3 +14,7 @@ export function isCustom(u: any): u is InputCustomEvent { export function isIdentify(u: any): u is InputIdentifyEvent { return u.kind === 'identify'; } + +export function isMigration(u: any): u is InputMigrationEvent { + return u.kind === 'migration_op'; +} diff --git a/packages/shared/common/src/internal/events/index.ts b/packages/shared/common/src/internal/events/index.ts index 7b32527fb..8e554d52e 100644 --- a/packages/shared/common/src/internal/events/index.ts +++ b/packages/shared/common/src/internal/events/index.ts @@ -3,5 +3,13 @@ import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; +import InputMigrationEvent from './InputMigrationEvent'; -export { InputCustomEvent, InputEvalEvent, InputEvent, InputIdentifyEvent, EventProcessor }; +export { + InputCustomEvent, + InputEvalEvent, + InputEvent, + InputIdentifyEvent, + InputMigrationEvent, + EventProcessor, +}; diff --git a/packages/shared/common/src/validators.ts b/packages/shared/common/src/validators.ts index d6bcc2fef..2ecd77501 100644 --- a/packages/shared/common/src/validators.ts +++ b/packages/shared/common/src/validators.ts @@ -161,6 +161,19 @@ export class DateValidator implements TypeValidator { } } +/** + * Validates that a string is a valid kind. + */ +export class KindValidator extends StringMatchingRegex { + constructor() { + super(/^(\w|\.|-)+$/); + } + + override is(u: unknown): u is string { + return super.is(u) && u !== 'kind'; + } +} + /** * A set of standard type validators. */ @@ -188,4 +201,6 @@ export class TypeValidators { } static readonly Date = new DateValidator(); + + static readonly Kind = new KindValidator(); } diff --git a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts new file mode 100644 index 000000000..f55c2c93c --- /dev/null +++ b/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts @@ -0,0 +1,381 @@ +import { InputMigrationEvent } from '@launchdarkly/js-sdk-common/dist/internal'; + +import { + internal, + LDClientImpl, + LDConcurrentExecution, + LDExecutionOrdering, + LDMigrationStage, + LDSerialExecution, +} from '../src'; +import { TestData } from '../src/integrations'; +import { LDClientCallbacks } from '../src/LDClientImpl'; +import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import basicPlatform from './evaluation/mocks/platform'; +import makeCallbacks from './makeCallbacks'; + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let events: internal.InputEvent[]; + let td: TestData; + let callbacks: LDClientCallbacks; + + beforeEach(async () => { + events = []; + jest + .spyOn(internal.EventProcessor.prototype, 'sendEvent') + .mockImplementation((evt) => events.push(evt)); + + td = new TestData(); + callbacks = makeCallbacks(false); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + }, + callbacks, + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + describe.each([ + [new LDSerialExecution(LDExecutionOrdering.Fixed), 'serial fixed'], + [new LDSerialExecution(LDExecutionOrdering.Random), 'serial random'], + [new LDConcurrentExecution(), 'concurrent'], + ])('given different execution methods: %p %p', (execution) => { + describe('given a migration which checks consistency and produces consistent results', () => { + let migration: Migration; + beforeEach(() => { + migration = new Migration(client, { + execution, + latencyTracking: false, + errorTracking: false, + readNew: async (payload?: string) => LDMigrationSuccess(payload || 'default'), + writeNew: async (payload?: string) => LDMigrationSuccess(payload || 'default'), + readOld: async (payload?: string) => LDMigrationSuccess(payload || 'default'), + writeOld: async (payload?: string) => LDMigrationSuccess(payload || 'default'), + check: (a: string, b: string) => a === b, + }); + }); + + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'finds the results consistent: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('consistent'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].value).toEqual(1); + }, + ); + }); + + describe('given a migration which checks consistency and produces inconsistent results', () => { + let migration: Migration; + beforeEach(() => { + migration = new Migration(client, { + execution, + latencyTracking: false, + errorTracking: false, + readNew: async () => LDMigrationSuccess('a'), + writeNew: async () => LDMigrationSuccess('b'), + readOld: async () => LDMigrationSuccess('c'), + writeOld: async () => LDMigrationSuccess('d'), + check: (a: string, b: string) => a === b, + }); + }); + + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'finds the results consistent: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('consistent'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].value).toEqual(0); + }, + ); + }); + + describe('given a migration which takes time to execute and tracks latency', () => { + let migration: Migration; + + function timeoutPromise(val: TReturn): Promise { + return new Promise((a) => { + setTimeout(() => a(val), 2); + }); + } + + beforeEach(() => { + migration = new Migration(client, { + execution, + latencyTracking: true, + errorTracking: false, + readNew: async () => timeoutPromise(LDMigrationSuccess('readNew')), + writeNew: async () => timeoutPromise(LDMigrationSuccess('writeNew')), + readOld: async () => timeoutPromise(LDMigrationSuccess('readOld')), + writeOld: async () => timeoutPromise(LDMigrationSuccess('writeOld')), + }); + }); + + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'can report read latency for new and old', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + }, + ); + + it.each([LDMigrationStage.Off, LDMigrationStage.DualWrite])( + 'can report latency for old reads', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.new).toBeUndefined(); + }, + ); + + it.each([LDMigrationStage.RampDown, LDMigrationStage.Complete])( + 'can report latency for new reads', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.old).toBeUndefined(); + }, + ); + + it.each([LDMigrationStage.Off])('can report latency for old writes: %p', async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.new).toBeUndefined(); + }); + + it.each([LDMigrationStage.Complete])( + 'can report latency for new writes: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.old).toBeUndefined(); + }, + ); + + it.each([LDMigrationStage.DualWrite, LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'can report latency for old and new writes: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + }, + ); + + it('can report write latency for new', async () => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.Live)); + + await migration.write(flagKey, { key: 'test' }, LDMigrationStage.Live); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + const migrationEvent = events[1] as InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('latency'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + }); + }); + + describe('given a migration which produces errors for every step', () => { + let migration: Migration; + beforeEach(() => { + migration = new Migration(client, { + execution, + latencyTracking: false, + errorTracking: true, + readNew: async () => LDMigrationError(new Error('error')), + writeNew: async () => LDMigrationError(new Error('error')), + readOld: async () => LDMigrationError(new Error('error')), + writeOld: async () => LDMigrationError(new Error('error')), + }); + }); + + it.each([LDMigrationStage.Off, LDMigrationStage.DualWrite])( + 'can report errors for old reads: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + expect(events[1]).toMatchObject({ + measurements: [ + { + key: 'error', + values: { + old: 1, + new: 0, + }, + }, + ], + }); + }, + ); + + it.each([LDMigrationStage.RampDown, LDMigrationStage.Complete])( + 'can report errors for new reads: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + expect(events[1]).toMatchObject({ + measurements: [ + { + key: 'error', + values: { + old: 0, + new: 1, + }, + }, + ], + }); + }, + ); + + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'can report errors for old and new reads simultaneously: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + expect(events[1]).toMatchObject({ + measurements: [ + { + key: 'error', + values: { + old: 1, + new: 1, + }, + }, + ], + }); + }, + ); + + it.each([LDMigrationStage.Off, LDMigrationStage.DualWrite, LDMigrationStage.Shadow])( + 'can report errors for old writes: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + expect(events[1]).toMatchObject({ + measurements: [ + { + key: 'error', + values: { + old: 1, + new: 0, + }, + }, + ], + }); + }, + ); + + it.each([LDMigrationStage.Live, LDMigrationStage.RampDown, LDMigrationStage.Complete])( + 'can report errors for new writes: %p', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + expect(events.length).toBe(2); + // Only check the measurements component of the event. + expect(events[1]).toMatchObject({ + measurements: [ + { + key: 'error', + values: { + old: 0, + new: 1, + }, + }, + ], + }); + }, + ); + }); + }); +}); diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts index 49966164f..79207f0f1 100644 --- a/packages/shared/sdk-server/__tests__/Migration.test.ts +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -1,9 +1,7 @@ import { LDClientImpl, LDConcurrentExecution, - LDErrorTracking, LDExecutionOrdering, - LDLatencyTracking, LDMigrationStage, LDSerialExecution, } from '../src'; @@ -176,8 +174,8 @@ describe('given an LDClient with test data', () => { it('uses the correct authoritative source', async () => { const migration = new Migration(client, { execution, - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => LDMigrationSuccess('new'), writeNew: async () => LDMigrationSuccess(false), readOld: async () => LDMigrationSuccess('old'), @@ -208,8 +206,8 @@ describe('given an LDClient with test data', () => { let receivedWritePayload: string | undefined; const migration = new Migration(client, { execution, - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async (payload) => { receivedReadPayload = payload; return LDMigrationSuccess('new'); @@ -253,8 +251,8 @@ describe('given an LDClient with test data', () => { ])('handles read errors for stage: %p', async (stage, authority) => { const migration = new Migration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => LDMigrationError(new Error('new')), writeNew: async () => LDMigrationSuccess(false), readOld: async () => LDMigrationError(new Error('old')), @@ -286,8 +284,8 @@ describe('given an LDClient with test data', () => { ])('handles exceptions for stage: %p', async (stage, authority) => { const migration = new Migration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => { throw new Error('new'); }, @@ -382,8 +380,8 @@ describe('given an LDClient with test data', () => { const migration = new Migration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => LDMigrationSuccess('new'), writeNew: async () => { newWriteCalled = true; @@ -424,8 +422,8 @@ describe('given an LDClient with test data', () => { const migration = new Migration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => LDMigrationSuccess('new'), writeNew: async () => { newWriteCalled = true; @@ -457,8 +455,8 @@ describe('given an LDClient with test data', () => { it('handles the case where the authoritative write succeeds, but the non-authoritative fails', async () => { const migrationA = new Migration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => LDMigrationSuccess('new'), writeNew: async () => { throw new Error('new'); @@ -504,8 +502,8 @@ describe('given an LDClient with test data', () => { const migrationB = new Migration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), - latencyTracking: LDLatencyTracking.Disabled, - errorTracking: LDErrorTracking.Disabled, + latencyTracking: false, + errorTracking: false, readNew: async () => LDMigrationSuccess('new'), writeNew: async () => LDMigrationSuccess(true), readOld: async () => LDMigrationSuccess('old'), diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 080044b9e..fb6571c1f 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -1,12 +1,10 @@ -import { Context } from '@launchdarkly/js-sdk-common'; - import { LDConsistencyCheck, LDMigrationStage } from '../src'; import MigrationOpTracker from '../src/MigrationOpTracker'; it('does not generate an event if an op is not set', () => { const tracker = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -17,16 +15,10 @@ it('does not generate an event if an op is not set', () => { expect(tracker.createEvent()).toBeUndefined(); }); -it('does not generate an event for an invalid context', () => { - const tracker = new MigrationOpTracker( - 'flag', - Context.fromLDContext({ kind: 'kind', key: '' }), - LDMigrationStage.Off, - LDMigrationStage.Off, - { - kind: 'FALLTHROUGH', - }, - ); +it('does not generate an event with missing context keys', () => { + const tracker = new MigrationOpTracker('flag', {}, LDMigrationStage.Off, LDMigrationStage.Off, { + kind: 'FALLTHROUGH', + }); // Set the op otherwise that would prevent an event as well. tracker.op('write'); @@ -37,7 +29,7 @@ it('does not generate an event for an invalid context', () => { it('generates an event if the minimal requirements are met.', () => { const tracker = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -59,7 +51,7 @@ it('generates an event if the minimal requirements are met.', () => { it('includes errors if at least one is set', () => { const tracker = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -80,7 +72,7 @@ it('includes errors if at least one is set', () => { const trackerB = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -103,7 +95,7 @@ it('includes errors if at least one is set', () => { it('includes latency if at least one measurement exists', () => { const tracker = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -123,7 +115,7 @@ it('includes latency if at least one measurement exists', () => { const trackerB = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -145,7 +137,7 @@ it('includes latency if at least one measurement exists', () => { it('includes if the result was consistent', () => { const tracker = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -166,7 +158,7 @@ it('includes if the result was consistent', () => { it('includes if the result was inconsistent', () => { const tracker = new MigrationOpTracker( 'flag', - Context.fromLDContext({ kind: 'user', key: 'bob' }), + { user: 'bob' }, LDMigrationStage.Off, LDMigrationStage.Off, { diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 2751d8735..0600145ab 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -18,6 +18,7 @@ import { LDFlagsState, LDFlagsStateOptions, LDMigrationDetail, + LDMigrationOpEvent, LDMigrationStage, LDOptions, LDStreamProcessor, @@ -46,6 +47,7 @@ import EventSender from './events/EventSender'; import isExperiment from './events/isExperiment'; import NullEventProcessor from './events/NullEventProcessor'; import FlagsStateBuilder from './FlagsStateBuilder'; +import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; import Configuration from './options/Configuration'; import AsyncStoreFacade from './store/AsyncStoreFacade'; @@ -279,6 +281,7 @@ export default class LDClientImpl implements LDClient { ): Promise { const convertedContext = Context.fromLDContext(context); const detail = await this.variationDetail(key, context, defaultValue as string); + const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; if (!IsMigrationStage(detail.value)) { const error = new Error(`Unrecognized MigrationState for "${key}"; returning default value.`); this.onError(error); @@ -289,7 +292,7 @@ export default class LDClientImpl implements LDClient { return { value: defaultValue, reason, - tracker: new MigrationOpTracker(key, convertedContext, defaultValue, defaultValue, reason), + tracker: new MigrationOpTracker(key, contextKeys, defaultValue, defaultValue, reason), }; } return { @@ -297,7 +300,7 @@ export default class LDClientImpl implements LDClient { value: detail.value as LDMigrationStage, tracker: new MigrationOpTracker( key, - convertedContext, + contextKeys, defaultValue, defaultValue, detail.reason, @@ -412,6 +415,13 @@ export default class LDClientImpl implements LDClient { ); } + trackMigration(event: LDMigrationOpEvent): void { + const converted = MigrationOpEventToInputEvent(event); + if (converted) { + this.eventProcessor.sendEvent(converted); + } + } + identify(context: LDContext): void { const checkedContext = Context.fromLDContext(context); if (!checkedContext.valid) { diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index adc98265a..9b60fb755 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -1,7 +1,13 @@ import { LDContext } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDMigrationStage } from './api'; -import { LDMigration, LDMigrationReadResult, LDMigrationWriteResult } from './api/LDMigration'; +import { LDClient, LDConsistencyCheck, LDMigrationStage, LDMigrationTracker } from './api'; +import { + LDMigration, + LDMigrationOrigin, + LDMigrationReadResult, + LDMigrationResult, + LDMigrationWriteResult, +} from './api/LDMigration'; import { LDConcurrentExecution, LDExecution, @@ -12,8 +18,8 @@ import { } from './api/options/LDMigrationOptions'; type MultipleReadResult = { - fromOld: LDMethodResult; - fromNew: LDMethodResult; + fromOld: LDMigrationReadResult; + fromNew: LDMigrationReadResult; }; async function safeCall( @@ -31,95 +37,6 @@ async function safeCall( } } -async function readSequentialRandom< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput, ->( - config: LDMigrationOptions< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput - >, - payload?: TMigrationReadInput, -): Promise> { - // This number is not used for a purpose requiring cryptographic security. - const randomIndex = Math.floor(Math.random() * 2); - - // Effectively flip a coin and do it on one order or the other. - if (randomIndex === 0) { - const fromOld = await safeCall(() => config.readOld(payload)); - const fromNew = await safeCall(() => config.readNew(payload)); - return { fromOld, fromNew }; - } - const fromNew = await safeCall(() => config.readNew(payload)); - const fromOld = await safeCall(() => config.readOld(payload)); - return { fromOld, fromNew }; -} - -async function readSequentialFixed< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput, ->( - config: LDMigrationOptions< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput - >, - payload?: TMigrationReadInput, -): Promise> { - const fromOld = await safeCall(() => config.readOld(payload)); - const fromNew = await safeCall(() => config.readNew(payload)); - return { fromOld, fromNew }; -} - -async function readConcurrent< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput, ->( - config: LDMigrationOptions< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput - >, - payload?: TMigrationReadInput, -): Promise> { - const fromOldPromise = safeCall(() => config.readOld(payload)); - const fromNewPromise = safeCall(() => config.readNew(payload)); - - const [fromOld, fromNew] = await Promise.all([fromOldPromise, fromNewPromise]); - - return { fromOld, fromNew }; -} - -async function read( - config: LDMigrationOptions< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput - >, - execution: LDSerialExecution | LDConcurrentExecution, - payload?: TMigrationReadInput, -): Promise> { - if (execution.type === LDExecution.Serial) { - const serial = execution as LDSerialExecution; - if (serial.ordering === LDExecutionOrdering.Fixed) { - return readSequentialFixed(config, payload); - } - return readSequentialRandom(config, payload); - } - return readConcurrent(config, payload); -} - export function LDMigrationSuccess(result: TResult): LDMethodResult { return { success: true, @@ -134,6 +51,11 @@ export function LDMigrationError(error: Error): { success: false; error: Error } }; } +interface MigrationContext { + payload?: TPayload; + tracker: LDMigrationTracker; +} + export default class Migration< TMigrationRead, TMigrationWrite, @@ -144,121 +66,109 @@ export default class Migration< { private readonly execution: LDSerialExecution | LDConcurrentExecution; + private readonly errorTracking: boolean; + + private readonly latencyTracking: boolean; + private readonly readTable: { [index: string]: ( - config: LDMigrationOptions< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput - >, - payload?: TMigrationReadInput, + context: MigrationContext, ) => Promise>; } = { - [LDMigrationStage.Off]: async (config, payload) => ({ - origin: 'old', - ...(await safeCall(() => config.readOld(payload))), - }), - [LDMigrationStage.DualWrite]: async (config, payload) => ({ - origin: 'old', - ...(await safeCall(() => config.readOld(payload))), - }), - [LDMigrationStage.Shadow]: async (config, payload) => { - const { fromOld } = await read(config, this.execution, payload); + [LDMigrationStage.Off]: async (context) => + this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)), + [LDMigrationStage.DualWrite]: async (context) => + this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)), + [LDMigrationStage.Shadow]: async (context) => { + const { fromOld, fromNew } = await this.doRead(context); - // TODO: Consistency check. + this.trackConsistency(context.tracker, fromOld, fromNew); - return { origin: 'old', ...fromOld }; + return fromOld; }, - [LDMigrationStage.Live]: async (config, payload) => { - const { fromNew } = await read(config, this.execution, payload); + [LDMigrationStage.Live]: async (context) => { + const { fromNew, fromOld } = await this.doRead(context); - // TODO: Consistency check. + this.trackConsistency(context.tracker, fromOld, fromNew); - return { origin: 'new', ...fromNew }; + return fromNew; }, - [LDMigrationStage.RampDown]: async (config, payload) => ({ - origin: 'new', - ...(await safeCall(() => config.readNew(payload))), - }), - [LDMigrationStage.Complete]: async (config, payload) => ({ - origin: 'new', - ...(await safeCall(() => config.readNew(payload))), - }), + [LDMigrationStage.RampDown]: async (context) => + this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)), + [LDMigrationStage.Complete]: async (context) => + this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)), }; private readonly writeTable: { [index: string]: ( - config: LDMigrationOptions< - TMigrationRead, - TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput - >, - payload?: TMigrationWriteInput, + context: MigrationContext, ) => Promise>; } = { - [LDMigrationStage.Off]: async (config, payload) => ({ - authoritative: { origin: 'old', ...(await safeCall(() => config.writeOld(payload))) }, + [LDMigrationStage.Off]: async (context) => ({ + authoritative: await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)), }), - [LDMigrationStage.DualWrite]: async (config, payload) => { - const fromOld = await safeCall(() => config.writeOld(payload)); + [LDMigrationStage.DualWrite]: async (context) => { + const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); if (!fromOld.success) { return { - authoritative: { origin: 'old', ...fromOld }, + authoritative: fromOld, }; } - const fromNew = await safeCall(() => config.writeNew(payload)); + const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); return { - authoritative: { origin: 'old', ...fromOld }, - nonAuthoritative: { origin: 'new', ...fromNew }, + authoritative: fromOld, + nonAuthoritative: fromNew, }; }, - [LDMigrationStage.Shadow]: async (config, payload) => { - const fromOld = await safeCall(() => config.writeOld(payload)); + [LDMigrationStage.Shadow]: async (context) => { + const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); if (!fromOld.success) { return { - authoritative: { origin: 'old', ...fromOld }, + authoritative: fromOld, }; } - const fromNew = await safeCall(() => config.writeNew(payload)); + const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); return { - authoritative: { origin: 'old', ...fromOld }, - nonAuthoritative: { origin: 'new', ...fromNew }, + authoritative: fromOld, + nonAuthoritative: fromNew, }; }, - [LDMigrationStage.Live]: async (config, payload) => { - const fromNew = await safeCall(() => config.writeNew(payload)); + [LDMigrationStage.Live]: async (context) => { + const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); if (!fromNew.success) { - return { authoritative: { origin: 'new', ...fromNew } }; + return { + authoritative: fromNew, + }; } - const fromOld = await safeCall(() => config.writeOld(payload)); + const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); return { - nonAuthoritative: { origin: 'old', ...fromOld }, - authoritative: { origin: 'new', ...fromNew }, + authoritative: fromNew, + nonAuthoritative: fromOld, }; }, - [LDMigrationStage.RampDown]: async (config, payload) => { - const fromNew = await safeCall(() => config.writeNew(payload)); + [LDMigrationStage.RampDown]: async (context) => { + const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); if (!fromNew.success) { - return { authoritative: { origin: 'new', ...fromNew } }; + return { + authoritative: fromNew, + }; } - const fromOld = await safeCall(() => config.writeOld(payload)); + const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); return { - nonAuthoritative: { origin: 'old', ...fromOld }, - authoritative: { origin: 'new', ...fromNew }, + authoritative: fromNew, + nonAuthoritative: fromOld, }; }, - [LDMigrationStage.Complete]: async (config, payload) => ({ - authoritative: { origin: 'new', ...(await safeCall(() => config.writeNew(payload))) }, + [LDMigrationStage.Complete]: async (context) => ({ + authoritative: await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)), }), }; @@ -271,11 +181,14 @@ export default class Migration< TMigrationWriteInput >, ) { - if (config.execution) { - this.execution = config.execution; + if (this.config.execution) { + this.execution = this.config.execution; } else { this.execution = new LDConcurrentExecution(); } + + this.latencyTracking = this.config.latencyTracking ?? true; + this.errorTracking = this.config.errorTracking ?? true; } async read( @@ -285,7 +198,13 @@ export default class Migration< payload?: TMigrationReadInput, ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); - return this.readTable[stage.value](this.config, payload); + const res = await this.readTable[stage.value]({ + payload, + tracker: stage.tracker, + }); + stage.tracker.op('read'); + this.sendEvent(stage.tracker); + return res; } async write( @@ -295,7 +214,122 @@ export default class Migration< payload?: TMigrationWriteInput, ): Promise> { const stage = await this.client.variationMigration(key, context, defaultStage); + const res = await this.writeTable[stage.value]({ + payload, + tracker: stage.tracker, + }); + stage.tracker.op('write'); + this.sendEvent(stage.tracker); + return res; + } + + private sendEvent(tracker: LDMigrationTracker) { + const event = tracker.createEvent(); + if (event) { + this.client.trackMigration(event); + } + } + + private trackConsistency( + tracker: LDMigrationTracker, + oldValue: LDMethodResult, + newValue: LDMethodResult, + ) { + if (this.config.check) { + if (oldValue.success && newValue.success) { + const res = this.config.check(oldValue.result, newValue.result); + tracker.consistency(res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent); + } + } + } + + private async readSequentialFixed( + context: MigrationContext, + ): Promise> { + const fromOld = await this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); + const fromNew = await this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + return { fromOld, fromNew }; + } + + private async readConcurrent( + context: MigrationContext, + ): Promise> { + const fromOldPromise = this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); + const fromNewPromise = this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + const [fromOld, fromNew] = await Promise.all([fromOldPromise, fromNewPromise]); + + return { fromOld, fromNew }; + } + + private async readSequentialRandom( + context: MigrationContext, + ): Promise> { + // This number is not used for a purpose requiring cryptographic security. + const randomIndex = Math.floor(Math.random() * 2); + + // Effectively flip a coin and do it on one order or the other. + if (randomIndex === 0) { + const fromOld = await this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); + const fromNew = await this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + return { fromOld, fromNew }; + } + const fromNew = await this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + const fromOld = await this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); + return { fromOld, fromNew }; + } + + private async doRead( + context: MigrationContext, + ): Promise> { + if (this.execution?.type === LDExecution.Serial) { + const serial = this.execution as LDSerialExecution; + if (serial.ordering === LDExecutionOrdering.Fixed) { + return this.readSequentialFixed(context); + } + return this.readSequentialRandom(context); + } + return this.readConcurrent(context); + } + + private async doSingleOp( + context: MigrationContext, + origin: LDMigrationOrigin, + method: (payload?: TInput) => Promise>, + ): Promise> { + const res = await this.trackLatency(context.tracker, origin, () => + safeCall(() => method(context.payload)), + ); + if (!res.success && this.errorTracking) { + context.tracker.error(origin); + } + return { origin, ...res }; + } + + private async trackLatency( + tracker: LDMigrationTracker, + origin: LDMigrationOrigin, + method: () => Promise, + ): Promise { + if (!this.latencyTracking) { + return method(); + } + let start; + let end; + let result: TResult; + // TODO: Need to validate performance existence check with edge SDKs. + if (typeof performance !== undefined) { + start = performance.now(); + result = await method(); + end = performance.now(); + } + start = Date.now(); + result = await method(); + end = Date.now(); - return this.writeTable[stage.value](this.config, payload); + // Performance timer is in ms, but may have a microsecond resolution + // fractional component. + const latency = Math.floor(end - start); + tracker.latency(origin, latency); + return result; } } diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts new file mode 100644 index 000000000..7b2d2a761 --- /dev/null +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -0,0 +1,34 @@ +import { internal, TypeValidators } from '@launchdarkly/js-sdk-common'; + +import { LDMigrationOpEvent } from './api'; + +export default function MigrationOpEventToInputEvent( + inEvent: LDMigrationOpEvent, +): internal.InputMigrationEvent | undefined { + if (inEvent.kind !== 'migration_op') { + return undefined; + } + if (!TypeValidators.Object.is(inEvent.contextKeys)) { + return undefined; + } + if (!TypeValidators.Number.is(inEvent.creationDate)) { + return undefined; + } + if (!Object.keys(inEvent.contextKeys).every((key) => TypeValidators.Kind.is(key))) { + return undefined; + } + + if ( + !Object.values(inEvent.contextKeys).every( + (value) => TypeValidators.String.is(value) && value !== '', + ) + ) { + return undefined; + } + + // TODO: Now much validation do we need on the measurements and output event? + + return { + ...inEvent, + }; +} diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index ff3d55a16..0f2351f16 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -1,4 +1,4 @@ -import { Context, LDEvaluationReason } from '@launchdarkly/js-sdk-common'; +import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { @@ -30,7 +30,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { constructor( private readonly flagKey: string, - private readonly context: Context, + private readonly contextKeys: Record, private readonly defaultStage: LDMigrationStage, private readonly stage: LDMigrationStage, private readonly reason: LDEvaluationReason, @@ -54,7 +54,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { } createEvent(): LDMigrationOpEvent | undefined { - if (this.operation && this.context.valid) { + if (this.operation && Object.keys(this.contextKeys).length) { const measurements: LDMigrationMeasurement[] = []; this.populateConsistency(measurements); @@ -65,7 +65,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { kind: 'migration_op', operation: this.operation, creationDate: Date.now(), - contextKeys: this.context.kindsAndKeys, + contextKeys: this.contextKeys, evaluation: { key: this.flagKey, value: this.stage, diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index 8b523ef09..e459de633 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -1,6 +1,6 @@ import { LDContext, LDEvaluationDetail, LDFlagValue } from '@launchdarkly/js-sdk-common'; -import { LDMigrationDetail } from './data'; +import { LDMigrationDetail, LDMigrationOpEvent } from './data'; import { LDFlagsState } from './data/LDFlagsState'; import { LDFlagsStateOptions } from './data/LDFlagsStateOptions'; import { LDMigrationStage } from './data/LDMigrationStage'; @@ -219,6 +219,13 @@ export interface LDClient { */ track(key: string, context: LDContext, data?: any, metricValue?: number): void; + /** + * Track the details of a migration. + * + * @param event Event containing information about the migration operation. + */ + trackMigration(event: LDMigrationOpEvent): void; + /** * Identifies a context to LaunchDarkly. * diff --git a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts index 3e7477165..086a63985 100644 --- a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts @@ -28,22 +28,6 @@ export enum LDExecution { Concurrent, } -/** - * Settings for latency tracking. - */ -export enum LDLatencyTracking { - Enabled, - Disabled, -} - -/** - * Settings for error tracking. - */ -export enum LDErrorTracking { - Enabled, - Disabled, -} - /** * Migration methods may return an LDMethodResult. * The implementation includes methods for creating results conveniently. @@ -94,16 +78,16 @@ export interface LDMigrationOptions< /** * Configure the latency tracking for the migration. * - * Defaults to {@link LDLatencyTracking.Enabled}. + * Defaults to {@link true}. */ - latencyTracking?: LDLatencyTracking; + latencyTracking?: boolean; /** * Configure the error tracking for the migration. * - * Defaults to {@link LDErrorTracking.Enabled}. + * Defaults to {@link true}. */ - errorTracking?: LDErrorTracking; + errorTracking?: boolean; /** * TKTK From 8655ed3f2023690b9405c31a8763582546fe6704 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 2 Aug 2023 14:59:34 -0700 Subject: [PATCH 18/57] feat: configuration and options validation (#221) Added Configuration class and options validation for js client common. --- .eslintrc.js | 1 + .../common/src/logging/createSafeLogger.ts | 3 +- packages/shared/common/src/validators.ts | 16 +++ .../shared/sdk-client/src/LDClientDomImpl.ts | 11 +- .../shared/sdk-client/src/api/LDOptions.ts | 26 ++-- .../src/configuration/Configuration.test.ts | 117 ++++++++++++++++++ .../src/configuration/Configuration.ts | 88 +++++++++++++ .../sdk-client/src/configuration/index.ts | 3 + .../src/configuration/validators.ts | 51 ++++++++ 9 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 packages/shared/sdk-client/src/configuration/Configuration.test.ts create mode 100644 packages/shared/sdk-client/src/configuration/Configuration.ts create mode 100644 packages/shared/sdk-client/src/configuration/index.ts create mode 100644 packages/shared/sdk-client/src/configuration/validators.ts diff --git a/.eslintrc.js b/.eslintrc.js index 5f6d01889..9689cd7b7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,7 @@ module.exports = { plugins: ['@typescript-eslint', 'prettier'], ignorePatterns: ['**/dist/**', '**/vercel/examples/**'], rules: { + '@typescript-eslint/lines-between-class-members': 'off', 'prettier/prettier': ['error'], 'class-methods-use-this': 'off', 'import/no-extraneous-dependencies': [ diff --git a/packages/shared/common/src/logging/createSafeLogger.ts b/packages/shared/common/src/logging/createSafeLogger.ts index 6169b18d7..346ddb19a 100644 --- a/packages/shared/common/src/logging/createSafeLogger.ts +++ b/packages/shared/common/src/logging/createSafeLogger.ts @@ -1,7 +1,6 @@ -import { format } from 'util'; - import { LDLogger } from '../api'; import BasicLogger from './BasicLogger'; +import format from './format'; import SafeLogger from './SafeLogger'; const createSafeLogger = (logger?: LDLogger) => { diff --git a/packages/shared/common/src/validators.ts b/packages/shared/common/src/validators.ts index d6bcc2fef..6ff21559c 100644 --- a/packages/shared/common/src/validators.ts +++ b/packages/shared/common/src/validators.ts @@ -139,6 +139,16 @@ export class Function implements TypeValidator { } } +export class NullableBoolean implements TypeValidator { + is(u: unknown): boolean { + return typeof u === 'boolean' || typeof u === 'undefined' || u === null; + } + + getType(): string { + return 'boolean | undefined | null'; + } +} + // Our reference SDK, Go, parses date/time strings with the time.RFC3339Nano format. // This regex should match strings that are valid in that format, and no others. // Acceptable: @@ -179,6 +189,10 @@ export class TypeValidators { static readonly Function = new Function(); + static createTypeArray(typeName: string, example: T) { + return new TypeArray(typeName, example); + } + static numberWithMin(min: number): NumberWithMinimum { return new NumberWithMinimum(min); } @@ -188,4 +202,6 @@ export class TypeValidators { } static readonly Date = new DateValidator(); + + static readonly NullableBoolean = new NullableBoolean(); } diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index c4b7095c6..3baf84319 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -2,20 +2,19 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { - createSafeLogger, LDContext, LDEvaluationDetail, LDFlagSet, LDFlagValue, - LDLogger, Platform, } from '@launchdarkly/js-sdk-common'; import { LDClientDom } from './api/LDClientDom'; -import { LDOptions } from './api/LDOptions'; +import LDOptions from './api/LDOptions'; +import Configuration from './configuration'; export default class LDClientDomImpl implements LDClientDom { - logger?: LDLogger; + config: Configuration; /** * Immediately return an LDClient instance. No async or remote calls. @@ -26,9 +25,7 @@ export default class LDClientDomImpl implements LDClientDom { * @param platform */ constructor(clientSideId: string, context: LDContext, options: LDOptions, platform: Platform) { - const { logger } = options; - - this.logger = createSafeLogger(logger); + this.config = new Configuration(); } allFlags(): LDFlagSet { diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index efb925a76..fe3d8ae05 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -2,7 +2,7 @@ import type { LDFlagSet, LDLogger } from '@launchdarkly/js-sdk-common'; import type { LDInspection } from './LDInspection'; -export interface LDOptions { +export default interface LDOptions { /** * An object that will perform logging for the client. * @@ -15,7 +15,7 @@ export interface LDOptions { * * 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} + * source of flag values. In the latter case, the flag values will be available via {@link LDClientDom.variation} * immediately after calling `initialize()` (normally they would not be available until the * client signals that it is ready). * @@ -24,36 +24,36 @@ export interface LDOptions { bootstrap?: 'localStorage' | LDFlagSet; /** - * The base URL for the LaunchDarkly server. + * The base uri for the LaunchDarkly server. * * Most users should use the default value. */ - baseUrl?: string; + baseUri?: string; /** - * The base URL for the LaunchDarkly events server. + * The base uri for the LaunchDarkly events server. * * Most users should use the default value. */ - eventsUrl?: string; + eventsUri?: string; /** - * The base URL for the LaunchDarkly streaming server. + * The base uri for the LaunchDarkly streaming server. * * Most users should use the default value. */ - streamUrl?: string; + streamUri?: string; /** * Whether or not to open a streaming connection to LaunchDarkly for live flag updates. * * If this is true, the client will always attempt to maintain a streaming connection; if false, * it never will. If you leave the value undefined (the default), the client will open a streaming - * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClientDom.on}). * * This is equivalent to calling `client.setStreaming()` with the same value. */ - streaming?: boolean; + stream?: boolean; /** * Whether or not to use the REPORT verb to fetch flag settings. @@ -62,7 +62,7 @@ export interface LDOptions { * including a JSON entity body with the context object. * * Otherwise (by default) a GET request will be issued with the context passed as - * a base64 URL-encoded path parameter. + * a base64 uri-encoded path parameter. * * Do not use unless advised by LaunchDarkly. */ @@ -95,7 +95,7 @@ export interface LDOptions { * calculated. * * The additional information will then be available through the client's - * {@link LDClient.variationDetail} method. Since this increases the size of network requests, + * {@link LDClientDom.variationDetail} method. Since this increases the size of network requests, * such information is not sent unless you set this option to true. */ evaluationReasons?: boolean; @@ -152,7 +152,7 @@ export interface LDOptions { * * The default value is 100. */ - eventCapacity?: number; + capacity?: number; /** * The interval in between flushes of the analytics events queue, in milliseconds. diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts new file mode 100644 index 000000000..1d3c8ac43 --- /dev/null +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -0,0 +1,117 @@ +/* eslint-disable no-console */ +import Configuration from './Configuration'; + +describe('Configuration', () => { + beforeEach(() => { + jest.resetAllMocks(); + console.error = jest.fn(); + }); + + test('defaults', () => { + const config = new Configuration(); + + expect(config).toMatchObject({ + allAttributesPrivate: false, + baseUri: 'https://sdk.launchdarkly.com', + capacity: 100, + diagnosticOptOut: false, + diagnosticRecordingInterval: 900000, + evaluationReasons: false, + eventsUri: 'https://events.launchdarkly.com', + flushInterval: 2000, + inspectors: [], + logger: { + destination: console.error, + logLevel: 1, + name: 'LaunchDarkly', + }, + privateAttributes: [], + sendEvents: true, + sendEventsOnlyForVariation: false, + sendLDHeaders: true, + streamReconnectDelay: 1000, + streamUri: 'https://clientstream.launchdarkly.com', + useReport: false, + }); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('specified options should be set', () => { + const config = new Configuration({ wrapperName: 'test', stream: true }); + expect(config).toMatchObject({ wrapperName: 'test', stream: true }); + }); + + test('unknown option', () => { + // @ts-ignore + const config = new Configuration({ baseballUri: 1 }); + + expect(config.baseballUri).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unknown config option')); + }); + + test('wrong type for boolean should be converted', () => { + // @ts-ignore + const config = new Configuration({ sendEvents: 0 }); + + expect(config.stream).toBeFalsy(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('should be a boolean, got number, converting'), + ); + }); + + test('wrong type for number should use default', () => { + // @ts-ignore + const config = new Configuration({ capacity: true }); + + expect(config.capacity).toEqual(100); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('should be of type number with minimum value of 1, got boolean'), + ); + }); + + test('enforce minimum', () => { + const config = new Configuration({ flushInterval: 1 }); + + expect(config.flushInterval).toEqual(2000); + expect(console.error).toHaveBeenNthCalledWith( + 1, + expect.stringContaining( + '"flushInterval" had invalid value of 1, using minimum of 2000 instead', + ), + ); + }); + + test('undefined stream should not log warning', () => { + const config = new Configuration({ stream: undefined }); + + expect(config.stream).toBeUndefined(); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('null stream should default to undefined', () => { + // @ts-ignore + const config = new Configuration({ stream: null }); + + expect(config.stream).toBeUndefined(); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('wrong stream type should be converted to boolean', () => { + // @ts-ignore + const config = new Configuration({ stream: 1 }); + + expect(config.stream).toBeTruthy(); + expect(console.error).toHaveBeenCalled(); + }); + + test('invalid bootstrap should use default', () => { + // @ts-ignore + const config = new Configuration({ bootstrap: 'localStora' }); + + expect(config.bootstrap).toBeUndefined(); + expect(console.error).toHaveBeenNthCalledWith( + 1, + expect.stringContaining(`should be of type 'localStorage' | LDFlagSet, got string`), + ); + }); +}); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts new file mode 100644 index 000000000..d3a10c8e7 --- /dev/null +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -0,0 +1,88 @@ +import { + createSafeLogger, + LDFlagSet, + NumberWithMinimum, + OptionMessages, + TypeValidators, +} from '@launchdarkly/js-sdk-common'; + +import { LDInspection } from '../api/LDInspection'; +import type LDOptions from '../api/LDOptions'; +import validators from './validators'; + +export default class Configuration { + public readonly logger = createSafeLogger(); + + public readonly baseUri = 'https://sdk.launchdarkly.com'; + public readonly eventsUri = 'https://events.launchdarkly.com'; + public readonly streamUri = 'https://clientstream.launchdarkly.com'; + + public readonly capacity = 100; + public readonly diagnosticRecordingInterval = 900000; + public readonly flushInterval = 2000; + public readonly streamReconnectDelay = 1000; + + public readonly allAttributesPrivate = false; + public readonly diagnosticOptOut = false; + public readonly evaluationReasons = false; + public readonly sendEvents = true; + public readonly sendEventsOnlyForVariation = false; + public readonly sendLDHeaders = true; + public readonly useReport = false; + + public readonly inspectors: LDInspection[] = []; + public readonly privateAttributes: string[] = []; + + public readonly application?: { id?: string; version?: string }; + public readonly bootstrap?: 'localStorage' | LDFlagSet; + public readonly requestHeaderTransform?: (headers: Map) => Map; + public readonly stream?: boolean; + public readonly wrapperName?: string; + public readonly wrapperVersion?: string; + + // Allow indexing Configuration by a string + [index: string]: any; + + constructor(pristineOptions: LDOptions = {}) { + const errors = this.validateTypesAndNames(pristineOptions); + errors.forEach((e: string) => this.logger.warn(e)); + } + + validateTypesAndNames(pristineOptions: LDOptions): string[] { + const errors: string[] = []; + + Object.entries(pristineOptions).forEach(([k, v]) => { + const validator = validators[k as keyof LDOptions]; + + if (validator) { + if (!validator.is(v)) { + const validatorType = validator.getType(); + + if (validatorType === 'boolean') { + errors.push(OptionMessages.wrongOptionTypeBoolean(k, typeof v)); + this[k] = !!v; + } else if (validatorType === 'boolean | undefined | null') { + errors.push(OptionMessages.wrongOptionTypeBoolean(k, typeof v)); + + if (typeof v !== 'boolean' && typeof v !== 'undefined' && v !== null) { + this[k] = !!v; + } + } else if (validator instanceof NumberWithMinimum && TypeValidators.Number.is(v)) { + const { min } = validator as NumberWithMinimum; + errors.push(OptionMessages.optionBelowMinimum(k, v, min)); + this[k] = min; + } else { + errors.push(OptionMessages.wrongOptionType(k, validator.getType(), typeof v)); + } + } else { + // if an option is explicitly null, coerce to undefined + this[k] = v ?? undefined; + } + } else { + errors.push(OptionMessages.unknownOption(k)); + } + }); + + return errors; + } +} diff --git a/packages/shared/sdk-client/src/configuration/index.ts b/packages/shared/sdk-client/src/configuration/index.ts new file mode 100644 index 000000000..b22c3ea49 --- /dev/null +++ b/packages/shared/sdk-client/src/configuration/index.ts @@ -0,0 +1,3 @@ +import Configuration from './Configuration'; + +export default Configuration; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts new file mode 100644 index 000000000..eaecc93a8 --- /dev/null +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -0,0 +1,51 @@ +import { noop, TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; + +import { LDInspection } from '../api/LDInspection'; +import LDOptions from '../api/LDOptions'; + +class BootStrapValidator implements TypeValidator { + is(u: unknown): boolean { + return u === 'localStorage' || typeof u === 'object' || typeof u === 'undefined' || u === null; + } + + getType(): string { + return `'localStorage' | LDFlagSet`; + } +} + +const validators: Record = { + logger: TypeValidators.Object, + + baseUri: TypeValidators.String, + streamUri: TypeValidators.String, + eventsUri: TypeValidators.String, + + capacity: TypeValidators.numberWithMin(1), + diagnosticRecordingInterval: TypeValidators.numberWithMin(2000), + flushInterval: TypeValidators.numberWithMin(2000), + streamReconnectDelay: TypeValidators.numberWithMin(0), + + allAttributesPrivate: TypeValidators.Boolean, + diagnosticOptOut: TypeValidators.Boolean, + evaluationReasons: TypeValidators.Boolean, + sendEvents: TypeValidators.Boolean, + sendEventsOnlyForVariation: TypeValidators.Boolean, + sendLDHeaders: TypeValidators.Boolean, + useReport: TypeValidators.Boolean, + + inspectors: TypeValidators.createTypeArray('LDInspection[]', { + type: 'flag-used', + method: noop, + name: '', + }), + privateAttributes: TypeValidators.StringArray, + + application: TypeValidators.Object, + bootstrap: new BootStrapValidator(), + requestHeaderTransform: TypeValidators.Function, + stream: TypeValidators.NullableBoolean, + wrapperName: TypeValidators.String, + wrapperVersion: TypeValidators.String, +}; + +export default validators; From a2154cc31b0880e2859407347e8c780e3014af4c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 3 Aug 2023 09:19:54 -0700 Subject: [PATCH 19/57] feat: Adjustments to spec and enhanced event validation. (#222) --- .../__tests__/MigratioOpEvent.test.ts | 111 ++++++++- .../__tests__/MigrationOpTracker.test.ts | 4 +- .../src/MigrationOpEventConversion.ts | 210 +++++++++++++++++- .../sdk-server/src/MigrationOpTracker.ts | 2 +- .../src/api/data/LDMigrationOpEvent.ts | 55 +++-- 5 files changed, 354 insertions(+), 28 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts index f55c2c93c..0886b8051 100644 --- a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts @@ -5,12 +5,14 @@ import { LDClientImpl, LDConcurrentExecution, LDExecutionOrdering, + LDMigrationOpEvent, LDMigrationStage, LDSerialExecution, } from '../src'; import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import MigrationOpEventConversion from '../src/MigrationOpEventConversion'; import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; @@ -144,7 +146,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); @@ -161,7 +163,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.new).toBeUndefined(); @@ -178,7 +180,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.old).toBeUndefined(); @@ -193,7 +195,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.new).toBeUndefined(); @@ -209,7 +211,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.old).toBeUndefined(); @@ -226,7 +228,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); @@ -241,7 +243,7 @@ describe('given an LDClient with test data', () => { expect(events.length).toBe(2); // Only check the measurements component of the event. const migrationEvent = events[1] as InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency'); + expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); @@ -379,3 +381,98 @@ describe('given an LDClient with test data', () => { }); }); }); + +// Out migrator doesn't create custom measurements. So we need an additional test to ensure +// that custom measurements make it through the conversion process. + +it('can accept custom measurements', () => { + const inputEvent: LDMigrationOpEvent = { + kind: 'migration_op', + operation: 'read', + creationDate: 0, + contextKeys: { user: 'bob' }, + evaluation: { + key: 'potato', + value: LDMigrationStage.Off, + default: LDMigrationStage.Live, + reason: { + kind: 'FALLTHROUGH', + }, + }, + measurements: [ + { + key: 'custom1', + kind: 'custom', + values: { + old: 1, + new: 2, + }, + }, + { + key: 'custom2', + kind: 'custom', + values: { + new: 2, + }, + }, + { + key: 'custom3', + kind: 'custom', + values: { + old: 2, + }, + }, + { + key: 'custom4', + kind: 'custom', + values: {}, + }, + ], + }; + const validatedEvent = MigrationOpEventConversion(inputEvent); + expect(validatedEvent).toEqual(inputEvent); +}); + +it('removes bad custom measurements', () => { + const inputEvent: LDMigrationOpEvent = { + kind: 'migration_op', + operation: 'read', + creationDate: 0, + contextKeys: { user: 'bob' }, + evaluation: { + key: 'potato', + value: LDMigrationStage.Off, + default: LDMigrationStage.Live, + reason: { + kind: 'FALLTHROUGH', + }, + }, + measurements: [ + { + key: 'custom1', + kind: 'custom', + values: { + // @ts-ignore + old: 'ham', + new: 2, + }, + }, + ], + }; + const validatedEvent = MigrationOpEventConversion(inputEvent); + expect(validatedEvent).toEqual({ + kind: 'migration_op', + operation: 'read', + creationDate: 0, + contextKeys: { user: 'bob' }, + evaluation: { + key: 'potato', + value: LDMigrationStage.Off, + default: LDMigrationStage.Live, + reason: { + kind: 'FALLTHROUGH', + }, + }, + measurements: [], + }); +}); diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index fb6571c1f..56369f1fc 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -107,7 +107,7 @@ it('includes latency if at least one measurement exists', () => { const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ - key: 'latency', + key: 'latency_ms', values: { old: 100, }, @@ -127,7 +127,7 @@ it('includes latency if at least one measurement exists', () => { const eventB = trackerB.createEvent(); expect(eventB?.measurements).toContainEqual({ - key: 'latency', + key: 'latency_ms', values: { new: 150, }, diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index 7b2d2a761..6daa08388 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -1,19 +1,214 @@ import { internal, TypeValidators } from '@launchdarkly/js-sdk-common'; -import { LDMigrationOpEvent } from './api'; +import { + LDMigrationConsistencyMeasurement, + LDMigrationCustomMeasurement, + LDMigrationErrorMeasurement, + LDMigrationEvaluation, + LDMigrationLatencyMeasurement, + LDMigrationMeasurement, + LDMigrationOp, + LDMigrationOpEvent, +} from './api'; +function isOperation(value: LDMigrationOp) { + if (!TypeValidators.String.is(value)) { + return false; + } + + return value === 'read' || value === 'write'; +} + +function isCustomMeasurement(value: LDMigrationMeasurement): value is LDMigrationCustomMeasurement { + return (value as any).kind === 'custom'; +} + +function isLatencyMeasurement( + value: LDMigrationMeasurement, +): value is LDMigrationLatencyMeasurement { + return (value as any).kind === undefined && value.key === 'latency_ms'; +} + +function isErrorMeasurement(value: LDMigrationMeasurement): value is LDMigrationErrorMeasurement { + return (value as any).kind === undefined && value.key === 'error'; +} + +function isConsistencyMeasurement( + value: LDMigrationMeasurement, +): value is LDMigrationConsistencyMeasurement { + return (value as any).kind === undefined && value.key === 'consistent'; +} + +function areValidValues(values: { old?: number; new?: number }) { + const oldValue = values.old; + const newValue = values.new; + if (oldValue !== undefined && !TypeValidators.Number.is(oldValue)) { + return false; + } + if (newValue !== undefined && !TypeValidators.Number.is(newValue)) { + return false; + } + return true; +} + +function validateMeasurement( + measurement: LDMigrationMeasurement, +): LDMigrationMeasurement | undefined { + if (!TypeValidators.String.is(measurement.key) || measurement.key === '') { + return undefined; + } + + if (isCustomMeasurement(measurement)) { + if (!TypeValidators.Object.is(measurement.values)) { + return undefined; + } + if (!areValidValues(measurement.values)) { + return undefined; + } + return { + kind: measurement.kind, + key: measurement.key, + values: { + old: measurement.values.old, + new: measurement.values.new, + }, + }; + } + + if (isLatencyMeasurement(measurement)) { + if (!TypeValidators.Object.is(measurement.values)) { + return undefined; + } + if (!areValidValues(measurement.values)) { + return undefined; + } + return { + key: measurement.key, + values: { + old: measurement.values.old, + new: measurement.values.new, + }, + }; + } + + if (isErrorMeasurement(measurement)) { + if (!TypeValidators.Object.is(measurement.values)) { + return undefined; + } + if (!areValidValues(measurement.values)) { + return undefined; + } + return { + key: measurement.key, + values: { + old: measurement.values.old, + new: measurement.values.new, + }, + }; + } + + if (isConsistencyMeasurement(measurement)) { + if ( + !TypeValidators.Number.is(measurement.value) || + !TypeValidators.Number.is(measurement.samplingOdds) + ) { + return undefined; + } + return { + key: measurement.key, + value: measurement.value, + samplingOdds: measurement.samplingOdds, + }; + } + + return undefined; +} + +function validateMeasurements(measurements: LDMigrationMeasurement[]): LDMigrationMeasurement[] { + return measurements + .map(validateMeasurement) + .filter((value) => value !== undefined) as LDMigrationMeasurement[]; +} + +function validateEvaluation(evaluation: LDMigrationEvaluation): LDMigrationEvaluation | undefined { + if (!TypeValidators.String.is(evaluation.key) || evaluation.key === '') { + return undefined; + } + if (!TypeValidators.Object.is(evaluation.reason)) { + return undefined; + } + if (!TypeValidators.String.is(evaluation.reason.kind) || evaluation.reason.kind === '') { + return undefined; + } + const validated: LDMigrationEvaluation = { + key: evaluation.key, + value: evaluation.value, + default: evaluation.default, + reason: { + kind: evaluation.reason.kind, + }, + }; + + const inReason = evaluation.reason; + const outReason = validated.reason; + if (TypeValidators.String.is(inReason.errorKind)) { + outReason.errorKind = inReason.errorKind; + } + + if (TypeValidators.String.is(inReason.ruleId)) { + outReason.ruleId = inReason.ruleId; + } + + if (TypeValidators.String.is(inReason.prerequisiteKey)) { + outReason.ruleId = inReason.ruleId; + } + + if (TypeValidators.Boolean.is(inReason.inExperiment)) { + outReason.inExperiment = inReason.inExperiment; + } + + if (TypeValidators.Number.is(inReason.ruleIndex)) { + outReason.ruleIndex = inReason.ruleIndex; + } + + if (TypeValidators.String.is(inReason.bigSegmentsStatus)) { + outReason.bigSegmentsStatus = inReason.bigSegmentsStatus; + } + + if (evaluation.variation && TypeValidators.Number.is(evaluation.variation)) { + validated.variation = evaluation.variation; + } + + return validated; +} + +/** + * Migration events can be generated directly in user code and may not follow the shape + * expected by the TypeScript definitions. So we do some validation on these events, as well + * as copying the data out of them, to reduce the amount of invalid data we may send. + * + * @param inEvent The event to process. + * @returns An event, or undefined if it could not be converted. + */ export default function MigrationOpEventToInputEvent( inEvent: LDMigrationOpEvent, ): internal.InputMigrationEvent | undefined { if (inEvent.kind !== 'migration_op') { return undefined; } + + if (!isOperation(inEvent.operation)) { + return undefined; + } + if (!TypeValidators.Object.is(inEvent.contextKeys)) { return undefined; } + if (!TypeValidators.Number.is(inEvent.creationDate)) { return undefined; } + if (!Object.keys(inEvent.contextKeys).every((key) => TypeValidators.Kind.is(key))) { return undefined; } @@ -26,9 +221,18 @@ export default function MigrationOpEventToInputEvent( return undefined; } - // TODO: Now much validation do we need on the measurements and output event? + const evaluation = validateEvaluation(inEvent.evaluation); + + if (!evaluation) { + return undefined; + } return { - ...inEvent, + kind: inEvent.kind, + operation: inEvent.operation, + creationDate: inEvent.creationDate, + contextKeys: { ...inEvent.contextKeys }, + measurements: validateMeasurements(inEvent.measurements), + evaluation, }; } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index 0f2351f16..f269b62b5 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -117,7 +117,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { values.old = this.latencyMeasurement.old; } measurements.push({ - key: 'latency', + key: 'latency_ms', values, }); } diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index 3b88a0570..d45980b79 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -1,35 +1,60 @@ import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; +import { LDMigrationStage } from './LDMigrationStage'; + export type LDMigrationOp = 'read' | 'write'; /** * Component of an LDMigrationOpEvent which tracks information about the * evaluation of the migration flag. */ -interface LDMigrationEvaluation { +export interface LDMigrationEvaluation { key: string; - value: any; - default: any; + value: LDMigrationStage; + default: LDMigrationStage; variation?: number; reason: LDEvaluationReason; } +export interface LDMigrationCustomMeasurement { + kind: 'custom'; + key: string; + values: { + old?: number; + new?: number; + }; +} + +export interface LDMigrationConsistencyMeasurement { + key: 'consistent'; + value: number; + samplingOdds: number; +} + +export interface LDMigrationLatencyMeasurement { + key: 'latency_ms'; + values: { + old?: number; + new?: number; + }; +} + +export interface LDMigrationErrorMeasurement { + key: 'error'; + values: { + old?: number; + new?: number; + }; +} + /** * Types of measurements supported by an LDMigrationOpEvent. */ export type LDMigrationMeasurement = - | { - key: 'latency' | 'error'; - values: { - old?: number; - new?: number; - }; - } - | { - key: 'consistent'; - value: number; - samplingOdds: number; - }; + | LDMigrationLatencyMeasurement + | LDMigrationErrorMeasurement + | LDMigrationConsistencyMeasurement + | LDMigrationCustomMeasurement; /** * Event used to track information about a migration operation. From ababe7488722435366641414cc963e9e8ace2356 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 3 Aug 2023 09:29:54 -0700 Subject: [PATCH 20/57] feat: Add configuration overrides and metrics data kinds. (#220) --- .../data_sources/PollingProcessor.test.ts | 11 ++- .../data_sources/StreamingProcessor.test.ts | 74 +++++++-------- .../__tests__/store/serialization.test.ts | 92 +++++++++++++++++-- .../src/data_sources/FileDataSource.ts | 6 ++ .../src/data_sources/PollingProcessor.ts | 3 + .../src/data_sources/StreamingProcessor.ts | 3 + .../shared/sdk-server/src/store/Metric.ts | 5 + .../shared/sdk-server/src/store/Override.ts | 5 + .../src/store/VersionedDataKinds.ts | 13 ++- .../sdk-server/src/store/serialization.ts | 30 ++++-- .../__tests__/RedisCore.test.ts | 52 +++++++++++ 11 files changed, 233 insertions(+), 61 deletions(-) create mode 100644 packages/shared/sdk-server/src/store/Metric.ts create mode 100644 packages/shared/sdk-server/src/store/Override.ts diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index b1361307f..7822c5e31 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -16,7 +16,12 @@ describe('given an event processor', () => { requestAllData: jest.fn(), }; const longInterval = 100000; - const allData = { flags: { flag: { version: 1 } }, segments: { segment: { version: 1 } } }; + const allData = { + flags: { flag: { version: 1 } }, + segments: { segment: { version: 1 } }, + configurationOverrides: { override: { version: 1 } }, + metrics: { metric: { version: 1 } }, + }; const jsonData = JSON.stringify(allData); let store: LDFeatureStore; @@ -69,6 +74,10 @@ describe('given an event processor', () => { expect(flags).toEqual(allData.flags); const segments = await storeFacade.all(VersionedDataKinds.Segments); expect(segments).toEqual(allData.segments); + const configurationOverrides = await storeFacade.all(VersionedDataKinds.ConfigurationOverrides); + expect(configurationOverrides).toEqual(allData.configurationOverrides); + const metrics = await storeFacade.all(VersionedDataKinds.Metrics); + expect(metrics).toEqual(allData.metrics); }); }); diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts index ecceb74ad..fababa968 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts @@ -118,10 +118,16 @@ describe('given a stream processor with mock event source', () => { segments: { segkey: { key: 'segkey', version: 2 }, }, + configurationOverrides: { + configKey: { key: 'configKey', version: 3 }, + }, + metrics: { + metricKey: { key: 'metricKey', version: 4 }, + }, }, }; - it('causes flags and segments to be stored', async () => { + it('causes flags/segments/configOverrides/metrics to be stored', async () => { streamProcessor.start(); es.handlers.put({ data: JSON.stringify(putData) }); const initialized = await asyncStore.initialized(); @@ -131,6 +137,10 @@ describe('given a stream processor with mock event source', () => { expect(f?.version).toEqual(1); const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); expect(s?.version).toEqual(2); + const override = await asyncStore.get(VersionedDataKinds.ConfigurationOverrides, 'configKey'); + expect(override?.version).toEqual(3); + const metric = await asyncStore.get(VersionedDataKinds.Metrics, 'metricKey'); + expect(metric?.version).toEqual(4); }); it('calls initialization callback', async () => { @@ -164,32 +174,24 @@ describe('given a stream processor with mock event source', () => { }); describe('when patching a message', () => { - it('updates a patched flag', async () => { + it.each([ + VersionedDataKinds.Features, + VersionedDataKinds.Segments, + VersionedDataKinds.ConfigurationOverrides, + VersionedDataKinds.Metrics, + ])('patches a item of each kind: %j', async (kind) => { streamProcessor.start(); const patchData = { - path: '/flags/flagkey', - data: { key: 'flagkey', version: 1 }, + path: `${kind.streamApiPath}itemKey`, + data: { key: 'itemKey', version: 1 }, }; es.handlers.patch({ data: JSON.stringify(patchData) }); - const f = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); + const f = await asyncStore.get(kind, 'itemKey'); expect(f!.version).toEqual(1); }); - it('updates a patched segment', async () => { - streamProcessor.start(); - const patchData = { - path: '/segments/segkey', - data: { key: 'segkey', version: 1 }, - }; - - es.handlers.patch({ data: JSON.stringify(patchData) }); - - const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s!.version).toEqual(1); - }); - it('passes error to callback if data is invalid', async () => { streamProcessor.start(); @@ -201,34 +203,24 @@ describe('given a stream processor with mock event source', () => { }); describe('when deleting a message', () => { - it('deletes a flag', async () => { + it.each([ + VersionedDataKinds.Features, + VersionedDataKinds.Segments, + VersionedDataKinds.ConfigurationOverrides, + VersionedDataKinds.Metrics, + ])('deletes each data kind: %j', async (kind) => { streamProcessor.start(); - const flag = { key: 'flagkey', version: 1 }; - await asyncStore.upsert(VersionedDataKinds.Features, flag); - const f = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); - expect(f!.version).toEqual(1); - - const deleteData = { path: `/flags/${flag.key}`, version: 2 }; - - es.handlers.delete({ data: JSON.stringify(deleteData) }); - - const f2 = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); - expect(f2).toBe(null); - }); - - it('deletes a segment', async () => { - streamProcessor.start(); - const segment = { key: 'segkey', version: 1 }; - await asyncStore.upsert(VersionedDataKinds.Segments, segment); - const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s!.version).toEqual(1); + const item = { key: 'itemKey', version: 1 }; + await asyncStore.upsert(kind, item); + const stored = await asyncStore.get(kind, 'itemKey'); + expect(stored!.version).toEqual(1); - const deleteData = { path: `/segments/${segment.key}`, version: 2 }; + const deleteData = { path: `${kind.streamApiPath}${item.key}`, version: 2 }; es.handlers.delete({ data: JSON.stringify(deleteData) }); - const s2 = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s2).toBe(null); + const stored2 = await asyncStore.get(VersionedDataKinds.Features, 'itemKey'); + expect(stored2).toBe(null); }); it('passes error to callback if data is invalid', async () => { diff --git a/packages/shared/sdk-server/__tests__/store/serialization.test.ts b/packages/shared/sdk-server/__tests__/store/serialization.test.ts index 7cc4e95db..ad101fbd4 100644 --- a/packages/shared/sdk-server/__tests__/store/serialization.test.ts +++ b/packages/shared/sdk-server/__tests__/store/serialization.test.ts @@ -151,11 +151,13 @@ const segmentWithBucketBy = { deleted: false, }; -function makeAllData(flag?: any, segment?: any): any { +function makeAllData(flag?: any, segment?: any, override?: any, metric?: any): any { const allData: any = { data: { flags: {}, segments: {}, + configurationOverrides: {}, + metrics: {}, }, }; @@ -165,22 +167,38 @@ function makeAllData(flag?: any, segment?: any): any { if (segment) { allData.data.segments.segmentName = segment; } + if (override) { + allData.data.configurationOverrides.overrideName = override; + } + if (metric) { + allData.data.metrics.metricName = metric; + } return allData; } -function makeSerializedAllData(flag?: any, segment?: any): string { - return JSON.stringify(makeAllData(flag, segment)); +function makeSerializedAllData(flag?: any, segment?: any, override?: any, metric?: any): string { + return JSON.stringify(makeAllData(flag, segment, override, metric)); } -function makePatchData(flag?: any, segment?: any): any { +function makePatchData(flag?: any, segment?: any, override?: any, metric?: any): any { + let path = '/flags/flagName'; + if (segment) { + path = '/segments/segmentName'; + } + if (override) { + path = '/configurationOverrides/overrideName'; + } + if (metric) { + path = '/metrics/metricName'; + } return { - path: flag ? '/flags/flagName' : '/segments/segmentName', - data: flag ?? segment, + path, + data: flag ?? segment ?? override ?? metric, }; } -function makeSerializedPatchData(flag?: any, segment?: any): string { - return JSON.stringify(makePatchData(flag, segment)); +function makeSerializedPatchData(flag?: any, segment?: any, override?: any, metric?: any): string { + return JSON.stringify(makePatchData(flag, segment, override, metric)); } describe('when deserializing all data', () => { @@ -234,6 +252,28 @@ describe('when deserializing all data', () => { const ref = parsed?.data.flags.flagName.rules?.[0].rollout?.bucketByAttributeReference; expect(ref?.isValid).toBeTruthy(); }); + + it('handles a config override', () => { + const override = { + key: 'overrideName', + value: 'potato', + version: 1, + }; + const jsonString = makeSerializedAllData(undefined, undefined, override, undefined); + const parsed = deserializeAll(jsonString); + expect(parsed).toMatchObject({ data: { configurationOverrides: { overrideName: override } } }); + }); + + it('handles a metric', () => { + const metric = { + key: 'metricName', + samplingRatio: 42, + version: 1, + }; + const jsonString = makeSerializedAllData(undefined, undefined, undefined, metric); + const parsed = deserializeAll(jsonString); + expect(parsed).toMatchObject({ data: { metrics: { metricName: metric } } }); + }); }); describe('when deserializing patch data', () => { @@ -285,6 +325,42 @@ describe('when deserializing patch data', () => { const ref = (parsed?.data as Flag).rules?.[0].rollout?.bucketByAttributeReference; expect(ref?.isValid).toBeTruthy(); }); + + it('handles a config override', () => { + const override = { + key: 'overrideName', + value: 'potato', + version: 1, + }; + const jsonString = makeSerializedPatchData(undefined, undefined, override, undefined); + const parsed = deserializePatch(jsonString); + expect(parsed).toEqual({ + data: override, + path: '/configurationOverrides/overrideName', + kind: { + namespace: 'configurationOverrides', + streamApiPath: '/configurationOverrides/', + }, + }); + }); + + it('handles a metric', () => { + const metric = { + key: 'metricName', + samplingRatio: 42, + version: 1, + }; + const jsonString = makeSerializedPatchData(undefined, undefined, undefined, metric); + const parsed = deserializePatch(jsonString); + expect(parsed).toEqual({ + data: metric, + path: '/metrics/metricName', + kind: { + namespace: 'metrics', + streamApiPath: '/metrics/', + }, + }); + }); }); it('removes null elements', () => { diff --git a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts index c40209e0b..fccf40f53 100644 --- a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts +++ b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts @@ -137,5 +137,11 @@ export default class FileDataSource implements LDStreamProcessor { processSegment(parsed.segments[key]); this.addItem(VersionedDataKinds.Segments, parsed.segments[key]); }); + Object.keys(parsed.configurationOverrides || {}).forEach((key) => { + this.addItem(VersionedDataKinds.ConfigurationOverrides, parsed.configurationOverrides[key]); + }); + Object.keys(parsed.metrics || {}).forEach((key) => { + this.addItem(VersionedDataKinds.Metrics, parsed.metrics[key]); + }); } } diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index a7af081a4..307691efe 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -69,6 +69,9 @@ export default class PollingProcessor implements LDStreamProcessor { const initData = { [VersionedDataKinds.Features.namespace]: parsed.flags, [VersionedDataKinds.Segments.namespace]: parsed.segments, + [VersionedDataKinds.ConfigurationOverrides.namespace]: + parsed.configurationOverrides || {}, + [VersionedDataKinds.Metrics.namespace]: parsed.metrics || {}, }; this.featureStore.init(initData, () => { fn?.(); diff --git a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts index 411657aff..698121749 100644 --- a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts @@ -131,6 +131,9 @@ export default class StreamingProcessor implements LDStreamProcessor { const initData = { [VersionedDataKinds.Features.namespace]: parsed.data.flags, [VersionedDataKinds.Segments.namespace]: parsed.data.segments, + [VersionedDataKinds.ConfigurationOverrides.namespace]: + parsed.data.configurationOverrides || {}, + [VersionedDataKinds.Metrics.namespace]: parsed.data.metrics || {}, }; this.featureStore.init(initData, () => fn?.()); diff --git a/packages/shared/sdk-server/src/store/Metric.ts b/packages/shared/sdk-server/src/store/Metric.ts new file mode 100644 index 000000000..ec42a4e28 --- /dev/null +++ b/packages/shared/sdk-server/src/store/Metric.ts @@ -0,0 +1,5 @@ +import { Versioned } from '../evaluation/data/Versioned'; + +export interface Metric extends Versioned { + samplingRatio?: number; +} diff --git a/packages/shared/sdk-server/src/store/Override.ts b/packages/shared/sdk-server/src/store/Override.ts new file mode 100644 index 000000000..37bc07524 --- /dev/null +++ b/packages/shared/sdk-server/src/store/Override.ts @@ -0,0 +1,5 @@ +import { Versioned } from '../evaluation/data/Versioned'; + +export interface Override extends Versioned { + value: any; +} diff --git a/packages/shared/sdk-server/src/store/VersionedDataKinds.ts b/packages/shared/sdk-server/src/store/VersionedDataKinds.ts index 6bfab16e7..2ac0af241 100644 --- a/packages/shared/sdk-server/src/store/VersionedDataKinds.ts +++ b/packages/shared/sdk-server/src/store/VersionedDataKinds.ts @@ -3,7 +3,6 @@ import { DataKind } from '../api/interfaces'; export interface VersionedDataKind extends DataKind { namespace: string; streamApiPath: string; - requestPath: string; getDependencyKeys?: (item: any) => string[]; } @@ -11,12 +10,20 @@ export default class VersionedDataKinds { static readonly Features: VersionedDataKind = { namespace: 'features', streamApiPath: '/flags/', - requestPath: '/sdk/latest-flags/', }; static readonly Segments: VersionedDataKind = { namespace: 'segments', streamApiPath: '/segments/', - requestPath: '/sdk/latest-segments/', + }; + + static readonly ConfigurationOverrides: VersionedDataKind = { + namespace: 'configurationOverrides', + streamApiPath: '/configurationOverrides/', + }; + + static readonly Metrics: VersionedDataKind = { + namespace: 'metrics', + streamApiPath: '/metrics/', }; } diff --git a/packages/shared/sdk-server/src/store/serialization.ts b/packages/shared/sdk-server/src/store/serialization.ts index 0807fb087..8935ab85b 100644 --- a/packages/shared/sdk-server/src/store/serialization.ts +++ b/packages/shared/sdk-server/src/store/serialization.ts @@ -8,6 +8,8 @@ import { VersionedData } from '../api/interfaces'; import { Flag } from '../evaluation/data/Flag'; import { Rollout } from '../evaluation/data/Rollout'; import { Segment } from '../evaluation/data/Segment'; +import { Metric } from './Metric'; +import { Override } from './Override'; import VersionedDataKinds, { VersionedDataKind } from './VersionedDataKinds'; /** @@ -23,13 +25,15 @@ export function reviver(this: any, key: string, value: any): any { return value; } -interface FlagsAndSegments { +interface AllData { flags: { [name: string]: Flag }; segments: { [name: string]: Segment }; + configurationOverrides?: { [name: string]: Override }; + metrics?: { [name: string]: Metric }; } -interface AllData { - data: FlagsAndSegments; +interface AllDataStream { + data: AllData; } /** @@ -61,10 +65,12 @@ interface DeleteData extends Omit { type VersionedFlag = VersionedData & Flag; type VersionedSegment = VersionedData & Segment; +type VersionedOverride = VersionedData & Override; +type VersionedMetric = VersionedData & Metric; interface PatchData { path: string; - data: VersionedFlag | VersionedSegment; + data: VersionedFlag | VersionedSegment | VersionedOverride | VersionedMetric; kind?: VersionedDataKind; } @@ -136,14 +142,14 @@ function tryParse(data: string): any { /** * @internal */ -export function deserializeAll(data: string): AllData | undefined { +export function deserializeAll(data: string): AllDataStream | undefined { // The reviver lacks the context of where a different key exists, being as it // starts at the deepest level and works outward. As a result attributes are // translated into references after the initial parsing. That way we can be sure // they are the correct ones. For instance if we added 'attribute' as a new field to // the schema for something that was NOT an attribute reference, then we wouldn't // want to construct an attribute reference out of it. - const parsed = tryParse(data) as AllData; + const parsed = tryParse(data) as AllDataStream; if (!parsed) { return undefined; @@ -167,8 +173,8 @@ export function deserializeAll(data: string): AllData | undefined { * @param data String data from launchdarkly. * @returns The parsed and processed data. */ -export function deserializePoll(data: string): FlagsAndSegments | undefined { - const parsed = tryParse(data) as FlagsAndSegments; +export function deserializePoll(data: string): AllData | undefined { + const parsed = tryParse(data) as AllData; if (!parsed) { return undefined; @@ -200,6 +206,10 @@ export function deserializePatch(data: string): PatchData | undefined { } else if (parsed.path.startsWith(VersionedDataKinds.Segments.streamApiPath)) { processSegment(parsed.data as VersionedSegment); parsed.kind = VersionedDataKinds.Segments; + } else if (parsed.path.startsWith(VersionedDataKinds.ConfigurationOverrides.streamApiPath)) { + parsed.kind = VersionedDataKinds.ConfigurationOverrides; + } else if (parsed.path.startsWith(VersionedDataKinds.Metrics.streamApiPath)) { + parsed.kind = VersionedDataKinds.Metrics; } return parsed; @@ -217,6 +227,10 @@ export function deserializeDelete(data: string): DeleteData | undefined { parsed.kind = VersionedDataKinds.Features; } else if (parsed.path.startsWith(VersionedDataKinds.Segments.streamApiPath)) { parsed.kind = VersionedDataKinds.Segments; + } else if (parsed.path.startsWith(VersionedDataKinds.ConfigurationOverrides.streamApiPath)) { + parsed.kind = VersionedDataKinds.ConfigurationOverrides; + } else if (parsed.path.startsWith(VersionedDataKinds.Metrics.streamApiPath)) { + parsed.kind = VersionedDataKinds.Metrics; } return parsed; } diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index fffb1151d..de7d2d4f8 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -6,10 +6,17 @@ import clearPrefix from './clearPrefix'; const featuresKind = { namespace: 'features', deserialize: (data: string) => JSON.parse(data) }; const segmentsKind = { namespace: 'segments', deserialize: (data: string) => JSON.parse(data) }; +const configurationOverridesKind = { + namespace: 'configurationOverrides', + deserialize: (data: string) => JSON.parse(data), +}; +const metricsKind = { namespace: 'metrics', deserialize: (data: string) => JSON.parse(data) }; const dataKind = { features: featuresKind, segments: segmentsKind, + configurationOverrides: configurationOverridesKind, + metrics: metricsKind, }; function promisify(method: (callback: (val: T) => void) => void): Promise { @@ -98,14 +105,24 @@ describe('given an empty store', () => { const segments = [ { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, ]; + const configurationOverrides = [ + { key: 'first', item: { version: 1, serializedItem: `{"version":3}`, deleted: false } }, + ]; + const metrics = [ + { key: 'first', item: { version: 1, serializedItem: `{"version":4}`, deleted: false } }, + ]; await facade.init([ { key: dataKind.features, item: flags }, { key: dataKind.segments, item: segments }, + { key: dataKind.configurationOverrides, item: configurationOverrides }, + { key: dataKind.metrics, item: metrics }, ]); const items1 = await facade.getAll(dataKind.features); const items2 = await facade.getAll(dataKind.segments); + const overrides1 = await facade.getAll(dataKind.configurationOverrides); + const metrics1 = await facade.getAll(dataKind.metrics); // Reading from the store will not maintain the version. expect(items1).toEqual([ @@ -125,20 +142,43 @@ describe('given an empty store', () => { }, ]); + expect(overrides1).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":3}' }, + }, + ]); + expect(metrics1).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":4}' }, + }, + ]); + const newFlags = [ { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, ]; const newSegments = [ { key: 'first', item: { version: 3, serializedItem: `{"version":3}`, deleted: false } }, ]; + const newOverrides = [ + { key: 'first', item: { version: 2, serializedItem: `{"version":5}`, deleted: false } }, + ]; + const newMetrics = [ + { key: 'first', item: { version: 3, serializedItem: `{"version":6}`, deleted: false } }, + ]; await facade.init([ { key: dataKind.features, item: newFlags }, { key: dataKind.segments, item: newSegments }, + { key: dataKind.configurationOverrides, item: newOverrides }, + { key: dataKind.metrics, item: newMetrics }, ]); const items3 = await facade.getAll(dataKind.features); const items4 = await facade.getAll(dataKind.segments); + const overrides2 = await facade.getAll(dataKind.configurationOverrides); + const metrics2 = await facade.getAll(dataKind.metrics); expect(items3).toEqual([ { @@ -152,6 +192,18 @@ describe('given an empty store', () => { item: { version: 0, deleted: false, serializedItem: '{"version":3}' }, }, ]); + expect(overrides2).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":5}' }, + }, + ]); + expect(metrics2).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":6}' }, + }, + ]); }); }); From f750bd56cc8c3cbbada844e4811720829adde705 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 4 Aug 2023 09:31:34 -0700 Subject: [PATCH 21/57] feat: Add custom event support to tracker. (#227) Co-authored-by: Yusinto Ngadiman --- .../__tests__/MigrationOpTracker.test.ts | 47 +++++++++++++++++++ .../sdk-server/src/MigrationOpTracker.ts | 9 +++- .../src/api/data/LDMigrationDetail.ts | 43 ++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 56369f1fc..c96983e77 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -175,3 +175,50 @@ it('includes if the result was inconsistent', () => { samplingOdds: 0, }); }); + +it('allows for the addition of custom measurements', () => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + + tracker.custom({ + kind: 'custom', + key: 'cats', + values: { + old: 3, + new: 10, + }, + }); + + tracker.custom({ + kind: 'custom', + key: 'badgers', + values: { + new: 10, + }, + }); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'cats', + kind: 'custom', + values: { + old: 3, + new: 10, + }, + }); + expect(event?.measurements).toContainEqual({ + key: 'badgers', + kind: 'custom', + values: { + new: 10, + }, + }); +}); diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index f269b62b5..0d2fc07cc 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -3,6 +3,7 @@ import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { LDConsistencyCheck, + LDMigrationCustomMeasurement, LDMigrationMeasurement, LDMigrationOp, LDMigrationOpEvent, @@ -28,6 +29,8 @@ export default class MigrationOpTracker implements LDMigrationTracker { private operation?: LDMigrationOp; + private customMeasurements: LDMigrationCustomMeasurement[] = []; + constructor( private readonly flagKey: string, private readonly contextKeys: Record, @@ -53,9 +56,13 @@ export default class MigrationOpTracker implements LDMigrationTracker { this.latencyMeasurement[origin] = value; } + custom(measurement: LDMigrationCustomMeasurement) { + this.customMeasurements.push(measurement); + } + createEvent(): LDMigrationOpEvent | undefined { if (this.operation && Object.keys(this.contextKeys).length) { - const measurements: LDMigrationMeasurement[] = []; + const measurements = [...this.customMeasurements]; this.populateConsistency(measurements); this.populateLatency(measurements); diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts index e35e045c1..33e56bbf6 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -1,7 +1,11 @@ import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationOrigin } from '../LDMigration'; -import { LDMigrationOp, LDMigrationOpEvent } from './LDMigrationOpEvent'; +import { + LDMigrationCustomMeasurement, + LDMigrationOp, + LDMigrationOpEvent, +} from './LDMigrationOpEvent'; import { LDMigrationStage } from './LDMigrationStage'; /** @@ -19,10 +23,47 @@ export enum LDConsistencyCheck { * TKTK */ export interface LDMigrationTracker { + /** + * Sets the migration related operation associated with these tracking measurements. + * + * @param op The operation being tracked. + */ op(op: LDMigrationOp): void; + + /** + * Report that an error has occurred for the specified origin. + * + * @param origin The origin of the error. + */ error(origin: LDMigrationOrigin): void; + + /** + * Report the result of a consistency check. + * + * @param result The result of the check. + */ consistency(result: LDConsistencyCheck): void; + + /** + * Report the latency of an operation. + * + * @param origin The origin the latency is being reported for. + * @param value The latency, in milliseconds, of the operation. + */ latency(origin: LDMigrationOrigin, value: number): void; + + /** + * Report a custom measurement. Unlike other methods on the tracker multiple custom + * measurements can be reported. + * + * @param measurement The custom measurement to track. + */ + custom(measurement: LDMigrationCustomMeasurement): void; + + /** + * Create a migration op event. If the event could not be created, because of a missing + * operation, then undefined is returned. + */ createEvent(): LDMigrationOpEvent | undefined; } From 02e92a4bcfd2a004027825e43f63c5c23366165a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 16 Aug 2023 14:02:49 -0700 Subject: [PATCH 22/57] feat: Remove custom events. (#243) --- .../__tests__/MigratioOpEvent.test.ts | 48 ++++++++----------- .../__tests__/MigrationOpTracker.test.ts | 47 ------------------ .../src/MigrationOpEventConversion.ts | 26 ++-------- .../sdk-server/src/MigrationOpTracker.ts | 9 +--- .../src/api/data/LDMigrationDetail.ts | 14 +----- .../src/api/data/LDMigrationOpEvent.ts | 12 +---- 6 files changed, 26 insertions(+), 130 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts index 0886b8051..4b258d5cd 100644 --- a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts @@ -382,10 +382,7 @@ describe('given an LDClient with test data', () => { }); }); -// Out migrator doesn't create custom measurements. So we need an additional test to ensure -// that custom measurements make it through the conversion process. - -it('can accept custom measurements', () => { +it('ignores invalid measurement keys', () => { const inputEvent: LDMigrationOpEvent = { kind: 'migration_op', operation: 'read', @@ -401,39 +398,20 @@ it('can accept custom measurements', () => { }, measurements: [ { - key: 'custom1', - kind: 'custom', + // @ts-ignore + key: 'bad', values: { old: 1, new: 2, }, }, - { - key: 'custom2', - kind: 'custom', - values: { - new: 2, - }, - }, - { - key: 'custom3', - kind: 'custom', - values: { - old: 2, - }, - }, - { - key: 'custom4', - kind: 'custom', - values: {}, - }, ], }; const validatedEvent = MigrationOpEventConversion(inputEvent); - expect(validatedEvent).toEqual(inputEvent); + expect(validatedEvent).toEqual({ ...inputEvent, measurements: [] }); }); -it('removes bad custom measurements', () => { +it('invalid data types are filtered', () => { const inputEvent: LDMigrationOpEvent = { kind: 'migration_op', operation: 'read', @@ -449,14 +427,26 @@ it('removes bad custom measurements', () => { }, measurements: [ { - key: 'custom1', - kind: 'custom', + key: 'latency_ms', values: { // @ts-ignore old: 'ham', new: 2, }, }, + { + key: 'consistent', + // @ts-ignore + value: undefined, + }, + { + key: 'error', + values: { + // @ts-ignore + old: {}, + new: 2, + }, + }, ], }; const validatedEvent = MigrationOpEventConversion(inputEvent); diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index c96983e77..56369f1fc 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -175,50 +175,3 @@ it('includes if the result was inconsistent', () => { samplingOdds: 0, }); }); - -it('allows for the addition of custom measurements', () => { - const tracker = new MigrationOpTracker( - 'flag', - { user: 'bob' }, - LDMigrationStage.Off, - LDMigrationStage.Off, - { - kind: 'FALLTHROUGH', - }, - ); - tracker.op('read'); - - tracker.custom({ - kind: 'custom', - key: 'cats', - values: { - old: 3, - new: 10, - }, - }); - - tracker.custom({ - kind: 'custom', - key: 'badgers', - values: { - new: 10, - }, - }); - - const event = tracker.createEvent(); - expect(event?.measurements).toContainEqual({ - key: 'cats', - kind: 'custom', - values: { - old: 3, - new: 10, - }, - }); - expect(event?.measurements).toContainEqual({ - key: 'badgers', - kind: 'custom', - values: { - new: 10, - }, - }); -}); diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index 6daa08388..e89f072ef 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -2,7 +2,6 @@ import { internal, TypeValidators } from '@launchdarkly/js-sdk-common'; import { LDMigrationConsistencyMeasurement, - LDMigrationCustomMeasurement, LDMigrationErrorMeasurement, LDMigrationEvaluation, LDMigrationLatencyMeasurement, @@ -19,10 +18,6 @@ function isOperation(value: LDMigrationOp) { return value === 'read' || value === 'write'; } -function isCustomMeasurement(value: LDMigrationMeasurement): value is LDMigrationCustomMeasurement { - return (value as any).kind === 'custom'; -} - function isLatencyMeasurement( value: LDMigrationMeasurement, ): value is LDMigrationLatencyMeasurement { @@ -54,27 +49,13 @@ function areValidValues(values: { old?: number; new?: number }) { function validateMeasurement( measurement: LDMigrationMeasurement, ): LDMigrationMeasurement | undefined { + // Here we are protecting ourselves from JS callers. TypeScript says that + // it cannot be an empty string, but those using JS can do what they want. + // @ts-ignore if (!TypeValidators.String.is(measurement.key) || measurement.key === '') { return undefined; } - if (isCustomMeasurement(measurement)) { - if (!TypeValidators.Object.is(measurement.values)) { - return undefined; - } - if (!areValidValues(measurement.values)) { - return undefined; - } - return { - kind: measurement.kind, - key: measurement.key, - values: { - old: measurement.values.old, - new: measurement.values.new, - }, - }; - } - if (isLatencyMeasurement(measurement)) { if (!TypeValidators.Object.is(measurement.values)) { return undefined; @@ -121,6 +102,7 @@ function validateMeasurement( }; } + // Not a supported measurement type. return undefined; } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index 0d2fc07cc..f269b62b5 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -3,7 +3,6 @@ import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { LDConsistencyCheck, - LDMigrationCustomMeasurement, LDMigrationMeasurement, LDMigrationOp, LDMigrationOpEvent, @@ -29,8 +28,6 @@ export default class MigrationOpTracker implements LDMigrationTracker { private operation?: LDMigrationOp; - private customMeasurements: LDMigrationCustomMeasurement[] = []; - constructor( private readonly flagKey: string, private readonly contextKeys: Record, @@ -56,13 +53,9 @@ export default class MigrationOpTracker implements LDMigrationTracker { this.latencyMeasurement[origin] = value; } - custom(measurement: LDMigrationCustomMeasurement) { - this.customMeasurements.push(measurement); - } - createEvent(): LDMigrationOpEvent | undefined { if (this.operation && Object.keys(this.contextKeys).length) { - const measurements = [...this.customMeasurements]; + const measurements: LDMigrationMeasurement[] = []; this.populateConsistency(measurements); this.populateLatency(measurements); diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts index 33e56bbf6..83dd6c3a0 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -1,11 +1,7 @@ import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationOrigin } from '../LDMigration'; -import { - LDMigrationCustomMeasurement, - LDMigrationOp, - LDMigrationOpEvent, -} from './LDMigrationOpEvent'; +import { LDMigrationOp, LDMigrationOpEvent } from './LDMigrationOpEvent'; import { LDMigrationStage } from './LDMigrationStage'; /** @@ -52,14 +48,6 @@ export interface LDMigrationTracker { */ latency(origin: LDMigrationOrigin, value: number): void; - /** - * Report a custom measurement. Unlike other methods on the tracker multiple custom - * measurements can be reported. - * - * @param measurement The custom measurement to track. - */ - custom(measurement: LDMigrationCustomMeasurement): void; - /** * Create a migration op event. If the event could not be created, because of a missing * operation, then undefined is returned. diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index d45980b79..d6f68d649 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -16,15 +16,6 @@ export interface LDMigrationEvaluation { reason: LDEvaluationReason; } -export interface LDMigrationCustomMeasurement { - kind: 'custom'; - key: string; - values: { - old?: number; - new?: number; - }; -} - export interface LDMigrationConsistencyMeasurement { key: 'consistent'; value: number; @@ -53,8 +44,7 @@ export interface LDMigrationErrorMeasurement { export type LDMigrationMeasurement = | LDMigrationLatencyMeasurement | LDMigrationErrorMeasurement - | LDMigrationConsistencyMeasurement - | LDMigrationCustomMeasurement; + | LDMigrationConsistencyMeasurement; /** * Event used to track information about a migration operation. From f9b4e6e520211a624208939ce444e1cba1861255 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:25:10 -0700 Subject: [PATCH 23/57] feat: Event sampling. (#245) --- contract-tests/index.js | 1 + contract-tests/sdkClientEntity.js | 89 +++++++- .../internal/events/EventProcessor.test.ts | 194 ++++++++++++++++++ .../src/internal/events/EventProcessor.ts | 85 ++++++-- .../src/internal/events/InputCustomEvent.ts | 6 +- .../src/internal/events/InputEvalEvent.ts | 9 +- .../src/internal/events/InputIdentifyEvent.ts | 8 +- .../internal/events/InputMigrationEvent.ts | 1 + .../src/internal/events/LDEventOverrides.ts | 29 +++ .../common/src/internal/events/index.ts | 4 + .../common/src/internal/events/sampling.ts | 22 ++ .../__tests__/LDClient.events.test.ts | 41 ++-- ...Event.test.ts => MigrationOpEvent.test.ts} | 186 +++++++++++------ .../__tests__/MigrationOpTracker.test.ts | 14 +- .../__tests__/events/EventProcessor.test.ts | 12 +- .../shared/sdk-server/src/LDClientImpl.ts | 129 +++++++++--- packages/shared/sdk-server/src/Migration.ts | 20 +- .../src/MigrationOpEventConversion.ts | 29 ++- .../sdk-server/src/MigrationOpTracker.ts | 23 ++- .../src/api/data/LDMigrationDetail.ts | 7 + .../src/api/data/LDMigrationOpEvent.ts | 8 +- .../sdk-server/src/evaluation/data/Flag.ts | 5 +- .../sdk-server/src/events/EventFactory.ts | 24 ++- packages/shared/sdk-server/src/index.ts | 11 +- .../test_data/TestDataFlagBuilder.ts | 17 ++ .../store/node-server-sdk-dynamodb/README.md | 4 +- 26 files changed, 794 insertions(+), 184 deletions(-) create mode 100644 packages/shared/common/src/internal/events/LDEventOverrides.ts create mode 100644 packages/shared/common/src/internal/events/sampling.ts rename packages/shared/sdk-server/__tests__/{MigratioOpEvent.test.ts => MigrationOpEvent.test.ts} (71%) diff --git a/contract-tests/index.js b/contract-tests/index.js index ee27b7751..fe9fecc47 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -28,6 +28,7 @@ app.get('/', (req, res) => { 'tags', 'big-segments', 'user-type', + 'migrations', ], }); }); diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index c1095659c..b60cf1d9b 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -1,4 +1,12 @@ -import ld from 'node-server-sdk'; +import got from 'got'; +import ld, { + LDExecution, + LDExecutionOrdering, + LDMigrationError, + LDMigrationSuccess, + LDSerialExecution, + Migration, +} from 'node-server-sdk'; import BigSegmentTestStore from './BigSegmentTestStore.js'; import { Log, sdkLogger } from './log.js'; @@ -126,6 +134,85 @@ export async function newSdkClientEntity(options) { case 'getBigSegmentStoreStatus': return await client.bigSegmentStoreStatusProvider.requireStatus(); + case 'migrationVariation': + const migrationVariation = params.migrationVariation; + const res = await client.variationMigration( + migrationVariation.key, + migrationVariation.context, + migrationVariation.defaultStage, + ); + return { result: res.value }; + + case 'migrationOperation': + const migrationOperation = params.migrationOperation; + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: migrationOperation.trackLatency, + errorTracking: migrationOperation.trackErrors, + check: migrationOperation.trackConsistency ? (a, b) => a === b : undefined, + readNew: async () => { + try { + const res = await got.post(migrationOperation.newEndpoint, {}); + return LDMigrationSuccess(res.body); + } catch (err) { + return LDMigrationError(err.message); + } + }, + writeNew: async () => { + try { + const res = await got.post(migrationOperation.newEndpoint, {}); + return LDMigrationSuccess(res.body); + } catch (err) { + return LDMigrationError(err.message); + } + }, + readOld: async () => { + try { + const res = await got.post(migrationOperation.oldEndpoint, {}); + return LDMigrationSuccess(res.body); + } catch (err) { + return LDMigrationError(err.message); + } + }, + writeOld: async () => { + try { + const res = await got.post(migrationOperation.oldEndpoint, {}); + return LDMigrationSuccess(res.body); + } catch (err) { + return LDMigrationError(err.message); + } + }, + }); + + switch (migrationOperation.operation) { + case 'read': { + const res = await migration.read( + migrationOperation.key, + migrationOperation.context, + migrationOperation.defaultStage, + ); + if (res.success) { + return { result: res.result }; + } else { + return { result: res.error }; + } + } + case 'write': { + const res = await migration.write( + migrationOperation.key, + migrationOperation.context, + migrationOperation.defaultStage, + ); + + if (res.authoritative.success) { + return { result: res.authoritative.result }; + } else { + return { result: res.authoritative.error }; + } + } + } + return undefined; + default: throw badCommandError; } diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index fc507d249..5bc17c794 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -28,6 +28,12 @@ import { } from '../../../src/api/subsystem'; import { EventProcessor, InputIdentifyEvent } from '../../../src/internal'; import { EventProcessorOptions } from '../../../src/internal/events/EventProcessor'; +import shouldSample from '../../../src/internal/events/sampling'; + +jest.mock('../../../src/internal/events/sampling', () => ({ + __esModule: true, + default: jest.fn(() => true), +})); const user = { key: 'userKey', name: 'Red' }; const userWithFilteredName = { @@ -195,6 +201,8 @@ describe('given an event processor', () => { beforeEach(() => { eventSender = new MockEventSender(); contextDeduplicator = new MockContextDeduplicator(); + // @ts-ignore + shouldSample.mockImplementation(() => true); eventProcessor = new EventProcessor( eventProcessorConfig, @@ -286,6 +294,9 @@ describe('given an event processor', () => { value: 'value', trackEvents: true, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -302,6 +313,102 @@ describe('given an event processor', () => { ]); }); + it('uses sampling ratio for feature events', async () => { + Date.now = jest.fn(() => 1000); + eventProcessor.sendEvent({ + kind: 'feature', + creationDate: 1000, + context: Context.fromLDContext(user), + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: true, + default: 'default', + samplingRatio: 2, + indexSamplingRatio: 1, // Disable the index events. + withReasons: true, + }); + + await eventProcessor.flush(); + const request = await eventSender.queue.take(); + expect(shouldSample).toHaveBeenCalledWith(2); + + expect(request.data).toEqual([ + { + kind: 'index', + creationDate: 1000, + context: { ...user, kind: 'user' }, + }, + { ...makeFeatureEvent(1000, 11), samplingRatio: 2 }, + makeSummary(1000, 1000, 1, 11), + ]); + }); + + it('excludes feature events that are not sampled', async () => { + // @ts-ignore + shouldSample.mockImplementation((ratio) => ratio !== 2); + Date.now = jest.fn(() => 1000); + eventProcessor.sendEvent({ + kind: 'feature', + creationDate: 1000, + context: Context.fromLDContext(user), + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: true, + default: 'default', + samplingRatio: 2, + indexSamplingRatio: 1, // Disable the index events. + withReasons: true, + }); + + await eventProcessor.flush(); + const request = await eventSender.queue.take(); + expect(shouldSample).toHaveBeenCalledWith(2); + expect(shouldSample).toHaveBeenCalledWith(1); + + expect(request.data).toEqual([ + { + kind: 'index', + creationDate: 1000, + context: { ...user, kind: 'user' }, + }, + makeSummary(1000, 1000, 1, 11), + ]); + }); + + it('excludes index events that are not sampled', async () => { + // @ts-ignore + shouldSample.mockImplementation((ratio) => ratio === 2); + Date.now = jest.fn(() => 1000); + eventProcessor.sendEvent({ + kind: 'feature', + creationDate: 1000, + context: Context.fromLDContext(user), + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: true, + default: 'default', + samplingRatio: 2, + indexSamplingRatio: 1, // Disable the index events. + withReasons: true, + }); + + await eventProcessor.flush(); + const request = await eventSender.queue.take(); + expect(shouldSample).toHaveBeenCalledWith(2); + expect(shouldSample).toHaveBeenCalledWith(1); + + expect(request.data).toEqual([ + { ...makeFeatureEvent(1000, 11), samplingRatio: 2 }, + makeSummary(1000, 1000, 1, 11), + ]); + }); + it('handles the version being 0', async () => { Date.now = jest.fn(() => 1000); eventProcessor.sendEvent({ @@ -314,6 +421,9 @@ describe('given an event processor', () => { value: 'value', trackEvents: true, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -344,6 +454,9 @@ describe('given an event processor', () => { trackEvents: false, debugEventsUntilDate: 2000, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -373,6 +486,9 @@ describe('given an event processor', () => { trackEvents: true, debugEventsUntilDate: 2000, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -409,6 +525,9 @@ describe('given an event processor', () => { trackEvents: false, debugEventsUntilDate: 1500, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -438,6 +557,9 @@ describe('given an event processor', () => { value: 'value', trackEvents: true, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); eventProcessor.sendEvent({ kind: 'feature', @@ -449,6 +571,9 @@ describe('given an event processor', () => { value: 'carrot', trackEvents: true, default: 'potato', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -511,6 +636,9 @@ describe('given an event processor', () => { value: 'value', trackEvents: false, default: 'default', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); eventProcessor.sendEvent({ kind: 'feature', @@ -522,6 +650,9 @@ describe('given an event processor', () => { value: 'carrot', trackEvents: false, default: 'potato', + samplingRatio: 1, + indexSamplingRatio: 1, + withReasons: true, }); await eventProcessor.flush(); @@ -577,6 +708,43 @@ describe('given an event processor', () => { context: Context.fromLDContext(user), key: 'eventkey', data: { thing: 'stuff' }, + samplingRatio: 1, + indexSamplingRatio: 1, + }); + + await eventProcessor.flush(); + const request = await eventSender.queue.take(); + + expect(request.data).toEqual([ + { + kind: 'index', + creationDate: 1000, + context: { ...user, kind: 'user' }, + }, + { + kind: 'custom', + key: 'eventkey', + data: { thing: 'stuff' }, + creationDate: 1000, + contextKeys: { + user: 'userKey', + }, + }, + ]); + }); + + it('does not queue a custom event that is not sampled', async () => { + // @ts-ignore + shouldSample.mockImplementation((ratio) => ratio !== 2); + Date.now = jest.fn(() => 1000); + eventProcessor.sendEvent({ + kind: 'custom', + creationDate: 1000, + context: Context.fromLDContext(user), + key: 'eventkey', + data: { thing: 'stuff' }, + samplingRatio: 2, + indexSamplingRatio: 1, }); await eventProcessor.flush(); @@ -588,6 +756,27 @@ describe('given an event processor', () => { creationDate: 1000, context: { ...user, kind: 'user' }, }, + ]); + }); + + it('does not queue a index event that is not sampled with a custom event', async () => { + // @ts-ignore + shouldSample.mockImplementation((ratio) => ratio === 2); + Date.now = jest.fn(() => 1000); + eventProcessor.sendEvent({ + kind: 'custom', + creationDate: 1000, + context: Context.fromLDContext(user), + key: 'eventkey', + data: { thing: 'stuff' }, + samplingRatio: 2, + indexSamplingRatio: 1, + }); + + await eventProcessor.flush(); + const request = await eventSender.queue.take(); + + expect(request.data).toEqual([ { kind: 'custom', key: 'eventkey', @@ -596,6 +785,7 @@ describe('given an event processor', () => { contextKeys: { user: 'userKey', }, + samplingRatio: 2, }, ]); }); @@ -607,6 +797,8 @@ describe('given an event processor', () => { context: Context.fromLDContext(anonUser), key: 'eventkey', data: { thing: 'stuff' }, + samplingRatio: 1, + indexSamplingRatio: 1, }); await eventProcessor.flush(); @@ -639,6 +831,8 @@ describe('given an event processor', () => { key: 'eventkey', data: { thing: 'stuff' }, metricValue: 1.5, + samplingRatio: 1, + indexSamplingRatio: 1, }); await eventProcessor.flush(); diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 870a4fc35..4b847c18a 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -9,7 +9,10 @@ import ClientContext from '../../options/ClientContext'; import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; import { isFeature, isIdentify, isMigration } from './guards'; import InputEvent from './InputEvent'; +import InputIdentifyEvent from './InputIdentifyEvent'; +import InputMigrationEvent from './InputMigrationEvent'; import LDInvalidSDKKeyError from './LDInvalidSDKKeyError'; +import shouldSample from './sampling'; type FilteredContext = any; @@ -20,6 +23,7 @@ interface IdentifyOutputEvent { kind: 'identify' | 'index'; creationDate: number; context: FilteredContext; + samplingRatio?: number; } interface CustomOutputEvent { @@ -29,6 +33,7 @@ interface CustomOutputEvent { contextKeys: Record; data?: any; metricValue?: number; + samplingRatio?: number; } interface FeatureOutputEvent { @@ -43,6 +48,11 @@ interface FeatureOutputEvent { reason?: LDEvaluationReason; context?: FilteredContext; contextKeys?: Record; + samplingRatio?: number; +} + +interface IndexInputEvent extends Omit { + kind: 'index'; } /** @@ -51,12 +61,18 @@ interface FeatureOutputEvent { */ type DiagnosticEvent = any; +interface MigrationOutputEvent extends Omit { + // Make the sampling ratio optional so we can omit it when it is one. + samplingRatio?: number; +} + type OutputEvent = | IdentifyOutputEvent | CustomOutputEvent | FeatureOutputEvent | SummarizedFlagsEvent - | DiagnosticEvent; + | DiagnosticEvent + | MigrationOutputEvent; export interface EventProcessorOptions { allAttributesPrivate: boolean; @@ -197,22 +213,33 @@ export default class EventProcessor implements LDEventProcessor { } if (isMigration(inputEvent)) { - // The contents of the migration op event have been validated - // before this point, so we can just send it. - // TODO: Implement sampling odds. - this.enqueue({ - ...inputEvent, - }); + // These conditions are not combined, because we always want to stop + // processing at this point for a migration event. It cannot generate + // an index event or debug event. + if (shouldSample(inputEvent.samplingRatio)) { + const migrationEvent: MigrationOutputEvent = { + ...inputEvent, + }; + if (migrationEvent.samplingRatio === 1) { + delete migrationEvent.samplingRatio; + } + this.enqueue(migrationEvent); + } return; } this.summarizer.summarizeEvent(inputEvent); const isFeatureEvent = isFeature(inputEvent); + const addFullEvent = (isFeatureEvent && inputEvent.trackEvents) || !isFeatureEvent; + const addDebugEvent = this.shouldDebugEvent(inputEvent); const isIdentifyEvent = isIdentify(inputEvent); + + // We only want to notify the de-duplicator if we would sample the index event. + // Otherwise we could deduplicate and then not send the event. const shouldNotDeduplicate = this.contextDeduplicator.processContext(inputEvent.context); // If there is no cache, then it will never be in the cache. @@ -224,22 +251,28 @@ export default class EventProcessor implements LDEventProcessor { const addIndexEvent = shouldNotDeduplicate && !isIdentifyEvent; - if (addIndexEvent) { - this.enqueue({ - kind: 'index', - creationDate: inputEvent.creationDate, - context: this.contextFilter.filter(inputEvent.context), - }); + if (addIndexEvent && shouldSample(inputEvent.indexSamplingRatio)) { + this.enqueue( + this.makeOutputEvent( + { + kind: 'index', + creationDate: inputEvent.creationDate, + context: inputEvent.context, + samplingRatio: inputEvent.indexSamplingRatio, + }, + false, + ), + ); } - if (addFullEvent) { + if (addFullEvent && shouldSample(inputEvent.samplingRatio)) { this.enqueue(this.makeOutputEvent(inputEvent, false)); } - if (addDebugEvent) { + if (addDebugEvent && shouldSample(inputEvent.samplingRatio)) { this.enqueue(this.makeOutputEvent(inputEvent, true)); } } - private makeOutputEvent(event: InputEvent, debug: boolean): OutputEvent { + private makeOutputEvent(event: InputEvent | IndexInputEvent, debug: boolean): OutputEvent { switch (event.kind) { case 'feature': { const out: FeatureOutputEvent = { @@ -248,8 +281,13 @@ export default class EventProcessor implements LDEventProcessor { key: event.key, value: event.value, default: event.default, - prereqOf: event.prereqOf, }; + if (event.samplingRatio !== 1) { + out.samplingRatio = event.samplingRatio; + } + if (event.prereqOf) { + out.prereqOf = event.prereqOf; + } if (event.variation !== undefined) { out.variation = event.variation; } @@ -266,12 +304,17 @@ export default class EventProcessor implements LDEventProcessor { } return out; } + case 'index': // Intentional fallthrough. case 'identify': { - return { - kind: 'identify', + const out: IdentifyOutputEvent = { + kind: event.kind, creationDate: event.creationDate, context: this.contextFilter.filter(event.context), }; + if (event.samplingRatio !== 1) { + out.samplingRatio = event.samplingRatio; + } + return out; } case 'custom': { const out: CustomOutputEvent = { @@ -281,6 +324,10 @@ export default class EventProcessor implements LDEventProcessor { contextKeys: event.context.kindsAndKeys, }; + if (event.samplingRatio !== 1) { + out.samplingRatio = event.samplingRatio; + } + if (event.data !== undefined) { out.data = event.data; } diff --git a/packages/shared/common/src/internal/events/InputCustomEvent.ts b/packages/shared/common/src/internal/events/InputCustomEvent.ts index c3e33b7d6..5e6fa2ae1 100644 --- a/packages/shared/common/src/internal/events/InputCustomEvent.ts +++ b/packages/shared/common/src/internal/events/InputCustomEvent.ts @@ -5,13 +5,13 @@ export default class InputCustomEvent { public readonly creationDate: number; - public readonly context: Context; - constructor( - context: Context, + public readonly context: Context, public readonly key: string, public readonly data?: any, public readonly metricValue?: number, + public readonly samplingRatio: number = 1, + public readonly indexSamplingRatio: number = 1, ) { this.creationDate = Date.now(); this.context = context; diff --git a/packages/shared/common/src/internal/events/InputEvalEvent.ts b/packages/shared/common/src/internal/events/InputEvalEvent.ts index 078e7cceb..201f47beb 100644 --- a/packages/shared/common/src/internal/events/InputEvalEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvalEvent.ts @@ -6,8 +6,6 @@ export default class InputEvalEvent { public readonly creationDate: number; - public readonly context: Context; - public readonly default: any; public readonly trackEvents?: boolean; @@ -27,8 +25,8 @@ export default class InputEvalEvent { public readonly excludeFromSummaries?: boolean; constructor( - withReasons: boolean, - context: Context, + public readonly withReasons: boolean, + public readonly context: Context, public readonly key: string, defValue: any, // default is a reserved keyword in this context. detail: LDEvaluationDetail, @@ -39,9 +37,10 @@ export default class InputEvalEvent { reason?: LDEvaluationReason, debugEventsUntilDate?: number, excludeFromSummaries?: boolean, + public readonly samplingRatio: number = 1, + public readonly indexSamplingRatio: number = 1, ) { this.creationDate = Date.now(); - this.context = context; this.default = defValue; this.variation = detail.variationIndex ?? undefined; this.value = detail.value; diff --git a/packages/shared/common/src/internal/events/InputIdentifyEvent.ts b/packages/shared/common/src/internal/events/InputIdentifyEvent.ts index 4f2a60900..b9ba89cce 100644 --- a/packages/shared/common/src/internal/events/InputIdentifyEvent.ts +++ b/packages/shared/common/src/internal/events/InputIdentifyEvent.ts @@ -5,10 +5,10 @@ export default class InputIdentifyEvent { public readonly creationDate: number; - public readonly context: Context; - - constructor(context: Context) { + constructor( + public readonly context: Context, + public readonly samplingRatio: number = 1, + ) { this.creationDate = Date.now(); - this.context = context; } } diff --git a/packages/shared/common/src/internal/events/InputMigrationEvent.ts b/packages/shared/common/src/internal/events/InputMigrationEvent.ts index cebc9b440..0e8b3a222 100644 --- a/packages/shared/common/src/internal/events/InputMigrationEvent.ts +++ b/packages/shared/common/src/internal/events/InputMigrationEvent.ts @@ -10,4 +10,5 @@ export default interface InputMigrationEvent { contextKeys: Record; evaluation: any; measurements: any[]; + samplingRatio: number; } diff --git a/packages/shared/common/src/internal/events/LDEventOverrides.ts b/packages/shared/common/src/internal/events/LDEventOverrides.ts new file mode 100644 index 000000000..459e0d6fa --- /dev/null +++ b/packages/shared/common/src/internal/events/LDEventOverrides.ts @@ -0,0 +1,29 @@ +/** + * Represents an override for a specific custom event. + * + * This does not use the data store type, because storage would not be shared between + * client and server implementations. + */ +export interface LDMetricOverride { + samplingRatio?: number; +} + +/** + * Interfaces for accessing dynamic event configuration data from LaunchDarkly. + * + * LaunchDarkly may adjust the rate of sampling of specific event types, or + * specific custom events. + */ +export interface LDEventOverrides { + /** + * Get the sampling ratio for a custom event. + * + * @param key A key of a custom event. + */ + samplingRatio(key: string): Promise; + + /** + * Get the sampling ratio for index events. + */ + indexEventSamplingRatio(): Promise; +} diff --git a/packages/shared/common/src/internal/events/index.ts b/packages/shared/common/src/internal/events/index.ts index 8e554d52e..a124929c8 100644 --- a/packages/shared/common/src/internal/events/index.ts +++ b/packages/shared/common/src/internal/events/index.ts @@ -4,6 +4,8 @@ import InputEvalEvent from './InputEvalEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import { LDEventOverrides } from './LDEventOverrides'; +import shouldSample from './sampling'; export { InputCustomEvent, @@ -12,4 +14,6 @@ export { InputIdentifyEvent, InputMigrationEvent, EventProcessor, + LDEventOverrides, + shouldSample, }; diff --git a/packages/shared/common/src/internal/events/sampling.ts b/packages/shared/common/src/internal/events/sampling.ts new file mode 100644 index 000000000..3ee1c5280 --- /dev/null +++ b/packages/shared/common/src/internal/events/sampling.ts @@ -0,0 +1,22 @@ +/** + * The contents of this file are for event sampling. They are not used for + * any purpose requiring cryptographic security. + * */ + +export default function shouldSample(ratio: number) { + const truncated = Math.trunc(ratio); + // A radio of 1 means 1 in 1. So that will always sample. No need + // to draw a random number. + if (truncated === 1) { + return true; + } + + if (truncated === 0) { + return false; + } + + // Math.random() * truncated) would return 0, 1, ... (ratio - 1). + // Checking for any number in the range will have approximately a 1 in X + // chance. So we check for 0 as it is part of any range. + return Math.floor(Math.random() * truncated) === 0; +} diff --git a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts index 393387ae5..563c63f1f 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts @@ -1,3 +1,5 @@ +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + import { Context, internal } from '@launchdarkly/js-sdk-common'; import { LDClientImpl } from '../src'; @@ -10,14 +12,14 @@ const anonymousUser = { key: 'anon-user', anonymous: true }; describe('given a client with mock event processor', () => { let client: LDClientImpl; - let events: internal.InputEvent[]; + let events: AsyncQueue; let td: TestData; beforeEach(async () => { - events = []; + events = new AsyncQueue(); jest .spyOn(internal.EventProcessor.prototype, 'sendEvent') - .mockImplementation((evt) => events.push(evt)); + .mockImplementation((evt) => events.add(evt)); jest .spyOn(internal.EventProcessor.prototype, 'flush') .mockImplementation(() => Promise.resolve()); @@ -43,8 +45,7 @@ describe('given a client with mock event processor', () => { await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', key: 'flagkey', @@ -60,8 +61,7 @@ describe('given a client with mock event processor', () => { td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); await client.variation('flagkey', anonymousUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', key: 'flagkey', @@ -77,8 +77,7 @@ describe('given a client with mock event processor', () => { td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); await client.variationDetail('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', key: 'flagkey', @@ -111,8 +110,7 @@ describe('given a client with mock event processor', () => { }); await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', creationDate: e.creationDate, @@ -145,8 +143,7 @@ describe('given a client with mock event processor', () => { }); await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', creationDate: e.creationDate, @@ -172,8 +169,7 @@ describe('given a client with mock event processor', () => { }); await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', creationDate: e.creationDate, @@ -210,8 +206,7 @@ describe('given a client with mock event processor', () => { }); await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', creationDate: e.creationDate, @@ -250,8 +245,7 @@ describe('given a client with mock event processor', () => { await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', creationDate: e.creationDate, @@ -268,8 +262,7 @@ describe('given a client with mock event processor', () => { td.update(td.flag('flagkey').on(true).variations('a', 'b').fallthroughVariation(1)); await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', creationDate: e.creationDate, @@ -285,8 +278,7 @@ describe('given a client with mock event processor', () => { it('generates event for unknown feature', async () => { await client.variation('flagkey', defaultUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', key: 'flagkey', @@ -299,8 +291,7 @@ describe('given a client with mock event processor', () => { it('generates event for unknown feature when user is anonymous', async () => { await client.variation('flagkey', anonymousUser, 'c'); - expect(events).toHaveLength(1); - const e = events[0]; + const e = await events.take(); expect(e).toMatchObject({ kind: 'feature', key: 'flagkey', diff --git a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts similarity index 71% rename from packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts rename to packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts index 4b258d5cd..fc2813eb0 100644 --- a/packages/shared/sdk-server/__tests__/MigratioOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts @@ -1,7 +1,8 @@ -import { InputMigrationEvent } from '@launchdarkly/js-sdk-common/dist/internal'; +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { internal } from '@launchdarkly/js-sdk-common'; import { - internal, LDClientImpl, LDConcurrentExecution, LDExecutionOrdering, @@ -16,17 +17,27 @@ import MigrationOpEventConversion from '../src/MigrationOpEventConversion'; import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; +jest.mock('@launchdarkly/js-sdk-common', () => ({ + __esModule: true, + // @ts-ignore + ...jest.requireActual('@launchdarkly/js-sdk-common'), + internal: { + ...jest.requireActual('@launchdarkly/js-sdk-common').internal, + shouldSample: jest.fn().mockReturnValue(true), + }, +})); + describe('given an LDClient with test data', () => { let client: LDClientImpl; - let events: internal.InputEvent[]; + let events: AsyncQueue; let td: TestData; let callbacks: LDClientCallbacks; beforeEach(async () => { - events = []; + events = new AsyncQueue(); jest .spyOn(internal.EventProcessor.prototype, 'sendEvent') - .mockImplementation((evt) => events.push(evt)); + .mockImplementation((evt) => events.add(evt)); td = new TestData(); callbacks = makeCallbacks(false); @@ -44,6 +55,7 @@ describe('given an LDClient with test data', () => { afterEach(() => { client.close(); + events.close(); }); describe.each([ @@ -69,16 +81,57 @@ describe('given an LDClient with test data', () => { it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( 'finds the results consistent: %p', async (stage) => { + jest.spyOn(internal, 'shouldSample').mockReturnValue(true); const flagKey = 'migration'; td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('consistent'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].value).toEqual(1); + expect(migrationEvent.measurements[0].value).toEqual(true); + }, + ); + + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'it uses the check ratio and does a consistency check when it should sample: %p', + async (stage) => { + jest.spyOn(internal, 'shouldSample').mockReturnValue(true); + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage).checkRatio(10)); + // eslint-disable-next-line no-await-in-loop + await migration.read(flagKey, { key: 'test' }, stage); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + // Only check the measurements component of the event. + expect(migrationEvent.measurements[0].key).toEqual('consistent'); + // This isn't a precise check, but we should have non-zero values. + expect(migrationEvent.measurements[0].value).toEqual(true); + expect(internal.shouldSample).toHaveBeenCalledWith(10); + }, + ); + + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( + 'it uses the check ratio and does not do a consistency check when it should not: %p', + async (stage) => { + jest.spyOn(internal, 'shouldSample').mockReturnValue(false); + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage).checkRatio(12)); + // eslint-disable-next-line no-await-in-loop + await migration.read(flagKey, { key: 'test' }, stage); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + // Only check the measurements component of the event. + expect(migrationEvent.measurements.length).toEqual(0); + expect(internal.shouldSample).toHaveBeenCalledWith(12); }, ); }); @@ -101,16 +154,18 @@ describe('given an LDClient with test data', () => { it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( 'finds the results consistent: %p', async (stage) => { + jest.spyOn(internal, 'shouldSample').mockReturnValue(true); const flagKey = 'migration'; td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('consistent'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].value).toEqual(0); + expect(migrationEvent.measurements[0].value).toEqual(false); }, ); }); @@ -143,9 +198,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); @@ -160,9 +216,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); @@ -177,9 +234,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); @@ -192,9 +250,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.write(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); @@ -208,9 +267,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.write(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); @@ -225,9 +285,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.write(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); @@ -240,9 +301,10 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(LDMigrationStage.Live)); await migration.write(flagKey, { key: 'test' }, LDMigrationStage.Live); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - const migrationEvent = events[1] as InputMigrationEvent; + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); @@ -271,15 +333,17 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(events[1]).toMatchObject({ + expect(migrationEvent).toMatchObject({ measurements: [ { key: 'error', values: { - old: 1, - new: 0, + old: true, }, }, ], @@ -294,15 +358,16 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - expect(events[1]).toMatchObject({ + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + expect(migrationEvent).toMatchObject({ measurements: [ { key: 'error', values: { - old: 0, - new: 1, + new: true, }, }, ], @@ -317,15 +382,18 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.read(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(events[1]).toMatchObject({ + expect(migrationEvent).toMatchObject({ measurements: [ { key: 'error', values: { - old: 1, - new: 1, + old: true, + new: true, }, }, ], @@ -340,15 +408,16 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.write(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); - // Only check the measurements component of the event. - expect(events[1]).toMatchObject({ + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + expect(migrationEvent).toMatchObject({ measurements: [ { key: 'error', values: { - old: 1, - new: 0, + old: true, }, }, ], @@ -363,15 +432,17 @@ describe('given an LDClient with test data', () => { td.update(td.flag(flagKey).valueForAll(stage)); await migration.write(flagKey, { key: 'test' }, stage); - expect(events.length).toBe(2); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(events[1]).toMatchObject({ + expect(migrationEvent).toMatchObject({ measurements: [ { key: 'error', values: { - old: 0, - new: 1, + new: true, }, }, ], @@ -401,8 +472,7 @@ it('ignores invalid measurement keys', () => { // @ts-ignore key: 'bad', values: { - old: 1, - new: 2, + old: true, }, }, ], @@ -444,7 +514,7 @@ it('invalid data types are filtered', () => { values: { // @ts-ignore old: {}, - new: 2, + new: true, }, }, ], diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 56369f1fc..139606540 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -65,8 +65,7 @@ it('includes errors if at least one is set', () => { expect(event?.measurements).toContainEqual({ key: 'error', values: { - old: 1, - new: 0, + old: true, }, }); @@ -86,8 +85,7 @@ it('includes errors if at least one is set', () => { expect(eventB?.measurements).toContainEqual({ key: 'error', values: { - old: 0, - new: 1, + new: true, }, }); }); @@ -150,8 +148,8 @@ it('includes if the result was consistent', () => { const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ key: 'consistent', - value: 1, - samplingOdds: 0, + value: true, + samplingRatio: 1, }); }); @@ -171,7 +169,7 @@ it('includes if the result was inconsistent', () => { const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ key: 'consistent', - value: 0, - samplingOdds: 0, + value: false, + samplingRatio: 1, }); }); diff --git a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts index 443654344..5a114ae90 100644 --- a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts @@ -299,10 +299,10 @@ describe('given an event processor with diagnostics manager', () => { it('counts events in queue from last flush and dropped events', async () => { const context = Context.fromLDContext(user); - eventProcessor.sendEvent({ kind: 'identify', creationDate: 1000, context }); - eventProcessor.sendEvent({ kind: 'identify', creationDate: 1001, context }); - eventProcessor.sendEvent({ kind: 'identify', creationDate: 1002, context }); - eventProcessor.sendEvent({ kind: 'identify', creationDate: 1003, context }); + eventProcessor.sendEvent({ kind: 'identify', creationDate: 1000, context, samplingRatio: 1 }); + eventProcessor.sendEvent({ kind: 'identify', creationDate: 1001, context, samplingRatio: 1 }); + eventProcessor.sendEvent({ kind: 'identify', creationDate: 1002, context, samplingRatio: 1 }); + eventProcessor.sendEvent({ kind: 'identify', creationDate: 1003, context, samplingRatio: 1 }); await eventProcessor.flush(); await waitForMessages(3); @@ -344,12 +344,16 @@ describe('given an event processor with diagnostics manager', () => { key: 'eventkey1', creationDate: 1000, context, + samplingRatio: 1, + indexSamplingRatio: 1, }); eventProcessor.sendEvent({ kind: 'custom', key: 'eventkey2', creationDate: 1001, context, + samplingRatio: 1, + indexSamplingRatio: 1, }); await eventProcessor.flush(); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 0600145ab..f0f945377 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -51,6 +51,8 @@ import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; import Configuration from './options/Configuration'; import AsyncStoreFacade from './store/AsyncStoreFacade'; +import { Metric } from './store/Metric'; +import { Override } from './store/Override'; import VersionedDataKinds from './store/VersionedDataKinds'; enum InitState { @@ -108,6 +110,8 @@ export default class LDClientImpl implements LDClient { private diagnosticsManager?: DiagnosticsManager; + private eventConfig: internal.LDEventOverrides; + /** * Intended for use by platform specific client implementations. * @@ -225,6 +229,26 @@ export default class LDClientImpl implements LDClient { this.onReady(); } }); + + this.eventConfig = { + samplingRatio: async (key: string) => { + const ratioItem = await asyncFacade.get(VersionedDataKinds.Metrics, key); + if (ratioItem && !ratioItem.deleted) { + return (ratioItem as Metric).samplingRatio ?? 1; + } + return 1; + }, + indexEventSamplingRatio: async () => { + const indexSampling = await asyncFacade.get( + VersionedDataKinds.ConfigurationOverrides, + 'indexSamplingRatio', + ); + if (indexSampling && !indexSampling.deleted) { + return (indexSampling as Override).value ?? 1; + } + return 1; + }, + }; } initialized(): boolean { @@ -250,7 +274,12 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: any) => void, ): Promise { - const res = await this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault); + const [res] = await this.evaluateIfPossible( + key, + context, + defaultValue, + this.eventFactoryDefault, + ); if (!callback) { return res.detail.value; } @@ -264,7 +293,7 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: LDEvaluationDetail) => void, ): Promise { - const res = await this.evaluateIfPossible( + const [res] = await this.evaluateIfPossible( key, context, defaultValue, @@ -280,8 +309,15 @@ export default class LDClientImpl implements LDClient { defaultValue: LDMigrationStage, ): Promise { const convertedContext = Context.fromLDContext(context); - const detail = await this.variationDetail(key, context, defaultValue as string); + const [{ detail }, flag] = await this.evaluateIfPossible( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + ); + const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; + const checkRatio = flag?.migration?.checkRatio; if (!IsMigrationStage(detail.value)) { const error = new Error(`Unrecognized MigrationState for "${key}"; returning default value.`); this.onError(error); @@ -292,18 +328,28 @@ export default class LDClientImpl implements LDClient { return { value: defaultValue, reason, - tracker: new MigrationOpTracker(key, contextKeys, defaultValue, defaultValue, reason), + checkRatio, + tracker: new MigrationOpTracker( + key, + contextKeys, + defaultValue, + defaultValue, + reason, + checkRatio, + ), }; } return { ...detail, value: detail.value as LDMigrationStage, + checkRatio, tracker: new MigrationOpTracker( key, contextKeys, defaultValue, - defaultValue, + detail.value, detail.reason, + checkRatio, // Can be null for compatibility reasons. detail.variationIndex === null ? undefined : detail.variationIndex, ), @@ -410,16 +456,42 @@ export default class LDClientImpl implements LDClient { this.logger?.warn(ClientMessages.missingContextKeyNoEvent); return; } - this.eventProcessor.sendEvent( - this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue), - ); + // Async immediately invoking function expression to get the flag from the store + // without requiring track to be async. + (async () => { + this.eventProcessor.sendEvent( + this.eventFactoryDefault.customEvent( + key, + checkedContext!, + data, + metricValue, + await this.eventConfig.samplingRatio(key), + await this.eventConfig.indexEventSamplingRatio(), + ), + ); + })(); } trackMigration(event: LDMigrationOpEvent): void { const converted = MigrationOpEventToInputEvent(event); - if (converted) { - this.eventProcessor.sendEvent(converted); + if (!converted) { + return; } + // Async immediately invoking function expression to get the flag from the store + // without requiring track to be async. + (async () => { + // TODO: Would it be better to pass this through. + const sampling = await this.featureStore.get( + VersionedDataKinds.Features, + event.evaluation.key, + ); + const samplingRatio = (sampling as Flag)?.samplingRatio ?? 1; + const inputEvent: internal.InputMigrationEvent = { + ...converted, + samplingRatio, + }; + this.eventProcessor.sendEvent(inputEvent); + })(); } identify(context: LDContext): void { @@ -445,10 +517,10 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: any, eventFactory: EventFactory, - ): Promise { + ): Promise<[EvalResult, Flag?]> { if (this.config.offline) { this.logger?.info('Variation called in offline mode. Returning default value.'); - return EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue); + return [EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue), undefined]; } const evalContext = Context.fromLDContext(context); if (!evalContext.valid) { @@ -457,7 +529,7 @@ export default class LDClientImpl implements LDClient { `${evalContext.message ?? 'Context not valid;'} returning default value.`, ), ); - return EvalResult.forError(ErrorKinds.UserNotSpecified, undefined, defaultValue); + return [EvalResult.forError(ErrorKinds.UserNotSpecified, undefined, defaultValue), undefined]; } const flag = (await this.featureStore.get(VersionedDataKinds.Features, flagKey)) as Flag; @@ -468,20 +540,31 @@ export default class LDClientImpl implements LDClient { this.eventProcessor.sendEvent( this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), ); - return result; + return [result, undefined]; } const evalRes = await this.evaluator.evaluate(flag, evalContext, eventFactory); if (evalRes.detail.variationIndex === undefined || evalRes.detail.variationIndex === null) { this.logger?.debug('Result value is null in variation'); evalRes.setDefault(defaultValue); } - evalRes.events?.forEach((event) => { - this.eventProcessor.sendEvent(event); - }); - this.eventProcessor.sendEvent( - eventFactory.evalEvent(flag, evalContext, evalRes.detail, defaultValue), - ); - return evalRes; + // Immediately invoked function expression to take this processing out of the variation path. + (async () => { + const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); + evalRes.events?.forEach((event) => { + this.eventProcessor.sendEvent({ ...event, indexSamplingRatio }); + }); + this.eventProcessor.sendEvent( + eventFactory.evalEvent( + flag, + evalContext, + evalRes.detail, + defaultValue, + undefined, + indexSamplingRatio, + ), + ); + })(); + return [evalRes, flag]; } private async evaluateIfPossible( @@ -489,7 +572,7 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: any, eventFactory: EventFactory, - ): Promise { + ): Promise<[EvalResult, Flag?]> { if (!this.initialized()) { const storeInitialized = await this.featureStore.initialized(); if (storeInitialized) { @@ -503,7 +586,7 @@ export default class LDClientImpl implements LDClient { 'Variation called before LaunchDarkly client initialization completed (did you wait for the' + "'ready' event?) - using default value", ); - return EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue); + return [EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue), undefined]; } return this.variationInternal(flagKey, context, defaultValue, eventFactory); } diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index 9b60fb755..f715e5fb8 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -1,4 +1,4 @@ -import { LDContext } from '@launchdarkly/js-sdk-common'; +import { internal, LDContext } from '@launchdarkly/js-sdk-common'; import { LDClient, LDConsistencyCheck, LDMigrationStage, LDMigrationTracker } from './api'; import { @@ -17,6 +17,8 @@ import { LDSerialExecution, } from './api/options/LDMigrationOptions'; +const { shouldSample } = internal; + type MultipleReadResult = { fromOld: LDMigrationReadResult; fromNew: LDMigrationReadResult; @@ -54,6 +56,7 @@ export function LDMigrationError(error: Error): { success: false; error: Error } interface MigrationContext { payload?: TPayload; tracker: LDMigrationTracker; + checkRatio?: number; } export default class Migration< @@ -82,14 +85,14 @@ export default class Migration< [LDMigrationStage.Shadow]: async (context) => { const { fromOld, fromNew } = await this.doRead(context); - this.trackConsistency(context.tracker, fromOld, fromNew); + this.trackConsistency(context, fromOld, fromNew); return fromOld; }, [LDMigrationStage.Live]: async (context) => { const { fromNew, fromOld } = await this.doRead(context); - this.trackConsistency(context.tracker, fromOld, fromNew); + this.trackConsistency(context, fromOld, fromNew); return fromNew; }, @@ -201,6 +204,7 @@ export default class Migration< const res = await this.readTable[stage.value]({ payload, tracker: stage.tracker, + checkRatio: stage.checkRatio, }); stage.tracker.op('read'); this.sendEvent(stage.tracker); @@ -231,14 +235,16 @@ export default class Migration< } private trackConsistency( - tracker: LDMigrationTracker, + context: MigrationContext, oldValue: LDMethodResult, newValue: LDMethodResult, ) { - if (this.config.check) { + if (this.config.check && shouldSample(context.checkRatio ?? 1)) { if (oldValue.success && newValue.success) { const res = this.config.check(oldValue.result, newValue.result); - tracker.consistency(res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent); + context.tracker.consistency( + res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent, + ); } } } @@ -328,7 +334,7 @@ export default class Migration< // Performance timer is in ms, but may have a microsecond resolution // fractional component. - const latency = Math.floor(end - start); + const latency = end - start; tracker.latency(origin, latency); return result; } diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index e89f072ef..8fcc50d59 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -34,7 +34,7 @@ function isConsistencyMeasurement( return (value as any).kind === undefined && value.key === 'consistent'; } -function areValidValues(values: { old?: number; new?: number }) { +function areValidNumbers(values: { old?: number; new?: number }) { const oldValue = values.old; const newValue = values.new; if (oldValue !== undefined && !TypeValidators.Number.is(oldValue)) { @@ -46,6 +46,18 @@ function areValidValues(values: { old?: number; new?: number }) { return true; } +function areValidBooleans(values: { old?: boolean; new?: boolean }) { + const oldValue = values.old; + const newValue = values.new; + if (oldValue !== undefined && !TypeValidators.Boolean.is(oldValue)) { + return false; + } + if (newValue !== undefined && !TypeValidators.Boolean.is(newValue)) { + return false; + } + return true; +} + function validateMeasurement( measurement: LDMigrationMeasurement, ): LDMigrationMeasurement | undefined { @@ -60,7 +72,7 @@ function validateMeasurement( if (!TypeValidators.Object.is(measurement.values)) { return undefined; } - if (!areValidValues(measurement.values)) { + if (!areValidNumbers(measurement.values)) { return undefined; } return { @@ -76,7 +88,7 @@ function validateMeasurement( if (!TypeValidators.Object.is(measurement.values)) { return undefined; } - if (!areValidValues(measurement.values)) { + if (!areValidBooleans(measurement.values)) { return undefined; } return { @@ -90,15 +102,15 @@ function validateMeasurement( if (isConsistencyMeasurement(measurement)) { if ( - !TypeValidators.Number.is(measurement.value) || - !TypeValidators.Number.is(measurement.samplingOdds) + !TypeValidators.Boolean.is(measurement.value) || + !TypeValidators.Number.is(measurement.samplingRatio) ) { return undefined; } return { key: measurement.key, value: measurement.value, - samplingOdds: measurement.samplingOdds, + samplingRatio: measurement.samplingRatio, }; } @@ -157,7 +169,7 @@ function validateEvaluation(evaluation: LDMigrationEvaluation): LDMigrationEvalu outReason.bigSegmentsStatus = inReason.bigSegmentsStatus; } - if (evaluation.variation && TypeValidators.Number.is(evaluation.variation)) { + if (evaluation.variation !== undefined && TypeValidators.Number.is(evaluation.variation)) { validated.variation = evaluation.variation; } @@ -174,7 +186,8 @@ function validateEvaluation(evaluation: LDMigrationEvaluation): LDMigrationEvalu */ export default function MigrationOpEventToInputEvent( inEvent: LDMigrationOpEvent, -): internal.InputMigrationEvent | undefined { +): Omit | undefined { + // The sampling ratio is omitted and needs populated by the track migration method. if (inEvent.kind !== 'migration_op') { return undefined; } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index f269b62b5..e49a8a8d1 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -3,6 +3,7 @@ import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { LDConsistencyCheck, + LDMigrationErrorMeasurement, LDMigrationMeasurement, LDMigrationOp, LDMigrationOpEvent, @@ -34,6 +35,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { private readonly defaultStage: LDMigrationStage, private readonly stage: LDMigrationStage, private readonly reason: LDEvaluationReason, + private readonly checkRatio?: number, private readonly variation?: number, ) {} @@ -86,22 +88,25 @@ export default class MigrationOpTracker implements LDMigrationTracker { ) { measurements.push({ key: 'consistent', - value: this.consistencyCheck, - // TODO: Needs to come from someplace. - samplingOdds: 0, + value: this.consistencyCheck === LDConsistencyCheck.Consistent, + samplingRatio: this.checkRatio ?? 1, }); } } private populateErrors(measurements: LDMigrationMeasurement[]) { if (this.errors.new || this.errors.old) { - measurements.push({ + const measurement: LDMigrationErrorMeasurement = { key: 'error', - values: { - old: this.errors.old ? 1 : 0, - new: this.errors.new ? 1 : 0, - }, - }); + values: {}, + }; + if (this.errors.new) { + measurement.values.new = true; + } + if (this.errors.old) { + measurement.values.old = true; + } + measurements.push(measurement); } } diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts index 83dd6c3a0..6e095430c 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -80,4 +80,11 @@ export interface LDMigrationDetail { * A tracker which which can be used to generate analytics for the migration. */ tracker: LDMigrationTracker; + + /** + * When present represents a 1 in X ratio indicating the probability that a given operation + * should have its consistency checked. A 1 indicates that it should always be sampled and + * 0 indicates that it never should be sampled. + */ + checkRatio?: number; } diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index d6f68d649..c964caa97 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -18,8 +18,8 @@ export interface LDMigrationEvaluation { export interface LDMigrationConsistencyMeasurement { key: 'consistent'; - value: number; - samplingOdds: number; + value: boolean; + samplingRatio: number; } export interface LDMigrationLatencyMeasurement { @@ -33,8 +33,8 @@ export interface LDMigrationLatencyMeasurement { export interface LDMigrationErrorMeasurement { key: 'error'; values: { - old?: number; - new?: number; + old?: boolean; + new?: boolean; }; } diff --git a/packages/shared/sdk-server/src/evaluation/data/Flag.ts b/packages/shared/sdk-server/src/evaluation/data/Flag.ts index b3c377c42..2b4bf79de 100644 --- a/packages/shared/sdk-server/src/evaluation/data/Flag.ts +++ b/packages/shared/sdk-server/src/evaluation/data/Flag.ts @@ -26,5 +26,8 @@ export interface Flag extends Versioned { trackEventsFallthrough?: boolean; debugEventsUntilDate?: number; excludeFromSummaries?: boolean; - sampleWeight?: number; + samplingRatio?: number; + migration?: { + checkRatio?: number; + }; } diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index daf5121df..6524e5496 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -15,6 +15,7 @@ export default class EventFactory { detail: LDEvaluationDetail, defaultVal: any, prereqOfFlag?: Flag, + indexEventSamplingRatio?: number, ): internal.InputEvalEvent { const addExperimentData = isExperiment(flag, detail.reason); return new internal.InputEvalEvent( @@ -31,6 +32,8 @@ export default class EventFactory { this.withReasons || addExperimentData ? detail.reason : undefined, flag.debugEventsUntilDate, flag.excludeFromSummaries, + flag.samplingRatio, + indexEventSamplingRatio ?? 1, ); } @@ -40,11 +43,26 @@ export default class EventFactory { /* eslint-disable-next-line class-methods-use-this */ identifyEvent(context: Context) { - return new internal.InputIdentifyEvent(context); + // Currently sampling for identify events is always 1. + return new internal.InputIdentifyEvent(context, 1); } /* eslint-disable-next-line class-methods-use-this */ - customEvent(key: string, context: Context, data?: any, metricValue?: number) { - return new internal.InputCustomEvent(context, key, data ?? undefined, metricValue ?? undefined); + customEvent( + key: string, + context: Context, + data?: any, + metricValue?: number, + samplingRatio: number = 1, + indexSamplingRatio: number = 1, + ) { + return new internal.InputCustomEvent( + context, + key, + data ?? undefined, + metricValue ?? undefined, + samplingRatio, + indexSamplingRatio, + ); } } diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index 96acd80a4..f00a34adf 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -1,11 +1,20 @@ import BigSegmentStoreStatusProviderImpl from './BigSegmentStatusProviderImpl'; import LDClientImpl from './LDClientImpl'; +// TODO: Maybe we have a factory? +import Migration, { LDMigrationError, LDMigrationSuccess } from './Migration'; export * as integrations from './integrations'; export * as platform from '@launchdarkly/js-sdk-common'; export * from './api'; export * from './store'; export * from './events'; + export * from '@launchdarkly/js-sdk-common'; -export { LDClientImpl, BigSegmentStoreStatusProviderImpl }; +export { + LDClientImpl, + BigSegmentStoreStatusProviderImpl, + LDMigrationError, + LDMigrationSuccess, + Migration, +}; diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts b/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts index 168480f43..88dade591 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts @@ -18,6 +18,10 @@ interface BuilderData { // Each target being a context kind and a list of keys for that kind. targetsByVariation?: Record>; rules?: TestDataRuleBuilder[]; + migration?: { + checkRatio?: number; + }; + samplingRatio?: number; } /** @@ -367,6 +371,17 @@ export default class TestDataFlagBuilder { return flagRuleBuilder.andNotMatch(contextKind, attribute, ...values); } + checkRatio(ratio: number): TestDataFlagBuilder { + this.data.migration = this.data.migration ?? {}; + this.data.migration.checkRatio = ratio; + return this; + } + + samplingRatio(ratio: number): TestDataFlagBuilder { + this.data.samplingRatio = ratio; + return this; + } + /** * @internal */ @@ -390,6 +405,8 @@ export default class TestDataFlagBuilder { variation: this.data.fallthroughVariation, }, variations: [...this.data.variations], + migration: this.data.migration, + samplingRatio: this.data.samplingRatio, }; if (this.data.targetsByVariation) { diff --git a/packages/store/node-server-sdk-dynamodb/README.md b/packages/store/node-server-sdk-dynamodb/README.md index 59f9c6917..07aef68e0 100644 --- a/packages/store/node-server-sdk-dynamodb/README.md +++ b/packages/store/node-server-sdk-dynamodb/README.md @@ -52,7 +52,9 @@ const client = LaunchDarkly.init('YOUR SDK KEY', config); By default, the DynamoDB client will try to get your AWS credentials and region name from environment variables and/or local configuration files, as described in the AWS SDK documentation. You can also specify any valid [DynamoDB client options](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) like this: ```typescript -const dynamoDBOptions = { credentials: { accessKeyId: 'YOUR KEY', secretAccessKey: 'YOUR SECRET' }}; +const dynamoDBOptions = { + credentials: { accessKeyId: 'YOUR KEY', secretAccessKey: 'YOUR SECRET' }, +}; const store = DynamoDBFeatureStore('YOUR TABLE NAME', { clientOptions: dynamoDBOptions }); ``` From ab61937db4d0e7b9e7ccabd2ff375f3d3982e58a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 23 Aug 2023 09:29:17 -0700 Subject: [PATCH 24/57] chore: Add execution order support for contract tests. (#247) --- contract-tests/sdkClientEntity.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index b60cf1d9b..d5c91e630 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -1,5 +1,6 @@ import got from 'got'; import ld, { + LDConcurrentExecution, LDExecution, LDExecutionOrdering, LDMigrationError, @@ -62,6 +63,23 @@ export function makeSdkConfig(options, tag) { return cf; } +function getExecution(order) { + switch(order) { + case 'serial': { + return new LDSerialExecution(LDExecutionOrdering.Fixed); + } + case 'random': { + return new LDSerialExecution(LDExecutionOrdering.Random); + } + case 'concurrent': { + return new LDConcurrentExecution(); + } + default: { + throw new Error('Unsupported execution order.'); + } + } +} + export async function newSdkClientEntity(options) { const c = {}; const log = Log(options.tag); @@ -145,8 +163,10 @@ export async function newSdkClientEntity(options) { case 'migrationOperation': const migrationOperation = params.migrationOperation; + const readExecutionOrder = migrationOperation.readExecutionOrder; + const migration = new Migration(client, { - execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + execution: getExecution(readExecutionOrder), latencyTracking: migrationOperation.trackLatency, errorTracking: migrationOperation.trackErrors, check: migrationOperation.trackConsistency ? (a, b) => a === b : undefined, From bd956971e4b6896168dbca67614ca419da3f5435 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 23 Aug 2023 12:27:00 -0700 Subject: [PATCH 25/57] feat: Change migration variation to support forwarding the sampling ratio. (#248) Co-authored-by: Yusinto Ngadiman --- .../__tests__/MigrationOpEvent.test.ts | 3 ++- .../shared/sdk-server/src/LDClientImpl.ts | 21 ++++++------------- .../src/MigrationOpEventConversion.ts | 9 +++++++- .../sdk-server/src/MigrationOpTracker.ts | 2 ++ .../src/api/data/LDMigrationDetail.ts | 5 +++++ .../src/api/data/LDMigrationOpEvent.ts | 1 + 6 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts index fc2813eb0..d44b0fbe1 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts @@ -478,7 +478,7 @@ it('ignores invalid measurement keys', () => { ], }; const validatedEvent = MigrationOpEventConversion(inputEvent); - expect(validatedEvent).toEqual({ ...inputEvent, measurements: [] }); + expect(validatedEvent).toEqual({ ...inputEvent, measurements: [], samplingRatio: 1 }); }); it('invalid data types are filtered', () => { @@ -534,5 +534,6 @@ it('invalid data types are filtered', () => { }, }, measurements: [], + samplingRatio: 1, }); }); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index f0f945377..1ccff6083 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -318,6 +318,7 @@ export default class LDClientImpl implements LDClient { const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; const checkRatio = flag?.migration?.checkRatio; + const samplingRatio = flag?.samplingRatio; if (!IsMigrationStage(detail.value)) { const error = new Error(`Unrecognized MigrationState for "${key}"; returning default value.`); this.onError(error); @@ -336,6 +337,8 @@ export default class LDClientImpl implements LDClient { defaultValue, reason, checkRatio, + undefined, + samplingRatio, ), }; } @@ -352,6 +355,7 @@ export default class LDClientImpl implements LDClient { checkRatio, // Can be null for compatibility reasons. detail.variationIndex === null ? undefined : detail.variationIndex, + samplingRatio, ), }; } @@ -477,21 +481,8 @@ export default class LDClientImpl implements LDClient { if (!converted) { return; } - // Async immediately invoking function expression to get the flag from the store - // without requiring track to be async. - (async () => { - // TODO: Would it be better to pass this through. - const sampling = await this.featureStore.get( - VersionedDataKinds.Features, - event.evaluation.key, - ); - const samplingRatio = (sampling as Flag)?.samplingRatio ?? 1; - const inputEvent: internal.InputMigrationEvent = { - ...converted, - samplingRatio, - }; - this.eventProcessor.sendEvent(inputEvent); - })(); + + this.eventProcessor.sendEvent(converted); } identify(context: LDContext): void { diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index 8fcc50d59..e03b3f2cd 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -186,7 +186,7 @@ function validateEvaluation(evaluation: LDMigrationEvaluation): LDMigrationEvalu */ export default function MigrationOpEventToInputEvent( inEvent: LDMigrationOpEvent, -): Omit | undefined { +): internal.InputMigrationEvent | undefined { // The sampling ratio is omitted and needs populated by the track migration method. if (inEvent.kind !== 'migration_op') { return undefined; @@ -208,6 +208,12 @@ export default function MigrationOpEventToInputEvent( return undefined; } + const samplingRatio = inEvent.samplingRatio ?? 1; + + if (!TypeValidators.Number.is(samplingRatio)) { + return undefined; + } + if ( !Object.values(inEvent.contextKeys).every( (value) => TypeValidators.String.is(value) && value !== '', @@ -229,5 +235,6 @@ export default function MigrationOpEventToInputEvent( contextKeys: { ...inEvent.contextKeys }, measurements: validateMeasurements(inEvent.measurements), evaluation, + samplingRatio, }; } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index e49a8a8d1..17a5a21cd 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -37,6 +37,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { private readonly reason: LDEvaluationReason, private readonly checkRatio?: number, private readonly variation?: number, + private readonly samplingRatio?: number, ) {} op(op: LDMigrationOp) { @@ -76,6 +77,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { variation: this.variation, }, measurements, + samplingRatio: this.samplingRatio ?? 1, }; } return undefined; diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts index 6e095430c..2eed11f1e 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -87,4 +87,9 @@ export interface LDMigrationDetail { * 0 indicates that it never should be sampled. */ checkRatio?: number; + + /** + * Sampling ratio for the migration event. Defaults to 1 if not specified. + */ + samplingRatio?: number; } diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index c964caa97..109724f49 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -59,4 +59,5 @@ export interface LDMigrationOpEvent { contextKeys: Record; evaluation: LDMigrationEvaluation; measurements: LDMigrationMeasurement[]; + samplingRatio: number; } From ee623819665f0814cc238833aa4cbaaf3f445f36 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Aug 2023 15:46:09 -0700 Subject: [PATCH 26/57] feat: New data kinds for edge SDKs. (#260) --- package.json | 2 +- packages/sdk/akamai-base/example/package.json | 2 +- packages/sdk/akamai-base/package.json | 2 +- packages/sdk/akamai-edgekv/example/package.json | 2 +- packages/sdk/akamai-edgekv/package.json | 2 +- packages/sdk/cloudflare/example/package.json | 2 +- packages/sdk/cloudflare/package.json | 2 +- packages/sdk/server-node/package.json | 2 +- .../sdk/vercel/examples/complete/package.json | 2 +- packages/sdk/vercel/package.json | 2 +- .../shared/akamai-edgeworker-sdk/package.json | 2 +- .../src/featureStore/index.ts | 14 +++++++++++++- packages/shared/common/package.json | 2 +- packages/shared/sdk-server-edge/package.json | 2 +- .../sdk-server-edge/src/api/EdgeFeatureStore.ts | 16 ++++++++++++++-- packages/shared/sdk-server/package.json | 2 +- .../store/node-server-sdk-dynamodb/package.json | 2 +- .../store/node-server-sdk-redis/package.json | 2 +- 18 files changed, 43 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 723a19e2e..b82fdbb61 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" }, "packageManager": "yarn@3.4.1" } diff --git a/packages/sdk/akamai-base/example/package.json b/packages/sdk/akamai-base/example/package.json index f4475c014..3b32e538a 100644 --- a/packages/sdk/akamai-base/example/package.json +++ b/packages/sdk/akamai-base/example/package.json @@ -29,7 +29,7 @@ "rollup-plugin-copy-assets": "^2.0.3", "rollup-plugin-node-polyfills": "^0.2.1", "tslib": "^2.5.2", - "typescript": "^5.0.4" + "typescript": "5.1.6" }, "dependencies": { "@launchdarkly/akamai-server-base-sdk": "^1.0.0" diff --git a/packages/sdk/akamai-base/package.json b/packages/sdk/akamai-base/package.json index a7dabf27f..dfa7a96f3 100644 --- a/packages/sdk/akamai-base/package.json +++ b/packages/sdk/akamai-base/package.json @@ -70,7 +70,7 @@ "rollup-plugin-generate-package-json": "^3.2.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" }, "dependencies": { "@launchdarkly/akamai-edgeworker-sdk-common": "^0.3.0", diff --git a/packages/sdk/akamai-edgekv/example/package.json b/packages/sdk/akamai-edgekv/example/package.json index 8d7dcc279..d73791719 100644 --- a/packages/sdk/akamai-edgekv/example/package.json +++ b/packages/sdk/akamai-edgekv/example/package.json @@ -28,7 +28,7 @@ "rollup-plugin-copy-assets": "^2.0.3", "rollup-plugin-node-polyfills": "^0.2.1", "tslib": "^2.5.2", - "typescript": "^5.0.4" + "typescript": "5.1.6" }, "dependencies": { "@launchdarkly/akamai-server-edgekv-sdk": "^1.0.0" diff --git a/packages/sdk/akamai-edgekv/package.json b/packages/sdk/akamai-edgekv/package.json index c7f10ea62..b99f767a2 100644 --- a/packages/sdk/akamai-edgekv/package.json +++ b/packages/sdk/akamai-edgekv/package.json @@ -70,7 +70,7 @@ "rollup-plugin-generate-package-json": "^3.2.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" }, "dependencies": { "@launchdarkly/akamai-edgeworker-sdk-common": "^0.3.0", diff --git a/packages/sdk/cloudflare/example/package.json b/packages/sdk/cloudflare/example/package.json index fb77ba4fd..fee105ba2 100644 --- a/packages/sdk/cloudflare/example/package.json +++ b/packages/sdk/cloudflare/example/package.json @@ -16,7 +16,7 @@ "miniflare": "^2.5.0", "prettier": "^2.6.2", "ts-jest": "^28.0.3", - "typescript": "^5.0.3", + "typescript": "5.1.6", "wrangler": "2.13.0" }, "scripts": { diff --git a/packages/sdk/cloudflare/package.json b/packages/sdk/cloudflare/package.json index 658ee0728..1aa3b2c44 100644 --- a/packages/sdk/cloudflare/package.json +++ b/packages/sdk/cloudflare/package.json @@ -61,6 +61,6 @@ "rimraf": "^5.0.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index f15f155d4..3436c5775 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -66,6 +66,6 @@ "prettier": "^3.0.0", "ts-jest": "^29.0.5", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/sdk/vercel/examples/complete/package.json b/packages/sdk/vercel/examples/complete/package.json index 2749a1ca5..4755f0b48 100644 --- a/packages/sdk/vercel/examples/complete/package.json +++ b/packages/sdk/vercel/examples/complete/package.json @@ -28,6 +28,6 @@ "postcss": "^8.4.21", "tailwindcss": "^3.2.7", "turbo": "^1.8.5", - "typescript": "^4.9.5" + "typescript": "5.1.6" } } diff --git a/packages/sdk/vercel/package.json b/packages/sdk/vercel/package.json index 24f571c6d..90010bedd 100644 --- a/packages/sdk/vercel/package.json +++ b/packages/sdk/vercel/package.json @@ -59,6 +59,6 @@ "rimraf": "^5.0.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/shared/akamai-edgeworker-sdk/package.json b/packages/shared/akamai-edgeworker-sdk/package.json index b605689c5..84c7b9104 100644 --- a/packages/shared/akamai-edgeworker-sdk/package.json +++ b/packages/shared/akamai-edgeworker-sdk/package.json @@ -52,7 +52,7 @@ "rollup-plugin-generate-package-json": "^3.2.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" }, "dependencies": { "@launchdarkly/js-server-sdk-common": "^1.1.0", diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index aea71e572..68b02ddef 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -59,8 +59,14 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments[dataKey]); break; + case 'configurationOverrides': + callback(item.configurationOverrides?.[dataKey] ?? null); + break; + case 'metrics': + callback(item.metrics?.[dataKey] ?? null); + break; default: - throw new Error(`Unsupported DataKind: ${namespace}`); + callback(null); } } catch (err) { this.logger.error(err); @@ -90,6 +96,12 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments); break; + case 'configurationOverrides': + callback(item.configurationOverrides || {}); + break; + case 'metrics': + callback(item.metrics || {}); + break; default: throw new Error(`Unsupported DataKind: ${namespace}`); } diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 7387d9359..7170bfca4 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -43,6 +43,6 @@ "prettier": "^3.0.0", "ts-jest": "^29.0.5", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/shared/sdk-server-edge/package.json b/packages/shared/sdk-server-edge/package.json index acb02a812..f3d9aacc5 100644 --- a/packages/shared/sdk-server-edge/package.json +++ b/packages/shared/sdk-server-edge/package.json @@ -60,6 +60,6 @@ "rimraf": "^5.0.0", "ts-jest": "^29.1.0", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts index d2054d01c..8cdd897db 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts +++ b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts @@ -52,8 +52,14 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments[dataKey]); break; + case 'configurationOverrides': + callback(item.configurationOverrides?.[dataKey] ?? null); + break; + case 'metrics': + callback(item.metrics?.[dataKey] ?? null); + break; default: - throw new Error(`Unsupported DataKind: ${namespace}`); + callback(null); } } catch (err) { this.logger.error(err); @@ -83,8 +89,14 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments); break; + case 'configurationOverrides': + callback(item.configurationOverrides || {}); + break; + case 'metrics': + callback(item.metrics || {}); + break; default: - throw new Error(`Unsupported DataKind: ${namespace}`); + callback({}); } } catch (err) { this.logger.error(err); diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index 6459812f8..ead88d758 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -49,6 +49,6 @@ "prettier": "^3.0.0", "ts-jest": "^29.0.5", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/store/node-server-sdk-dynamodb/package.json b/packages/store/node-server-sdk-dynamodb/package.json index 0808125e2..56e8733fc 100644 --- a/packages/store/node-server-sdk-dynamodb/package.json +++ b/packages/store/node-server-sdk-dynamodb/package.json @@ -52,6 +52,6 @@ "prettier": "^3.0.0", "ts-jest": "^29.0.5", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index e641c6620..b8f81b41c 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -50,6 +50,6 @@ "prettier": "^3.0.0", "ts-jest": "^29.0.5", "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typescript": "5.1.6" } } From ee4ebbfb301535c60f85c0a50a04fb3da5cfb9b5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:40:58 -0700 Subject: [PATCH 27/57] feat: Add invoked measurement. (#258) Co-authored-by: Yusinto Ngadiman --- contract-tests/index.js | 1 + contract-tests/sdkClientEntity.js | 4 +- .../__tests__/MigrationOpEvent.test.ts | 198 +++++++++++------- .../__tests__/MigrationOpTracker.test.ts | 64 +++++- packages/shared/sdk-server/src/Migration.ts | 1 + .../src/MigrationOpEventConversion.ts | 29 ++- .../sdk-server/src/MigrationOpTracker.ts | 142 ++++++++++--- .../src/api/data/LDMigrationDetail.ts | 10 + .../src/api/data/LDMigrationOpEvent.ts | 11 +- 9 files changed, 355 insertions(+), 105 deletions(-) diff --git a/contract-tests/index.js b/contract-tests/index.js index fe9fecc47..fc1e51aea 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -29,6 +29,7 @@ app.get('/', (req, res) => { 'big-segments', 'user-type', 'migrations', + 'event-sampling', ], }); }); diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index d5c91e630..376a6f810 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -64,7 +64,7 @@ export function makeSdkConfig(options, tag) { } function getExecution(order) { - switch(order) { + switch (order) { case 'serial': { return new LDSerialExecution(LDExecutionOrdering.Fixed); } @@ -164,7 +164,7 @@ export async function newSdkClientEntity(options) { case 'migrationOperation': const migrationOperation = params.migrationOperation; const readExecutionOrder = migrationOperation.readExecutionOrder; - + const migration = new Migration(client, { execution: getExecution(readExecutionOrder), latencyTracking: migrationOperation.trackLatency, diff --git a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts index d44b0fbe1..c7dc7a4b7 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts @@ -91,9 +91,9 @@ describe('given an LDClient with test data', () => { // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(migrationEvent.measurements[0].key).toEqual('consistent'); + expect(migrationEvent.measurements[1].key).toEqual('consistent'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].value).toEqual(true); + expect(migrationEvent.measurements[1].value).toEqual(true); }, ); @@ -110,9 +110,9 @@ describe('given an LDClient with test data', () => { // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(migrationEvent.measurements[0].key).toEqual('consistent'); + expect(migrationEvent.measurements[1].key).toEqual('consistent'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].value).toEqual(true); + expect(migrationEvent.measurements[1].value).toEqual(true); expect(internal.shouldSample).toHaveBeenCalledWith(10); }, ); @@ -130,7 +130,7 @@ describe('given an LDClient with test data', () => { // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(migrationEvent.measurements.length).toEqual(0); + expect(migrationEvent.measurements.length).toEqual(1); expect(internal.shouldSample).toHaveBeenCalledWith(12); }, ); @@ -163,9 +163,9 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('consistent'); + expect(migrationEvent.measurements[1].key).toEqual('consistent'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].value).toEqual(false); + expect(migrationEvent.measurements[1].value).toEqual(false); }, ); }); @@ -191,6 +191,44 @@ describe('given an LDClient with test data', () => { }); }); + it.each([ + [LDMigrationStage.Off, { old: true }], + [LDMigrationStage.DualWrite, { old: true }], + [LDMigrationStage.Shadow, { old: true, new: true }], + [LDMigrationStage.RampDown, { new: true }], + [LDMigrationStage.Complete, { new: true }], + ])('tracks the invoked methods for reads', async (stage, values) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.read(flagKey, { key: 'test' }, stage); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('invoked'); + expect(migrationEvent.measurements[0].values).toEqual(values); + }); + + it.each([ + [LDMigrationStage.Off, { old: true }], + [LDMigrationStage.DualWrite, { old: true, new: true }], + [LDMigrationStage.Shadow, { old: true, new: true }], + [LDMigrationStage.RampDown, { old: true, new: true }], + [LDMigrationStage.Complete, { new: true }], + ])('tracks the invoked methods for writes', async (stage, values) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('invoked'); + expect(migrationEvent.measurements[0].values).toEqual(values); + }); + it.each([LDMigrationStage.Shadow, LDMigrationStage.Live])( 'can report read latency for new and old', async (stage) => { @@ -202,10 +240,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.new).toBeGreaterThanOrEqual(1); }, ); @@ -220,10 +258,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.new).toBeUndefined(); + expect(migrationEvent.measurements[1].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.new).toBeUndefined(); }, ); @@ -238,10 +276,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.old).toBeUndefined(); + expect(migrationEvent.measurements[1].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.old).toBeUndefined(); }, ); @@ -254,10 +292,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.new).toBeUndefined(); + expect(migrationEvent.measurements[1].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.new).toBeUndefined(); }); it.each([LDMigrationStage.Complete])( @@ -271,10 +309,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.old).toBeUndefined(); + expect(migrationEvent.measurements[1].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.old).toBeUndefined(); }, ); @@ -289,10 +327,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.new).toBeGreaterThanOrEqual(1); }, ); @@ -305,10 +343,10 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent.measurements[0].key).toEqual('latency_ms'); + expect(migrationEvent.measurements[1].key).toEqual('latency_ms'); // This isn't a precise check, but we should have non-zero values. - expect(migrationEvent.measurements[0].values.old).toBeGreaterThanOrEqual(1); - expect(migrationEvent.measurements[0].values.new).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.old).toBeGreaterThanOrEqual(1); + expect(migrationEvent.measurements[1].values.new).toBeGreaterThanOrEqual(1); }); }); @@ -338,15 +376,11 @@ describe('given an LDClient with test data', () => { // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(migrationEvent).toMatchObject({ - measurements: [ - { - key: 'error', - values: { - old: true, - }, - }, - ], + expect(migrationEvent.measurements).toContainEqual({ + key: 'error', + values: { + old: true, + }, }); }, ); @@ -362,15 +396,11 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent).toMatchObject({ - measurements: [ - { - key: 'error', - values: { - new: true, - }, - }, - ], + expect(migrationEvent.measurements).toContainEqual({ + key: 'error', + values: { + new: true, + }, }); }, ); @@ -387,16 +417,12 @@ describe('given an LDClient with test data', () => { // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(migrationEvent).toMatchObject({ - measurements: [ - { - key: 'error', - values: { - old: true, - new: true, - }, - }, - ], + expect(migrationEvent.measurements).toContainEqual({ + key: 'error', + values: { + old: true, + new: true, + }, }); }, ); @@ -412,19 +438,47 @@ describe('given an LDClient with test data', () => { await events.take(); // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; - expect(migrationEvent).toMatchObject({ - measurements: [ - { - key: 'error', - values: { - old: true, - }, - }, - ], + expect(migrationEvent.measurements).toContainEqual({ + key: 'error', + values: { + old: true, + }, }); }, ); + it.each([LDMigrationStage.Off, LDMigrationStage.DualWrite, LDMigrationStage.Shadow])( + 'it does not invoke non-authoritative write after an error with authoritative old', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('invoked'); + expect(migrationEvent.measurements[0].values).toEqual({ old: true }); + }, + ); + + it.each([LDMigrationStage.Live, LDMigrationStage.RampDown, LDMigrationStage.Complete])( + 'it does not invoke non-authoritative write after an error with authoritative new', + async (stage) => { + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + await migration.write(flagKey, { key: 'test' }, stage); + // Feature event. + await events.take(); + // Migration event. + const migrationEvent = (await events.take()) as internal.InputMigrationEvent; + expect(migrationEvent.measurements[0].key).toEqual('invoked'); + expect(migrationEvent.measurements[0].values).toEqual({ new: true }); + }, + ); + it.each([LDMigrationStage.Live, LDMigrationStage.RampDown, LDMigrationStage.Complete])( 'can report errors for new writes: %p', async (stage) => { @@ -437,15 +491,11 @@ describe('given an LDClient with test data', () => { // Migration event. const migrationEvent = (await events.take()) as internal.InputMigrationEvent; // Only check the measurements component of the event. - expect(migrationEvent).toMatchObject({ - measurements: [ - { - key: 'error', - values: { - new: true, - }, - }, - ], + expect(migrationEvent.measurements).toContainEqual({ + key: 'error', + values: { + new: true, + }, }); }, ); diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 139606540..64df78f3d 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -1,4 +1,5 @@ import { LDConsistencyCheck, LDMigrationStage } from '../src'; +import { LDMigrationOrigin } from '../src/api/LDMigration'; import MigrationOpTracker from '../src/MigrationOpTracker'; it('does not generate an event if an op is not set', () => { @@ -38,12 +39,20 @@ it('generates an event if the minimal requirements are met.', () => { ); tracker.op('write'); + tracker.invoked('old'); expect(tracker.createEvent()).toMatchObject({ contextKeys: { user: 'bob' }, evaluation: { default: 'off', key: 'flag', reason: { kind: 'FALLTHROUGH' }, value: 'off' }, kind: 'migration_op', - measurements: [], + measurements: [ + { + key: 'invoked', + values: { + old: true, + }, + }, + ], operation: 'write', }); }); @@ -60,6 +69,8 @@ it('includes errors if at least one is set', () => { ); tracker.op('read'); tracker.error('old'); + tracker.invoked('old'); + tracker.invoked('new'); const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ @@ -80,6 +91,8 @@ it('includes errors if at least one is set', () => { ); trackerB.op('read'); trackerB.error('new'); + trackerB.invoked('old'); + trackerB.invoked('new'); const eventB = trackerB.createEvent(); expect(eventB?.measurements).toContainEqual({ @@ -102,6 +115,8 @@ it('includes latency if at least one measurement exists', () => { ); tracker.op('read'); tracker.latency('old', 100); + tracker.invoked('old'); + tracker.invoked('new'); const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ @@ -122,6 +137,8 @@ it('includes latency if at least one measurement exists', () => { ); trackerB.op('read'); trackerB.latency('new', 150); + trackerB.invoked('old'); + trackerB.invoked('new'); const eventB = trackerB.createEvent(); expect(eventB?.measurements).toContainEqual({ @@ -144,6 +161,8 @@ it('includes if the result was consistent', () => { ); tracker.op('read'); tracker.consistency(LDConsistencyCheck.Consistent); + tracker.invoked('old'); + tracker.invoked('new'); const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ @@ -164,6 +183,8 @@ it('includes if the result was inconsistent', () => { }, ); tracker.op('read'); + tracker.invoked('old'); + tracker.invoked('new'); tracker.consistency(LDConsistencyCheck.Inconsistent); const event = tracker.createEvent(); @@ -173,3 +194,44 @@ it('includes if the result was inconsistent', () => { samplingRatio: 1, }); }); + +it.each(['old', 'new'])('includes which single origins were invoked', (origin) => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + tracker.invoked(origin as LDMigrationOrigin); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'invoked', + values: { [origin]: true }, + }); +}); + +it('includes when both origins were invoked', () => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + tracker.op('read'); + tracker.invoked('old'); + tracker.invoked('new'); + + const event = tracker.createEvent(); + expect(event?.measurements).toContainEqual({ + key: 'invoked', + values: { old: true, new: true }, + }); +}); diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index f715e5fb8..293278493 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -302,6 +302,7 @@ export default class Migration< origin: LDMigrationOrigin, method: (payload?: TInput) => Promise>, ): Promise> { + context.tracker.invoked(origin); const res = await this.trackLatency(context.tracker, origin, () => safeCall(() => method(context.payload)), ); diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index e03b3f2cd..f18348c30 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -4,6 +4,7 @@ import { LDMigrationConsistencyMeasurement, LDMigrationErrorMeasurement, LDMigrationEvaluation, + LDMigrationInvokedMeasurement, LDMigrationLatencyMeasurement, LDMigrationMeasurement, LDMigrationOp, @@ -21,17 +22,23 @@ function isOperation(value: LDMigrationOp) { function isLatencyMeasurement( value: LDMigrationMeasurement, ): value is LDMigrationLatencyMeasurement { - return (value as any).kind === undefined && value.key === 'latency_ms'; + return value.key === 'latency_ms'; } function isErrorMeasurement(value: LDMigrationMeasurement): value is LDMigrationErrorMeasurement { - return (value as any).kind === undefined && value.key === 'error'; + return value.key === 'error'; +} + +function isInvokedMeasurement( + value: LDMigrationMeasurement, +): value is LDMigrationInvokedMeasurement { + return value.key === 'invoked'; } function isConsistencyMeasurement( value: LDMigrationMeasurement, ): value is LDMigrationConsistencyMeasurement { - return (value as any).kind === undefined && value.key === 'consistent'; + return value.key === 'consistent'; } function areValidNumbers(values: { old?: number; new?: number }) { @@ -114,6 +121,22 @@ function validateMeasurement( }; } + if (isInvokedMeasurement(measurement)) { + if (!TypeValidators.Object.is(measurement.values)) { + return undefined; + } + if (!areValidBooleans(measurement.values)) { + return undefined; + } + return { + key: measurement.key, + values: { + old: measurement.values.old, + new: measurement.values.new, + }, + }; + } + // Not a supported measurement type. return undefined; } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index 17a5a21cd..4de63abd9 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -1,9 +1,10 @@ -import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; +import { LDEvaluationReason, LDLogger } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { LDConsistencyCheck, LDMigrationErrorMeasurement, + LDMigrationInvokedMeasurement, LDMigrationMeasurement, LDMigrationOp, LDMigrationOpEvent, @@ -20,6 +21,11 @@ export default class MigrationOpTracker implements LDMigrationTracker { new: false, }; + private wasInvoked = { + old: false, + new: false, + }; + private consistencyCheck?: LDConsistencyCheck; private latencyMeasurement = { @@ -38,6 +44,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { private readonly checkRatio?: number, private readonly variation?: number, private readonly samplingRatio?: number, + private readonly logger?: LDLogger, ) {} op(op: LDMigrationOp) { @@ -56,31 +63,118 @@ export default class MigrationOpTracker implements LDMigrationTracker { this.latencyMeasurement[origin] = value; } + invoked(origin: LDMigrationOrigin) { + this.wasInvoked[origin] = true; + } + createEvent(): LDMigrationOpEvent | undefined { - if (this.operation && Object.keys(this.contextKeys).length) { - const measurements: LDMigrationMeasurement[] = []; - - this.populateConsistency(measurements); - this.populateLatency(measurements); - this.populateErrors(measurements); - - return { - kind: 'migration_op', - operation: this.operation, - creationDate: Date.now(), - contextKeys: this.contextKeys, - evaluation: { - key: this.flagKey, - value: this.stage, - default: this.defaultStage, - reason: this.reason, - variation: this.variation, - }, - measurements, - samplingRatio: this.samplingRatio ?? 1, - }; + if (!this.operation) { + this.logger?.error('The operation must be set using "op" before an event can be created.'); + return undefined; + } + + if (Object.keys(this.contextKeys).length === 0) { + this.logger?.error( + 'The migration was not done against a valid context and cannot generate an event.', + ); + return undefined; + } + + if (!this.wasInvoked.old && !this.wasInvoked.new) { + this.logger?.error( + 'The migration invoked neither the "old" or "new" implementation and' + + 'an event cannot be generated', + ); + return undefined; + } + + const measurements: LDMigrationMeasurement[] = []; + + this.populateInvoked(measurements); + this.populateConsistency(measurements); + this.populateLatency(measurements); + this.populateErrors(measurements); + this.measurementConsistencyCheck(); + + return { + kind: 'migration_op', + operation: this.operation, + creationDate: Date.now(), + contextKeys: this.contextKeys, + evaluation: { + key: this.flagKey, + value: this.stage, + default: this.defaultStage, + reason: this.reason, + variation: this.variation, + }, + measurements, + samplingRatio: this.samplingRatio ?? 1, + }; + } + + private logTag() { + return `For migration ${this.operation}-${this.flagKey}:`; + } + + private latencyConsistencyMessage(origin: LDMigrationOrigin) { + return `Latency measurement for "${origin}", but "new" was not invoked.`; + } + + private errorConsistencyMessage(origin: LDMigrationOrigin) { + return `Error occurred for "${origin}", but "new" was not invoked.`; + } + + private consistencyCheckConsistencyMessage(origin: LDMigrationOrigin) { + return ( + `Consistency check was done, but "${origin}" was not invoked.` + + 'Both "old" and "new" must be invoked to do a consistency check.' + ); + } + + private checkOriginEventConsistency(origin: LDMigrationOrigin) { + if (this.wasInvoked[origin]) { + return; + } + + // If the specific origin was not invoked, but it contains measurements, then + // that is a problem. Check each measurement and log a message if it is present. + if (!Number.isNaN(this.latencyMeasurement[origin])) { + this.logger?.error(`${this.logTag()} ${this.latencyConsistencyMessage(origin)}`); + } + + if (this.errors[origin]) { + this.logger?.error(`${this.logTag()} ${this.errorConsistencyMessage(origin)}`); + } + + if (this.consistencyCheck !== LDConsistencyCheck.NotChecked) { + this.logger?.error(`${this.logTag()} ${this.consistencyCheckConsistencyMessage(origin)}`); + } + } + + /** + * Check that the latency, error, consistency and invoked measurements are self-consistent. + */ + private measurementConsistencyCheck() { + this.checkOriginEventConsistency('old'); + this.checkOriginEventConsistency('new'); + } + + private populateInvoked(measurements: LDMigrationMeasurement[]) { + const measurement: LDMigrationInvokedMeasurement = { + key: 'invoked', + values: {}, + }; + if (!this.wasInvoked.old && !this.wasInvoked.new) { + this.logger?.error('Migration op completed without executing any origins (old/new).'); + } + if (this.wasInvoked.old) { + measurement.values.old = true; + } + if (this.wasInvoked.new) { + measurement.values.new = true; } - return undefined; + measurements.push(measurement); } private populateConsistency(measurements: LDMigrationMeasurement[]) { diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts index 2eed11f1e..74682fba6 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -40,6 +40,16 @@ export interface LDMigrationTracker { */ consistency(result: LDConsistencyCheck): void; + /** + * Call this to report that an origin was invoked (executed). There are some situations where the + * expectation is that both the old and new implementation will be used, but with writes + * it is possible that the non-authoritative will not execute. Reporting the execution allows + * for more accurate analytics. + * + * @param origin The origin that was invoked. + */ + invoked(origin: LDMigrationOrigin): void; + /** * Report the latency of an operation. * diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index 109724f49..c3e69d702 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -38,13 +38,22 @@ export interface LDMigrationErrorMeasurement { }; } +export interface LDMigrationInvokedMeasurement { + key: 'invoked'; + values: { + old?: boolean; + new?: boolean; + }; +} + /** * Types of measurements supported by an LDMigrationOpEvent. */ export type LDMigrationMeasurement = | LDMigrationLatencyMeasurement | LDMigrationErrorMeasurement - | LDMigrationConsistencyMeasurement; + | LDMigrationConsistencyMeasurement + | LDMigrationInvokedMeasurement; /** * Event used to track information about a migration operation. From 760567d9ba6b89ef97383b186ccdeb68a0a8699f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:47:30 -0700 Subject: [PATCH 28/57] chore: Support migration payload contract tests. (#262) --- contract-tests/sdkClientEntity.js | 37 ++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index 376a6f810..3830730d4 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -80,6 +80,13 @@ function getExecution(order) { } } +function makeMigrationPostOptions(payload) { + if (payload) { + return { body: payload }; + } + return {}; +} + export async function newSdkClientEntity(options) { const c = {}; const log = Log(options.tag); @@ -170,33 +177,45 @@ export async function newSdkClientEntity(options) { latencyTracking: migrationOperation.trackLatency, errorTracking: migrationOperation.trackErrors, check: migrationOperation.trackConsistency ? (a, b) => a === b : undefined, - readNew: async () => { + readNew: async (payload) => { try { - const res = await got.post(migrationOperation.newEndpoint, {}); + const res = await got.post( + migrationOperation.newEndpoint, + makeMigrationPostOptions(payload), + ); return LDMigrationSuccess(res.body); } catch (err) { return LDMigrationError(err.message); } }, - writeNew: async () => { + writeNew: async (payload) => { try { - const res = await got.post(migrationOperation.newEndpoint, {}); + const res = await got.post( + migrationOperation.newEndpoint, + makeMigrationPostOptions(payload), + ); return LDMigrationSuccess(res.body); } catch (err) { return LDMigrationError(err.message); } }, - readOld: async () => { + readOld: async (payload) => { try { - const res = await got.post(migrationOperation.oldEndpoint, {}); + const res = await got.post( + migrationOperation.oldEndpoint, + makeMigrationPostOptions(payload), + ); return LDMigrationSuccess(res.body); } catch (err) { return LDMigrationError(err.message); } }, - writeOld: async () => { + writeOld: async (payload) => { try { - const res = await got.post(migrationOperation.oldEndpoint, {}); + const res = await got.post( + migrationOperation.oldEndpoint, + makeMigrationPostOptions(payload), + ); return LDMigrationSuccess(res.body); } catch (err) { return LDMigrationError(err.message); @@ -210,6 +229,7 @@ export async function newSdkClientEntity(options) { migrationOperation.key, migrationOperation.context, migrationOperation.defaultStage, + migrationOperation.payload, ); if (res.success) { return { result: res.result }; @@ -222,6 +242,7 @@ export async function newSdkClientEntity(options) { migrationOperation.key, migrationOperation.context, migrationOperation.defaultStage, + migrationOperation.payload, ); if (res.authoritative.success) { From e12068a48c376a2bbf553709085c8784e303dee3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 30 Aug 2023 15:32:15 -0700 Subject: [PATCH 29/57] chore: Add initial documentation. (#263) --- packages/shared/sdk-server/src/Migration.ts | 35 +++++++++++++ .../shared/sdk-server/src/api/LDClient.ts | 7 +-- .../shared/sdk-server/src/api/LDMigration.ts | 8 ++- .../src/api/data/LDMigrationDetail.ts | 2 - .../src/api/data/LDMigrationStage.ts | 34 ++++++++++++ .../src/api/options/LDMigrationOptions.ts | 52 +++++++++++++++++-- 6 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index 293278493..49457d2c7 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -39,6 +39,22 @@ async function safeCall( } } +/** + * Report a successful migration operation from `readNew`, `readOld`, `writeNew` or `writeOld`. + * + * ``` + * readNew: async () => { + * const myResult = doMyOldRead(); + * if(myResult.wasGood) { + * return LDMigrationSuccess(myResult); + * } + * return LDMigrationError(myResult.error) + * } + * ``` + * + * @param result The result of the operation. + * @returns An {@link LDMethodResult} + */ export function LDMigrationSuccess(result: TResult): LDMethodResult { return { success: true, @@ -46,6 +62,22 @@ export function LDMigrationSuccess(result: TResult): LDMethodResult { + * const myResult = doMyOldRead(); + * if(myResult.wasGood) { + * return LDMigrationSuccess(myResult); + * } + * return LDMigrationError(myResult.error) + * } + * ``` + * + * @param result The result of the operations. + * @returns An {@link LDMethodResult} + */ export function LDMigrationError(error: Error): { success: false; error: Error } { return { success: false, @@ -59,6 +91,9 @@ interface MigrationContext { checkRatio?: number; } +/** + * Class which allows performing technology migrations. + */ export default class Migration< TMigrationRead, TMigrationWrite, diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index e459de633..5b6e95407 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -123,10 +123,11 @@ export interface LDClient { ): Promise; /** - * TKTK: Should use a common description. + * Returns the migration stage of the migration feature flag for the given + * evaluation context. * - * If the evaluated value of the flag cannot be converted to an LDMigrationStage, then an error - * event will be raised. + * If the evaluated value of the flag cannot be converted to an LDMigrationStage, then the default + * value will be returned and error will be logged. * * @param key The unique key of the feature flag. * @param context The context requesting the flag. The client will generate an analytics event to diff --git a/packages/shared/sdk-server/src/api/LDMigration.ts b/packages/shared/sdk-server/src/api/LDMigration.ts index b19085a62..ff8bc3db5 100644 --- a/packages/shared/sdk-server/src/api/LDMigration.ts +++ b/packages/shared/sdk-server/src/api/LDMigration.ts @@ -51,9 +51,7 @@ export type LDMigrationWriteResult = { }; /** - * Interface for a migration. - * - * TKTK + * Interface representing a migration. */ export interface LDMigration< TMigrationRead, @@ -62,7 +60,7 @@ export interface LDMigration< TMigrationWriteInput, > { /** - * TKTK + * Perform a read using the migration. * * @param key The key of the flag controlling the migration. * @param context The context requesting the flag. The client will generate an analytics event to @@ -78,7 +76,7 @@ export interface LDMigration< ): Promise>; /** - * TKTK + * Perform a write using the migration. * * @param key The key of the flag controlling the migration. * @param context The context requesting the flag. The client will generate an analytics event to diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts index 74682fba6..9b80c55e5 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts @@ -15,8 +15,6 @@ export enum LDConsistencyCheck { /** * Used to track information related to a migration operation. - * - * TKTK */ export interface LDMigrationTracker { /** diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts index b262ddce7..f99c0bcfb 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts @@ -1,12 +1,46 @@ +/** + * Stage denotes one of six possible stages a technology migration could be a + * part of, progressing through the following order. + * + * Off -> DualWrite -> Shadow -> Live -> RampDown -> Complete + */ export enum LDMigrationStage { + /** + * Off - migration hasn't started, "old" is authoritative for reads and writes + */ Off = 'off', + + /** + * DualWrite - write to both "old" and "new", "old" is authoritative for reads + */ DualWrite = 'dualwrite', + + /** + * Shadow - both "new" and "old" versions run with a preference for "old" + */ Shadow = 'shadow', + + /** + * Live - both "new" and "old" versions run with a preference for "new" + */ Live = 'live', + + /** + * RampDown - only read from "new", write to "old" and "new" + */ RampDown = 'rampdown', + + /** + * Complete - migration is done + */ Complete = 'complete', } +/** + * Check if the given string is a migration stage. + * @param value The string to check. + * @returns True if the string is a migration stage. + */ export function IsMigrationStage(value: string): boolean { return Object.values(LDMigrationStage).includes(value as LDMigrationStage); } diff --git a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts index 086a63985..361469be1 100644 --- a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts @@ -90,27 +90,69 @@ export interface LDMigrationOptions< errorTracking?: boolean; /** - * TKTK + * Implementation which provides a read from the "new" source. + * + * Users are required to provide two different read methods -- one to read from the old migration source, and one to + * read from the new source. Additionally, customers can opt-in to consistency tracking by providing a `check` + * function. + * + * Depending on the migration stage, one or both of these read methods may be called. + * + * Throwing an exception from this method will be treated as an error. + * + * @param payload An optional payload. The payload is provided when calling the `read` method on the migration. + * @returns The result of the operation. Use {@link LDMigrationSuccess} or {@link LDMigrationError} to create a suitable return value. */ readNew: (payload?: TMigrationReadInput) => Promise>; /** - * TKTK + * Implementation which provides a write to the "new" source. + * + * Users are required to provide two different write methods -- one to write to the old migration source, and one to + * write to the new source. Not every stage requires + * + * + * Depending on the migration stage, one or both of these write methods may be called. + * + * Throwing an exception from this method will be treated as an error. + * + * @param payload An optional payload. The payload is provided when calling the `read` method on the migration. + * @returns The result of the operation. Use {@link LDMigrationSuccess} or {@link LDMigrationError} to create a suitable return value. */ writeNew: (payload?: TMigrationWriteInput) => Promise>; /** - * TKTK + * Implementation which provides a read from the "old" source. + * + * Users are required to provide two different read methods -- one to read from the old migration source, and one to + * read from the new source. Additionally, customers can opt-in to consistency tracking by providing a `check` + * function. + * + * Depending on the migration stage, one or both of these read methods may be called. + * + * Throwing an exception from this method will be treated as an error. + * */ readOld: (payload?: TMigrationReadInput) => Promise>; /** - * TKTK + * Implementation which provides a write to the "old" source. + * + * Users are required to provide two different write methods -- one to write to the old migration source, and one to + * write to the new source. Not every stage requires + * + * Depending on the migration stage, one or both of these write methods may be called. + * + * Throwing an exception from this method will be treated as an error. + * + * @param payload An optional payload. The payload is provided when calling the `read` method on the migration. + * @returns The result of the operation. Use {@link LDMigrationSuccess} or {@link LDMigrationError} to create a suitable return value. */ writeOld: (payload?: TMigrationWriteInput) => Promise>; /** - * TKTK + * Method used to do consistency checks for read operations. After a read operation, during which both data sources + * are read from, a check of read consistency may be done using this method. */ check?: (a: TMigrationRead, b: TMigrationRead) => boolean; } From f7ed7eb021997e8705998753ae9dc135f6e1f0ee Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:11:46 -0700 Subject: [PATCH 30/57] fix sampling --- .../shared/sdk-server/src/LDClientImpl.ts | 36 +++++++++++++++---- .../sdk-server/src/events/EventFactory.ts | 19 ++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index b09fee69b..bf0b602a7 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -550,6 +550,17 @@ export default class LDClientImpl implements LDClient { ); this.onError(error); const result = EvalResult.forError(ErrorKinds.FlagNotFound, undefined, defaultValue); + (async () => { + const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); + this.eventProcessor.sendEvent( + this.eventFactoryDefault.unknownFlagEvent( + flagKey, + evalContext, + result.detail, + indexSamplingRatio, + ), + ); + })(); this.eventProcessor.sendEvent( this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), ); @@ -567,12 +578,25 @@ export default class LDClientImpl implements LDClient { this.logger?.debug('Result value is null in variation'); evalRes.setDefault(defaultValue); } - evalRes.events?.forEach((event) => { - this.eventProcessor.sendEvent(event); - }); - this.eventProcessor.sendEvent( - eventFactory.evalEvent(flag, evalContext, evalRes.detail, defaultValue), - ); + + // Immediately invoked function expression to get the event out of the callback + // path and allow access to async methods. + (async () => { + const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); + evalRes.events?.forEach((event) => { + this.eventProcessor.sendEvent({ ...event, indexSamplingRatio }); + }); + this.eventProcessor.sendEvent( + eventFactory.evalEvent( + flag, + evalContext, + evalRes.detail, + defaultValue, + undefined, + indexSamplingRatio, + ), + ); + })(); cb(evalRes, flag); }, eventFactory, diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index 6524e5496..5e2c7f674 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -37,8 +37,23 @@ export default class EventFactory { ); } - unknownFlagEvent(key: string, context: Context, detail: LDEvaluationDetail) { - return new internal.InputEvalEvent(this.withReasons, context, key, detail.value, detail); + unknownFlagEvent( + key: string, + context: Context, + detail: LDEvaluationDetail, + indexEventSamplingRatio?: number, + ) { + return new internal.InputEvalEvent( + this.withReasons, + context, + key, + detail.value, + detail, + undefined, + undefined, + undefined, + indexEventSamplingRatio, + ); } /* eslint-disable-next-line class-methods-use-this */ From 10c4875947a0f4fc87888d2356e28d54bcad1705 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:22:55 -0700 Subject: [PATCH 31/57] fix merging event sampling --- packages/shared/sdk-server/src/LDClientImpl.ts | 3 --- .../shared/sdk-server/src/events/EventFactory.ts | 13 ++++++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index bf0b602a7..50f7a5573 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -561,9 +561,6 @@ export default class LDClientImpl implements LDClient { ), ); })(); - this.eventProcessor.sendEvent( - this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), - ); cb(result); return; } diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index 5e2c7f674..a996c1b5e 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -49,9 +49,16 @@ export default class EventFactory { key, detail.value, detail, - undefined, - undefined, - undefined, + // This isn't ideal, but the purpose of the factory is to at least + // handle this situation. + undefined, // version + undefined, // variation index + undefined, // track events + undefined, // prereqOf + undefined, // reason + undefined, // debugEventsUntilDate + undefined, // exclude from summaries + undefined, // sampling ratio indexEventSamplingRatio, ); } From 7c08e8285fd4a8c354845d20e4fd4b3c0fe95dd3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:34:36 -0700 Subject: [PATCH 32/57] feat: Refactor variation method and consistency tracking. (#264) --- .../__tests__/MigrationOpTracker.test.ts | 6 +-- .../shared/sdk-server/src/LDClientImpl.ts | 10 ++-- packages/shared/sdk-server/src/Migration.ts | 22 ++++----- .../sdk-server/src/MigrationOpTracker.ts | 9 ++-- .../shared/sdk-server/src/api/LDClient.ts | 6 +-- ...ationDetail.ts => LDMigrationVariation.ts} | 49 +++++++------------ .../shared/sdk-server/src/api/data/index.ts | 2 +- 7 files changed, 42 insertions(+), 62 deletions(-) rename packages/shared/sdk-server/src/api/data/{LDMigrationDetail.ts => LDMigrationVariation.ts} (61%) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 64df78f3d..57b153627 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -1,4 +1,4 @@ -import { LDConsistencyCheck, LDMigrationStage } from '../src'; +import { LDMigrationStage } from '../src'; import { LDMigrationOrigin } from '../src/api/LDMigration'; import MigrationOpTracker from '../src/MigrationOpTracker'; @@ -160,7 +160,7 @@ it('includes if the result was consistent', () => { }, ); tracker.op('read'); - tracker.consistency(LDConsistencyCheck.Consistent); + tracker.consistency(() => true); tracker.invoked('old'); tracker.invoked('new'); @@ -185,7 +185,7 @@ it('includes if the result was inconsistent', () => { tracker.op('read'); tracker.invoked('old'); tracker.invoked('new'); - tracker.consistency(LDConsistencyCheck.Inconsistent); + tracker.consistency(() => false); const event = tracker.createEvent(); expect(event?.measurements).toContainEqual({ diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 1ccff6083..3a5913d34 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -17,9 +17,9 @@ import { LDClient, LDFlagsState, LDFlagsStateOptions, - LDMigrationDetail, LDMigrationOpEvent, LDMigrationStage, + LDMigrationVariation, LDOptions, LDStreamProcessor, } from './api'; @@ -307,13 +307,13 @@ export default class LDClientImpl implements LDClient { key: string, context: LDContext, defaultValue: LDMigrationStage, - ): Promise { + ): Promise { const convertedContext = Context.fromLDContext(context); const [{ detail }, flag] = await this.evaluateIfPossible( key, context, defaultValue, - this.eventFactoryWithReasons, + this.eventFactoryDefault, ); const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; @@ -328,8 +328,6 @@ export default class LDClientImpl implements LDClient { }; return { value: defaultValue, - reason, - checkRatio, tracker: new MigrationOpTracker( key, contextKeys, @@ -343,9 +341,7 @@ export default class LDClientImpl implements LDClient { }; } return { - ...detail, value: detail.value as LDMigrationStage, - checkRatio, tracker: new MigrationOpTracker( key, contextKeys, diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index 49457d2c7..a645a7e3e 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -1,6 +1,6 @@ -import { internal, LDContext } from '@launchdarkly/js-sdk-common'; +import { LDContext } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDConsistencyCheck, LDMigrationStage, LDMigrationTracker } from './api'; +import { LDClient, LDMigrationStage, LDMigrationTracker } from './api'; import { LDMigration, LDMigrationOrigin, @@ -17,8 +17,6 @@ import { LDSerialExecution, } from './api/options/LDMigrationOptions'; -const { shouldSample } = internal; - type MultipleReadResult = { fromOld: LDMigrationReadResult; fromNew: LDMigrationReadResult; @@ -88,7 +86,6 @@ export function LDMigrationError(error: Error): { success: false; error: Error } interface MigrationContext { payload?: TPayload; tracker: LDMigrationTracker; - checkRatio?: number; } /** @@ -239,7 +236,6 @@ export default class Migration< const res = await this.readTable[stage.value]({ payload, tracker: stage.tracker, - checkRatio: stage.checkRatio, }); stage.tracker.op('read'); this.sendEvent(stage.tracker); @@ -274,13 +270,13 @@ export default class Migration< oldValue: LDMethodResult, newValue: LDMethodResult, ) { - if (this.config.check && shouldSample(context.checkRatio ?? 1)) { - if (oldValue.success && newValue.success) { - const res = this.config.check(oldValue.result, newValue.result); - context.tracker.consistency( - res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent, - ); - } + if (!this.config.check) { + return; + } + + if (oldValue.success && newValue.success) { + // Check is validated before this point, so it is force unwrapped. + context.tracker.consistency(() => this.config.check!(oldValue.result, newValue.result)); } } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index 4de63abd9..f88c28493 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -1,4 +1,4 @@ -import { LDEvaluationReason, LDLogger } from '@launchdarkly/js-sdk-common'; +import { internal, LDEvaluationReason, LDLogger } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { @@ -55,8 +55,11 @@ export default class MigrationOpTracker implements LDMigrationTracker { this.errors[origin] = true; } - consistency(result: LDConsistencyCheck) { - this.consistencyCheck = result; + consistency(check: () => boolean) { + if (internal.shouldSample(this.checkRatio ?? 1)) { + const res = check(); + this.consistencyCheck = res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent; + } } latency(origin: LDMigrationOrigin, value: number) { diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index 5b6e95407..5dfa78873 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -1,6 +1,6 @@ import { LDContext, LDEvaluationDetail, LDFlagValue } from '@launchdarkly/js-sdk-common'; -import { LDMigrationDetail, LDMigrationOpEvent } from './data'; +import { LDMigrationOpEvent, LDMigrationVariation } from './data'; import { LDFlagsState } from './data/LDFlagsState'; import { LDFlagsStateOptions } from './data/LDFlagsStateOptions'; import { LDMigrationStage } from './data/LDMigrationStage'; @@ -135,13 +135,13 @@ export interface LDClient { * @param defaultValue The default value of the flag, to be used if the value is not available * from LaunchDarkly. * @returns - * A Promise which will be resolved with the result (as an{@link LDMigrationDetail}). + * A Promise which will be resolved with the result (as an{@link LDMigrationVariation}). */ variationMigration( key: string, context: LDContext, defaultValue: LDMigrationStage, - ): Promise; + ): Promise; /** * Builds an object that encapsulates the state of all feature flags for a given context. diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts b/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts similarity index 61% rename from packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts rename to packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts index 9b80c55e5..5c6c8f85d 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationDetail.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts @@ -1,5 +1,3 @@ -import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; - import { LDMigrationOrigin } from '../LDMigration'; import { LDMigrationOp, LDMigrationOpEvent } from './LDMigrationOpEvent'; import { LDMigrationStage } from './LDMigrationStage'; @@ -32,11 +30,21 @@ export interface LDMigrationTracker { error(origin: LDMigrationOrigin): void; /** - * Report the result of a consistency check. + * Check the consistency of a read result. This method should be invoked if the `check` function + * is defined for the migration and both reads ("new"/"old") were done. + * + * The function will use the checkRatio to determine if the check should be executed, and it + * will record the result. * - * @param result The result of the check. + * Example calling the check function from the migration config. + * ``` + * context.tracker.consistency(() => config.check!(oldValue.result, newValue.result)); + * ``` + * + * @param check The function which executes the check. This is not the `check` function from the + * migration options, but instead should be a parameter-less function that calls that function. */ - consistency(result: LDConsistencyCheck): void; + consistency(check: () => boolean): void; /** * Call this to report that an origin was invoked (executed). There are some situations where the @@ -64,40 +72,17 @@ export interface LDMigrationTracker { } /** - * Detailed information about a migration variation. + * Migration value and tracker. */ -export interface LDMigrationDetail { +export interface LDMigrationVariation { /** * The result of the flag evaluation. This will be either one of the flag's variations or - * the default value that was passed to `LDClient.variationDetail`. + * the default value that was passed to `LDClient.variationMigration`. */ value: LDMigrationStage; /** - * The index of the returned value within the flag's list of variations, e.g. 0 for the - * first variation-- or `null` if the default value was returned. - */ - variationIndex?: number | null; - - /** - * An object describing the main factor that influenced the flag evaluation value. - */ - reason: LDEvaluationReason; - - /** - * A tracker which which can be used to generate analytics for the migration. + * A tracker which can be used to generate analytics for the migration. */ tracker: LDMigrationTracker; - - /** - * When present represents a 1 in X ratio indicating the probability that a given operation - * should have its consistency checked. A 1 indicates that it should always be sampled and - * 0 indicates that it never should be sampled. - */ - checkRatio?: number; - - /** - * Sampling ratio for the migration event. Defaults to 1 if not specified. - */ - samplingRatio?: number; } diff --git a/packages/shared/sdk-server/src/api/data/index.ts b/packages/shared/sdk-server/src/api/data/index.ts index 0aeca0e14..0ce283a47 100644 --- a/packages/shared/sdk-server/src/api/data/index.ts +++ b/packages/shared/sdk-server/src/api/data/index.ts @@ -1,5 +1,5 @@ export * from './LDFlagsStateOptions'; export * from './LDFlagsState'; export * from './LDMigrationStage'; -export * from './LDMigrationDetail'; export * from './LDMigrationOpEvent'; +export * from './LDMigrationVariation'; From cadc90b6a9f6f8f8c944a9c0b99932e99c2a4e43 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 5 Sep 2023 09:53:55 -0700 Subject: [PATCH 33/57] fix: Fix double call when platform support performance API. (#268) --- packages/shared/sdk-server/src/Migration.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index a645a7e3e..e6552ee51 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -359,10 +359,11 @@ export default class Migration< start = performance.now(); result = await method(); end = performance.now(); + } else { + start = Date.now(); + result = await method(); + end = Date.now(); } - start = Date.now(); - result = await method(); - end = Date.now(); // Performance timer is in ms, but may have a microsecond resolution // fractional component. From 11179ba659dea3b956f6c3889250ed2a8694925e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 11 Sep 2023 14:28:20 -0700 Subject: [PATCH 34/57] fix: Fix log messages for failed migration creation. (#274) --- packages/shared/sdk-server/src/MigrationOpTracker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index f88c28493..e7cf7637a 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -121,11 +121,11 @@ export default class MigrationOpTracker implements LDMigrationTracker { } private latencyConsistencyMessage(origin: LDMigrationOrigin) { - return `Latency measurement for "${origin}", but "new" was not invoked.`; + return `Latency measurement for "${origin}", but "${origin}" was not invoked.`; } private errorConsistencyMessage(origin: LDMigrationOrigin) { - return `Error occurred for "${origin}", but "new" was not invoked.`; + return `Error occurred for "${origin}", but "${origin}" was not invoked.`; } private consistencyCheckConsistencyMessage(origin: LDMigrationOrigin) { From 5ff85e405516cfdcc00f8fb90fe7c0a65e2a0ca9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 13 Sep 2023 11:33:21 -0700 Subject: [PATCH 35/57] chore: Use new granular categories for event sampling. (#277) --- contract-tests/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contract-tests/index.js b/contract-tests/index.js index fc1e51aea..6a5b72809 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -30,6 +30,8 @@ app.get('/', (req, res) => { 'user-type', 'migrations', 'event-sampling', + 'config-override-kind', + 'metric-kind' ], }); }); From d1f71976bfadeb41865663426c0cb781f6adac44 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Sep 2023 10:11:22 -0700 Subject: [PATCH 36/57] fix: Handle exceptions thrown in the comparison function. (#278) --- .../__tests__/MigrationOpTracker.test.ts | 30 +++++++++++++++++++ .../sdk-server/src/MigrationOpTracker.ts | 14 +++++++-- .../src/api/data/LDMigrationVariation.ts | 3 ++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 57b153627..1cb0dd280 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -1,6 +1,7 @@ import { LDMigrationStage } from '../src'; import { LDMigrationOrigin } from '../src/api/LDMigration'; import MigrationOpTracker from '../src/MigrationOpTracker'; +import TestLogger, { LogLevel } from './Logger'; it('does not generate an event if an op is not set', () => { const tracker = new MigrationOpTracker( @@ -235,3 +236,32 @@ it('includes when both origins were invoked', () => { values: { old: true, new: true }, }); }); + +it('can handle exceptions thrown in the consistency check method', () => { + const logger = new TestLogger(); + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + undefined, + undefined, + undefined, + logger, + ); + tracker.op('read'); + tracker.invoked('old'); + tracker.invoked('new'); + tracker.consistency(() => { + throw new Error('I HAVE FAILED'); + }); + logger.expectMessages([ + { + level: LogLevel.Error, + matches: /.*migration 'flag'.*Error: I HAVE FAILED/, + }, + ]); +}); diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index e7cf7637a..795572c00 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -57,8 +57,18 @@ export default class MigrationOpTracker implements LDMigrationTracker { consistency(check: () => boolean) { if (internal.shouldSample(this.checkRatio ?? 1)) { - const res = check(); - this.consistencyCheck = res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent; + try { + const res = check(); + this.consistencyCheck = res + ? LDConsistencyCheck.Consistent + : LDConsistencyCheck.Inconsistent; + } catch (exception) { + this.logger?.error( + 'Exception when executing consistency check function for migration' + + ` '${this.flagKey}' the consistency check will not be included in the generated migration` + + ` op event. Exception: ${exception}`, + ); + } } } diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts b/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts index 5c6c8f85d..396c0012b 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts @@ -41,6 +41,9 @@ export interface LDMigrationTracker { * context.tracker.consistency(() => config.check!(oldValue.result, newValue.result)); * ``` * + * If the consistency check function throws an exception, then the consistency check result + * will not be included in the generated event. + * * @param check The function which executes the check. This is not the `check` function from the * migration options, but instead should be a parameter-less function that calls that function. */ From 5b1a4d30a28cf379cac5f4d0b2e73bae4bb1a5aa Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 19 Sep 2023 11:01:01 -0700 Subject: [PATCH 37/57] feat: reuse EventProcessor for dom (#228) Please don't be alarmed with 55 file changes. Most are just import path changes due to existing files being moved from sdk-server to shared/common to be re-used across server and client sdks: 1. Move EventSender and its tests 2. Move errors.ts 3. Move NullEventProcessor 4. Move defaultHeaders and httpErrorMessage to utils/http.ts In addition, please also note these important changes: 1. `EventSender` is now intrinsic to EventProcessor and is excluded from the EventProcessor constructor. 2. `contextDeduplicator` is now optional because client sdks don't use them 3. `defaultHeaders` arguments order have changed to `defaultHeaders(sdkKey, info, tags)` --------- Co-authored-by: LaunchDarklyReleaseBot --- package.json | 6 +- .../__tests__/LDClientNode.test.ts | 33 +- .../__tests__/LDClientNode.tls.test.ts | 4 + .../internal/events/EventProcessor.test.ts | 334 +++------ packages/shared/common/jest.config.js | 2 +- .../common/src/api/options/LDClientContext.ts | 16 +- .../common/src/api/platform/EventSource.ts | 9 +- .../shared/common/src/api/platform/Info.ts | 6 + .../src/api/subsystem}/LDStreamProcessor.ts | 2 +- .../shared/common/src/api/subsystem/index.ts | 2 + .../{sdk-server => common}/src/errors.ts | 7 + packages/shared/common/src/index.ts | 1 + .../diagnostics/DiagnosticsManager.test.ts | 112 +++ .../diagnostics/DiagnosticsManager.ts | 89 +++ .../common/src/internal/diagnostics/index.ts | 4 + .../common/src/internal/diagnostics/types.ts | 72 ++ .../src/internal/events/EventProcessor.ts | 48 +- .../src/internal/events/EventSender.test.ts | 214 ++++++ .../common/src/internal/events/EventSender.ts | 101 +++ .../src/internal/events/NullEventProcessor.ts | 11 + .../common/src/internal/events/index.ts | 10 +- packages/shared/common/src/internal/index.ts | 3 + .../src/internal/mocks/clientContext.ts | 14 + .../src/internal/mocks/contextDeduplicator.ts | 18 + .../src/internal}/mocks/hasher.ts | 3 +- .../shared/common/src/internal/mocks/index.ts | 15 + .../common/src/internal/mocks/logger.ts | 8 + .../src/internal}/mocks/platform.ts | 23 +- .../src/internal/mocks/streamingProcessor.ts | 33 + .../stream/StreamingProcessor.test.ts | 234 ++++++ .../src/internal/stream/StreamingProcessor.ts | 141 ++++ .../common/src/internal/stream/index.ts | 4 + .../common/src/internal/stream/types.ts | 3 + .../common/src/options/ClientContext.ts | 16 +- .../common/src/options/ServiceEndpoints.ts | 11 +- packages/shared/common/src/utils/date.ts | 4 + packages/shared/common/src/utils/http.test.ts | 119 +++ packages/shared/common/src/utils/http.ts | 48 ++ packages/shared/common/src/utils/index.ts | 6 +- packages/shared/common/src/utils/sleep.ts | 6 + packages/shared/common/tsconfig.json | 2 +- .../shared/sdk-client/src/LDClientDomImpl.ts | 31 +- .../shared/sdk-client/src/api/LDOptions.ts | 36 +- .../src/configuration/Configuration.test.ts | 15 +- .../src/configuration/Configuration.ts | 22 +- .../src/configuration/validators.ts | 8 +- .../createDiagnosticsInitConfig.test.ts | 61 ++ .../createDiagnosticsInitConfig.ts | 35 + .../diagnostics/createDiagnosticsManager.ts | 22 + .../src/events/createEventProcessor.ts | 21 + .../sdk-client/src/platform/PlatformDom.ts | 13 + packages/shared/sdk-client/tsconfig.json | 2 +- .../__tests__/LDClient.allFlags.test.ts | 9 +- .../__tests__/LDClient.evaluation.test.ts | 27 +- .../__tests__/LDClient.events.test.ts | 5 +- .../LDClientImpl.bigSegments.test.ts | 13 +- .../__tests__/LDClientImpl.listeners.test.ts | 7 +- .../sdk-server/__tests__/LDClientImpl.test.ts | 178 ++--- .../data_sources/FileDataSource.test.ts | 677 ++++++------------ .../data_sources/PollingProcessor.test.ts | 62 +- .../__tests__/data_sources/Requestor.test.ts | 6 +- .../data_sources/StreamingProcessor.test.ts | 416 +++++------ .../data_sources/defaultHeaders.test.ts | 61 -- .../__tests__/evaluation/Bucketer.test.ts | 19 +- .../evaluation/Evaluator.bucketing.test.ts | 7 +- .../evaluation/Evaluator.clause.test.ts | 7 +- .../evaluation/Evaluator.rules.test.ts | 7 +- .../evaluation/Evaluator.segments.test.ts | 64 +- .../__tests__/evaluation/Evaluator.test.ts | 9 +- .../events/DiagnosticsManager.test.ts | 319 --------- .../__tests__/events/EventProcessor.test.ts | 37 +- .../__tests__/events/EventSender.test.ts | 177 ----- .../integrations/test_data/TestData.test.ts | 239 ++++--- packages/shared/sdk-server/jest.config.js | 2 +- .../shared/sdk-server/src/LDClientImpl.ts | 133 ++-- packages/shared/sdk-server/src/api/index.ts | 1 - .../sdk-server/src/api/options/LDOptions.ts | 9 +- .../sdk-server/src/api/subsystems/index.ts | 1 - .../src/data_sources/FileDataSource.ts | 33 +- .../src/data_sources/NullUpdateProcessor.ts | 21 - .../src/data_sources/PollingProcessor.ts | 37 +- .../sdk-server/src/data_sources/Requestor.ts | 13 +- .../src/data_sources/StreamingProcessor.ts | 199 ----- .../createStreamListeners.test.ts | 186 +++++ .../src/data_sources/createStreamListeners.ts | 95 +++ .../src/data_sources/defaultHeaders.ts | 30 - .../src/data_sources/httpErrorMessage.ts | 17 - .../createDiagnosticsInitConfig.test.ts | 116 +++ .../createDiagnosticsInitConfig.ts | 37 + .../src/events/DiagnosticsManager.ts | 199 ----- .../sdk-server/src/events/EventSender.ts | 123 ---- .../src/events/NullEventProcessor.ts | 22 - .../src/integrations/FileDataSourceFactory.ts | 28 +- .../src/integrations/test_data/TestData.ts | 22 +- .../integrations/test_data/TestDataSource.ts | 32 +- .../sdk-server/src/options/Configuration.ts | 16 +- .../src/options/ValidatedOptions.ts | 12 +- .../src/store/VersionedDataKinds.ts | 6 + .../sdk-server/src/store/serialization.ts | 8 +- packages/shared/sdk-server/tsconfig.json | 2 +- 100 files changed, 3116 insertions(+), 2690 deletions(-) rename packages/shared/{sdk-server/src/api/subsystems => common/src/api/subsystem}/LDStreamProcessor.ts (83%) rename packages/shared/{sdk-server => common}/src/errors.ts (86%) create mode 100644 packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts create mode 100644 packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts create mode 100644 packages/shared/common/src/internal/diagnostics/index.ts create mode 100644 packages/shared/common/src/internal/diagnostics/types.ts create mode 100644 packages/shared/common/src/internal/events/EventSender.test.ts create mode 100644 packages/shared/common/src/internal/events/EventSender.ts create mode 100644 packages/shared/common/src/internal/events/NullEventProcessor.ts create mode 100644 packages/shared/common/src/internal/mocks/clientContext.ts create mode 100644 packages/shared/common/src/internal/mocks/contextDeduplicator.ts rename packages/shared/{sdk-server/__tests__/evaluation => common/src/internal}/mocks/hasher.ts (85%) create mode 100644 packages/shared/common/src/internal/mocks/index.ts create mode 100644 packages/shared/common/src/internal/mocks/logger.ts rename packages/shared/{sdk-server/__tests__/evaluation => common/src/internal}/mocks/platform.ts (67%) create mode 100644 packages/shared/common/src/internal/mocks/streamingProcessor.ts create mode 100644 packages/shared/common/src/internal/stream/StreamingProcessor.test.ts create mode 100644 packages/shared/common/src/internal/stream/StreamingProcessor.ts create mode 100644 packages/shared/common/src/internal/stream/index.ts create mode 100644 packages/shared/common/src/internal/stream/types.ts create mode 100644 packages/shared/common/src/utils/date.ts create mode 100644 packages/shared/common/src/utils/http.test.ts create mode 100644 packages/shared/common/src/utils/http.ts create mode 100644 packages/shared/common/src/utils/sleep.ts create mode 100644 packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts create mode 100644 packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts create mode 100644 packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts create mode 100644 packages/shared/sdk-client/src/events/createEventProcessor.ts create mode 100644 packages/shared/sdk-client/src/platform/PlatformDom.ts delete mode 100644 packages/shared/sdk-server/__tests__/data_sources/defaultHeaders.test.ts delete mode 100644 packages/shared/sdk-server/__tests__/events/DiagnosticsManager.test.ts delete mode 100644 packages/shared/sdk-server/__tests__/events/EventSender.test.ts delete mode 100644 packages/shared/sdk-server/src/data_sources/NullUpdateProcessor.ts delete mode 100644 packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts create mode 100644 packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts create mode 100644 packages/shared/sdk-server/src/data_sources/createStreamListeners.ts delete mode 100644 packages/shared/sdk-server/src/data_sources/defaultHeaders.ts delete mode 100644 packages/shared/sdk-server/src/data_sources/httpErrorMessage.ts create mode 100644 packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts create mode 100644 packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts delete mode 100644 packages/shared/sdk-server/src/events/DiagnosticsManager.ts delete mode 100644 packages/shared/sdk-server/src/events/EventSender.ts delete mode 100644 packages/shared/sdk-server/src/events/NullEventProcessor.ts diff --git a/package.json b/package.json index a8aba41c6..ac9088ac2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "clean": "yarn workspaces foreach -pt run clean", "build": "yarn workspaces foreach -p --topological-dev run build", "//": "When using build:doc you need to specify the workspace. 'yarn run build:doc packages/shared/common' for example.", - "build:doc": "./scripts/build-doc.sh $1", + "build:doc": "npx typedoc --options $1/typedoc.json", "lint": "npx eslint . --ext .ts", "lint:fix": "yarn run lint -- --fix", "test": "echo Please run tests for individual packages.", @@ -44,8 +44,8 @@ "eslint-plugin-import": "^2.27.5", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", - "typedoc": "0.23.26", - "typescript": "^5.1.6" + "typedoc": "0.25.0", + "typescript": "5.1.6" }, "packageManager": "yarn@3.4.1" } diff --git a/packages/sdk/server-node/__tests__/LDClientNode.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.test.ts index 1e0dd0b52..0d4d44ae7 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.test.ts @@ -1,7 +1,9 @@ -import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { internal, LDContext } from '@launchdarkly/js-server-sdk-common'; import { init } from '../src'; +const { mocks } = internal; + it('fires ready event in offline mode', (done) => { const client = init('sdk_key', { offline: true }); client.on('ready', () => { @@ -10,23 +12,24 @@ it('fires ready event in offline mode', (done) => { }); }); -it('fires the failed event if initialization fails', (done) => { +it('fires the failed event if initialization fails', async () => { + jest.useFakeTimers(); + + const failedHandler = jest.fn().mockName('failedHandler'); const client = init('sdk_key', { - updateProcessor: { - start: (fn: (err: any) => void) => { - setTimeout(() => { - fn(new Error('BAD THINGS')); - }, 0); + sendEvents: false, + logger: mocks.logger, + updateProcessor: (clientContext, dataSourceUpdates, initSuccessHandler, errorHandler) => ({ + start: () => { + setTimeout(() => errorHandler?.(new Error('Something unexpected happened')), 0); }, - stop: () => {}, - close: () => {}, - sendEvents: false, - }, - }); - client.on('failed', () => { - client.close(); - done(); + close: jest.fn(), + }), }); + client.on('failed', failedHandler); + jest.runAllTimers(); + + expect(failedHandler).toBeCalledWith(new Error('Something unexpected happened')); }); // These tests are done in the node implementation because common doesn't have a crypto diff --git a/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts index ba675a552..eae972033 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts @@ -6,9 +6,12 @@ import { TestHttpServer, } from 'launchdarkly-js-test-helpers'; +import { internal } from '@launchdarkly/js-server-sdk-common'; + import { basicLogger, LDClient, LDLogger } from '../src'; import LDClientNode from '../src/LDClientNode'; +const { mocks } = internal; describe('When using a TLS connection', () => { let client: LDClient; let server: TestHttpServer; @@ -87,6 +90,7 @@ describe('When using a TLS connection', () => { stream: false, tlsParams: { ca: server.certificate }, diagnosticOptOut: true, + logger: mocks.logger, }); await client.waitForInitialization(); diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index fc507d249..a6641378b 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -1,33 +1,21 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -/* eslint-disable class-methods-use-this */ - -/* eslint-disable max-classes-per-file */ -import { AsyncQueue } from 'launchdarkly-js-test-helpers'; - -import { - ClientContext, - Context, - EventSource, - EventSourceInitDict, - Hasher, - Hmac, - Options, - Platform, - PlatformData, - Response, - SdkData, - ServiceEndpoints, -} from '../../../src'; -import { - LDContextDeduplicator, - LDDeliveryStatus, - LDEventSender, - LDEventSenderResult, - LDEventType, -} from '../../../src/api/subsystem'; +import { Context } from '../../../src'; +import { LDDeliveryStatus, LDEventType } from '../../../src/api/subsystem'; import { EventProcessor, InputIdentifyEvent } from '../../../src/internal'; import { EventProcessorOptions } from '../../../src/internal/events/EventProcessor'; +import { clientContext } from '../../../src/internal/mocks'; +import ContextDeduplicator from '../../../src/internal/mocks/contextDeduplicator'; +import BasicLogger from '../../../src/logging/BasicLogger'; +import format from '../../../src/logging/format'; + +const mockSendEventData = jest.fn(); + +jest.useFakeTimers(); + +jest.mock('../../../src/internal/events/EventSender', () => ({ + default: jest.fn(() => ({ + sendEventData: mockSendEventData, + })), +})); const user = { key: 'userKey', name: 'Red' }; const userWithFilteredName = { @@ -39,6 +27,7 @@ const userWithFilteredName = { const anonUser = { key: 'anon-user', name: 'Anon', anonymous: true }; const filteredUser = { key: 'userKey', kind: 'user', _meta: { redactedAttributes: ['name'] } }; +const testIndexEvent = { context: { ...user, kind: 'user' }, creationDate: 1000, kind: 'index' }; function makeSummary(start: number, end: number, count: number, version: number): any { return { endDate: end, @@ -94,43 +83,10 @@ function makeFeatureEvent( }; } -class MockEventSender implements LDEventSender { - public queue: AsyncQueue<{ type: LDEventType; data: any }> = new AsyncQueue(); - - public results: LDEventSenderResult[] = []; - - public defaultResult: LDEventSenderResult = { - status: LDDeliveryStatus.Succeeded, - }; - - async sendEventData(type: LDEventType, data: any): Promise { - this.queue.add({ type, data }); - return this.results.length ? this.results.shift()! : this.defaultResult; - } -} - -class MockContextDeduplicator implements LDContextDeduplicator { - flushInterval?: number | undefined = 0.1; - - seen: string[] = []; - - processContext(context: Context): boolean { - if (this.seen.indexOf(context.canonicalKey) >= 0) { - return false; - } - this.seen.push(context.canonicalKey); - return true; - } - - flush(): void {} -} - describe('given an event processor', () => { + let contextDeduplicator: ContextDeduplicator; let eventProcessor: EventProcessor; - let eventSender: MockEventSender; - let contextDeduplicator: MockContextDeduplicator; - const eventProcessorConfig: EventProcessorOptions = { allAttributesPrivate: false, privateAttributes: [], @@ -139,69 +95,15 @@ describe('given an event processor', () => { diagnosticRecordingInterval: 900, }; - const basicConfiguration = { - offline: false, - serviceEndpoints: new ServiceEndpoints('', '', ''), - }; - - const platform: Platform = { - info: { - platformData(): PlatformData { - return { - os: { - name: 'An OS', - version: '1.0.1', - arch: 'An Arch', - }, - name: 'The SDK Name', - additional: { - nodeVersion: '42', - }, - }; - }, - sdkData(): SdkData { - return { - name: 'An SDK', - version: '2.0.2', - }; - }, - }, - crypto: { - createHash(algorithm: string): Hasher { - throw new Error('Function not implemented'); - }, - createHmac(algorithm: string, key: string): Hmac { - // Not used for this test. - throw new Error('Function not implemented.'); - }, - randomUUID(): string { - // Not used for this test. - throw new Error(`Function not implemented.`); - }, - }, - requests: { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - fetch(url: string, options?: Options): Promise { - throw new Error('Function not implemented.'); - }, - - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - throw new Error('Function not implemented.'); - }, - }, - }; - beforeEach(() => { - eventSender = new MockEventSender(); - contextDeduplicator = new MockContextDeduplicator(); - - eventProcessor = new EventProcessor( - eventProcessorConfig, - new ClientContext('sdk-key', basicConfiguration, platform), - eventSender, - contextDeduplicator, + jest.clearAllMocks(); + mockSendEventData.mockImplementation(() => + Promise.resolve({ + status: LDDeliveryStatus.Succeeded, + }), ); + contextDeduplicator = new ContextDeduplicator(); + eventProcessor = new EventProcessor(eventProcessorConfig, clientContext, contextDeduplicator); }); afterEach(() => { @@ -214,12 +116,13 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - - expect(request.data[0].context).toEqual({ ...user, kind: 'user' }); - expect(request.data[0].creationDate).toEqual(1000); - expect(request.data[0].kind).toEqual('identify'); - expect(request.type).toEqual(LDEventType.AnalyticsEvents); + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + { + context: { ...user, kind: 'user' }, + creationDate: 1000, + kind: 'identify', + }, + ]); }); it('filters user in identify event', async () => { @@ -228,11 +131,13 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data[0].context).toEqual({ ...filteredUser, kind: 'user' }); - expect(request.data[0].creationDate).toEqual(1000); - expect(request.data[0].kind).toEqual('identify'); - expect(request.type).toEqual(LDEventType.AnalyticsEvents); + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + { + context: { ...filteredUser, kind: 'user' }, + creationDate: 1000, + kind: 'identify', + }, + ]); }); it('stringifies user attributes in identify event', async () => { @@ -255,23 +160,26 @@ describe('given an event processor', () => { ); await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data[0].context).toEqual({ - kind: 'user', - key: '1', - ip: '3', - country: '4', - email: '5', - firstName: '6', - lastName: '7', - avatar: '8', - name: '9', - age: 99, - anonymous: false, - }); - expect(request.data[0].creationDate).toEqual(1000); - expect(request.data[0].kind).toEqual('identify'); - expect(request.type).toEqual(LDEventType.AnalyticsEvents); + + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + { + context: { + kind: 'user', + key: '1', + ip: '3', + country: '4', + email: '5', + firstName: '6', + lastName: '7', + avatar: '8', + name: '9', + age: 99, + anonymous: false, + }, + creationDate: 1000, + kind: 'identify', + }, + ]); }); it('queues individual feature event with index event', async () => { @@ -289,14 +197,9 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ - { - kind: 'index', - creationDate: 1000, - context: { ...user, kind: 'user' }, - }, + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + testIndexEvent, makeFeatureEvent(1000, 11), makeSummary(1000, 1000, 1, 11), ]); @@ -318,14 +221,8 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - - expect(request.data).toEqual([ - { - kind: 'index', - creationDate: 1000, - context: { ...user, kind: 'user' }, - }, + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + testIndexEvent, makeFeatureEvent(1000, 0), makeSummary(1000, 1000, 1, 0), ]); @@ -348,13 +245,8 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ - { - kind: 'index', - creationDate: 1000, - context: { ...user, kind: 'user' }, - }, + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + testIndexEvent, makeFeatureEvent(1000, 11, true), makeSummary(1000, 1000, 1, 11), ]); @@ -377,13 +269,8 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ - { - kind: 'index', - creationDate: 1000, - context: { ...user, kind: 'user' }, - }, + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ + testIndexEvent, makeFeatureEvent(1000, 11, false), makeFeatureEvent(1000, 11, true), makeSummary(1000, 1000, 1, 11), @@ -393,11 +280,6 @@ describe('given an event processor', () => { it('expires debug mode based on client time if client time is later than server time', async () => { Date.now = jest.fn(() => 2000); - eventSender.defaultResult = { - status: LDDeliveryStatus.Succeeded, - serverTime: new Date(1000).getTime(), - }; - eventProcessor.sendEvent({ kind: 'feature', creationDate: 1400, @@ -413,8 +295,7 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1400, @@ -452,9 +333,8 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, @@ -526,9 +406,7 @@ describe('given an event processor', () => { await eventProcessor.flush(); - const request = await eventSender.queue.take(); - - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, @@ -580,9 +458,8 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, @@ -610,9 +487,8 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, @@ -642,9 +518,8 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, @@ -664,15 +539,18 @@ describe('given an event processor', () => { }); it('makes no requests if there are no events to flush', async () => { - eventProcessor.flush(); - expect(eventSender.queue.isEmpty()).toBeTruthy(); + await eventProcessor.flush(); + expect(mockSendEventData).not.toBeCalled(); }); it('will not shutdown after a recoverable error', async () => { - eventSender.defaultResult = { - status: LDDeliveryStatus.Failed, - error: new Error('some error'), - }; + mockSendEventData.mockImplementation(() => + Promise.resolve({ + status: LDDeliveryStatus.Failed, + error: new Error('some error'), + }), + ); + eventProcessor.sendEvent(new InputIdentifyEvent(Context.fromLDContext(user))); await expect(eventProcessor.flush()).rejects.toThrow('some error'); @@ -681,10 +559,13 @@ describe('given an event processor', () => { }); it('will shutdown after a non-recoverable error', async () => { - eventSender.defaultResult = { - status: LDDeliveryStatus.FailedAndMustShutDown, - error: new Error('some error'), - }; + mockSendEventData.mockImplementation(() => + Promise.resolve({ + status: LDDeliveryStatus.FailedAndMustShutDown, + error: new Error('some error'), + }), + ); + eventProcessor.sendEvent(new InputIdentifyEvent(Context.fromLDContext(user))); await expect(eventProcessor.flush()).rejects.toThrow('some error'); @@ -693,24 +574,33 @@ describe('given an event processor', () => { }); it('swallows errors from failed background flush', async () => { - // Make a new client that flushes fast. - const newConfig = { ...eventProcessorConfig, flushInterval: 0.1 }; - - eventSender.defaultResult = { - status: LDDeliveryStatus.Failed, - error: new Error('some error'), - }; - - eventProcessor.close(); - + mockSendEventData.mockImplementation(() => + Promise.resolve({ + status: LDDeliveryStatus.Failed, + error: new Error('some error'), + }), + ); + const mockConsole = jest.fn(); + const clientContextWithDebug = { ...clientContext }; + clientContextWithDebug.basicConfiguration.logger = new BasicLogger({ + level: 'debug', + destination: mockConsole, + formatter: format, + }); eventProcessor = new EventProcessor( - newConfig, - new ClientContext('sdk-key', basicConfiguration, platform), - eventSender, + eventProcessorConfig, + clientContextWithDebug, contextDeduplicator, ); + eventProcessor.sendEvent(new InputIdentifyEvent(Context.fromLDContext(user))); + await jest.advanceTimersByTimeAsync(eventProcessorConfig.flushInterval * 1000); - eventSender.queue.take(); + expect(mockConsole).toBeCalledTimes(2); + expect(mockConsole).toHaveBeenNthCalledWith(1, 'debug: [LaunchDarkly] Flushing 1 events'); + expect(mockConsole).toHaveBeenNthCalledWith( + 2, + 'debug: [LaunchDarkly] Flush failed: Error: some error', + ); }); }); diff --git a/packages/shared/common/jest.config.js b/packages/shared/common/jest.config.js index f106eb3bc..6753062cc 100644 --- a/packages/shared/common/jest.config.js +++ b/packages/shared/common/jest.config.js @@ -1,6 +1,6 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, - testMatch: ['**/__tests__/**/*test.ts?(x)'], + testMatch: ['**/*.test.ts?(x)'], testEnvironment: 'node', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: ['src/**/*.ts'], diff --git a/packages/shared/common/src/api/options/LDClientContext.ts b/packages/shared/common/src/api/options/LDClientContext.ts index 49263cdcf..6fd96452c 100644 --- a/packages/shared/common/src/api/options/LDClientContext.ts +++ b/packages/shared/common/src/api/options/LDClientContext.ts @@ -12,13 +12,6 @@ export interface LDServiceEndpoints { * The most basic properties of the SDK client that are available to all SDK component factories. */ export interface LDBasicConfiguration { - logger?: LDLogger; - - /** - * True if the SDK was configured to be completely offline. - */ - offline: boolean; - /** * The configured SDK key. */ @@ -28,6 +21,15 @@ export interface LDBasicConfiguration { * Defines the base service URIs used by SDK components. */ serviceEndpoints: LDServiceEndpoints; + + /** + * True if the SDK was configured to be completely offline. + */ + offline?: boolean; + + logger?: LDLogger; + + tags?: { value?: string }; } /** diff --git a/packages/shared/common/src/api/platform/EventSource.ts b/packages/shared/common/src/api/platform/EventSource.ts index 0a651363e..3cf8c1dd1 100644 --- a/packages/shared/common/src/api/platform/EventSource.ts +++ b/packages/shared/common/src/api/platform/EventSource.ts @@ -1,10 +1,17 @@ +export type EventName = 'delete' | 'patch' | 'ping' | 'put'; +export type EventListener = (event?: { data?: any }) => void; +export type ProcessStreamResponse = { + deserializeData: (data: string) => any; + processJson: (json: any) => void; +}; + export interface EventSource { onclose: (() => void) | undefined; onerror: (() => void) | undefined; onopen: (() => void) | undefined; onretrying: ((e: { delayMillis: number }) => void) | undefined; - addEventListener(type: string, listener: (event?: { data?: any }) => void): void; + addEventListener(type: EventName, listener: EventListener): void; close(): void; } diff --git a/packages/shared/common/src/api/platform/Info.ts b/packages/shared/common/src/api/platform/Info.ts index 7f724826b..9b0bf40f6 100644 --- a/packages/shared/common/src/api/platform/Info.ts +++ b/packages/shared/common/src/api/platform/Info.ts @@ -44,6 +44,12 @@ export interface SdkData { */ version?: string; + /** + * If this is a top-level (not a wrapper) SDK this will be used to create the user agent string. + * It will take the form 'userAgentBase/version`. + */ + userAgentBase?: string; + /** * Name of the wrapper SDK if present. */ diff --git a/packages/shared/sdk-server/src/api/subsystems/LDStreamProcessor.ts b/packages/shared/common/src/api/subsystem/LDStreamProcessor.ts similarity index 83% rename from packages/shared/sdk-server/src/api/subsystems/LDStreamProcessor.ts rename to packages/shared/common/src/api/subsystem/LDStreamProcessor.ts index ab0f6162b..67a7fa559 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDStreamProcessor.ts +++ b/packages/shared/common/src/api/subsystem/LDStreamProcessor.ts @@ -6,7 +6,7 @@ * @ignore */ export interface LDStreamProcessor { - start: (fn?: (err?: any) => void) => void; + start: () => void; stop: () => void; close: () => void; } diff --git a/packages/shared/common/src/api/subsystem/index.ts b/packages/shared/common/src/api/subsystem/index.ts index 70a1777e9..000f60f68 100644 --- a/packages/shared/common/src/api/subsystem/index.ts +++ b/packages/shared/common/src/api/subsystem/index.ts @@ -1,6 +1,7 @@ import LDContextDeduplicator from './LDContextDeduplicator'; import LDEventProcessor from './LDEventProcessor'; import LDEventSender, { LDDeliveryStatus, LDEventSenderResult, LDEventType } from './LDEventSender'; +import { LDStreamProcessor } from './LDStreamProcessor'; export { LDEventProcessor, @@ -9,4 +10,5 @@ export { LDDeliveryStatus, LDEventType, LDEventSenderResult, + LDStreamProcessor, }; diff --git a/packages/shared/sdk-server/src/errors.ts b/packages/shared/common/src/errors.ts similarity index 86% rename from packages/shared/sdk-server/src/errors.ts rename to packages/shared/common/src/errors.ts index 180401a2d..4a805c875 100644 --- a/packages/shared/sdk-server/src/errors.ts +++ b/packages/shared/common/src/errors.ts @@ -2,6 +2,13 @@ // more complex, then they could be independent files. /* eslint-disable max-classes-per-file */ +export class LDFileDataSourceError extends Error { + constructor(message: string) { + super(message); + this.name = 'LaunchDarklyFileDataSourceError'; + } +} + export class LDPollingError extends Error { constructor(message: string) { super(message); diff --git a/packages/shared/common/src/index.ts b/packages/shared/common/src/index.ts index 53118276a..653cde18d 100644 --- a/packages/shared/common/src/index.ts +++ b/packages/shared/common/src/index.ts @@ -9,5 +9,6 @@ export * from './options'; export * from './utils'; export * as internal from './internal'; +export * from './errors'; export { AttributeReference, Context, ContextFilter }; diff --git a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts new file mode 100644 index 000000000..c0130433e --- /dev/null +++ b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts @@ -0,0 +1,112 @@ +import { basicPlatform } from '../mocks'; +import DiagnosticsManager from './DiagnosticsManager'; + +describe('given a diagnostics manager', () => { + const dateNowString = '2023-08-10'; + let manager: DiagnosticsManager; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(dateNowString)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + manager = new DiagnosticsManager('my-sdk-key', basicPlatform, { test1: 'value1' }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('uses the last 6 characters of the SDK key in the diagnostic id', () => { + const { id } = manager.createInitEvent(); + expect(id.sdkKeySuffix).toEqual('dk-key'); + }); + + it('creates random UUID', () => { + const { id } = manager.createInitEvent(); + const manager2 = new DiagnosticsManager('my-sdk-key', basicPlatform, {}); + const { id: id2 } = manager2.createInitEvent(); + + expect(id.diagnosticId).toBeTruthy(); + expect(id2.diagnosticId).toBeTruthy(); + expect(id.diagnosticId).not.toEqual(id2.diagnosticId); + }); + + it('puts the start time into the init event', () => { + const { creationDate } = manager.createInitEvent(); + expect(creationDate).toEqual(Date.now()); + }); + + it('puts SDK data into the init event', () => { + const { sdk } = manager.createInitEvent(); + expect(sdk).toMatchObject(basicPlatform.info.sdkData()); + }); + + it('puts config data into the init event', () => { + const { configuration } = manager.createInitEvent(); + expect(configuration).toEqual({ test1: 'value1' }); + }); + + it('puts platform data into the init event', () => { + const { platform } = manager.createInitEvent(); + expect(platform).toEqual({ + name: 'The SDK Name', + osName: 'An OS', + osVersion: '1.0.1', + osArch: 'An Arch', + nodeVersion: '42', + }); + }); + + it('creates periodic event from stats, then resets', () => { + const originalDate = Date.now(); + const streamInit1 = originalDate + 1; + const streamInit2 = originalDate + 2; + const statsCreation1 = originalDate + 3; + const statsCreation2 = originalDate + 4; + + manager.recordStreamInit(streamInit1, true, 1000); + manager.recordStreamInit(streamInit2, false, 550); + jest.setSystemTime(statsCreation1); + const statsEvent1 = manager.createStatsEventAndReset(4, 5, 6); + + expect(statsEvent1).toMatchObject({ + kind: 'diagnostic', + creationDate: statsCreation1, + dataSinceDate: originalDate, + droppedEvents: 4, + deduplicatedUsers: 5, + eventsInLastBatch: 6, + streamInits: [ + { + timestamp: streamInit1, + failed: true, + durationMillis: 1000, + }, + { + timestamp: streamInit2, + failed: false, + durationMillis: 550, + }, + ], + }); + + jest.setSystemTime(statsCreation2); + const statsEvent2 = manager.createStatsEventAndReset(1, 2, 3); + + expect(statsEvent2).toMatchObject({ + kind: 'diagnostic', + creationDate: statsCreation2, + dataSinceDate: statsCreation1, + droppedEvents: 1, + deduplicatedUsers: 2, + eventsInLastBatch: 3, + streamInits: [], + }); + }); +}); diff --git a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts new file mode 100644 index 000000000..4516b9f17 --- /dev/null +++ b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts @@ -0,0 +1,89 @@ +import { Platform } from '../../api'; +import { DiagnosticId, DiagnosticInitEvent, DiagnosticStatsEvent, StreamInitData } from './types'; + +export default class DiagnosticsManager { + private readonly startTime: number; + private streamInits: StreamInitData[] = []; + private readonly id: DiagnosticId; + private dataSinceDate: number; + + constructor( + sdkKey: string, + private readonly platform: Platform, + private readonly diagnosticInitConfig: any, + ) { + this.startTime = Date.now(); + this.dataSinceDate = this.startTime; + this.id = { + diagnosticId: platform.crypto.randomUUID(), + sdkKeySuffix: sdkKey.length > 6 ? sdkKey.substring(sdkKey.length - 6) : sdkKey, + }; + } + + /** + * Creates the initial event that is sent by the event processor when the SDK starts up. This will + * not be repeated during the lifetime of the SDK client. + */ + createInitEvent(): DiagnosticInitEvent { + const sdkData = this.platform.info.sdkData(); + const platformData = this.platform.info.platformData(); + + return { + kind: 'diagnostic-init', + id: this.id, + creationDate: this.startTime, + sdk: sdkData, + configuration: this.diagnosticInitConfig, + platform: { + name: platformData.name, + osArch: platformData.os?.arch, + osName: platformData.os?.name, + osVersion: platformData.os?.version, + ...(platformData.additional || {}), + }, + }; + } + + /** + * Records a stream connection attempt (called by the stream processor). + * + * @param timestamp Time of the *beginning* of the connection attempt. + * @param failed True if the connection failed, or we got a read timeout before receiving a "put". + * @param durationMillis Elapsed time between starting timestamp and when we either gave up/lost + * the connection or received a successful "put". + */ + recordStreamInit(timestamp: number, failed: boolean, durationMillis: number) { + const item = { timestamp, failed, durationMillis }; + this.streamInits.push(item); + } + + /** + * Creates a periodic event containing time-dependent stats, and resets the state of the manager + * with regard to those stats. + * + * Note: the reason droppedEvents, deduplicatedUsers, and eventsInLastBatch are passed into this + * function, instead of being properties of the DiagnosticsManager, is that the event processor is + * the one who's calling this function and is also the one who's tracking those stats. + */ + createStatsEventAndReset( + droppedEvents: number, + deduplicatedUsers: number, + eventsInLastBatch: number, + ): DiagnosticStatsEvent { + const currentTime = Date.now(); + const evt: DiagnosticStatsEvent = { + kind: 'diagnostic', + id: this.id, + creationDate: currentTime, + dataSinceDate: this.dataSinceDate, + droppedEvents, + deduplicatedUsers, + eventsInLastBatch, + streamInits: this.streamInits, + }; + + this.streamInits = []; + this.dataSinceDate = currentTime; + return evt; + } +} diff --git a/packages/shared/common/src/internal/diagnostics/index.ts b/packages/shared/common/src/internal/diagnostics/index.ts new file mode 100644 index 000000000..242b42f16 --- /dev/null +++ b/packages/shared/common/src/internal/diagnostics/index.ts @@ -0,0 +1,4 @@ +import DiagnosticsManager from './DiagnosticsManager'; + +// eslint-disable-next-line import/prefer-default-export +export { DiagnosticsManager }; diff --git a/packages/shared/common/src/internal/diagnostics/types.ts b/packages/shared/common/src/internal/diagnostics/types.ts new file mode 100644 index 000000000..bfb882a53 --- /dev/null +++ b/packages/shared/common/src/internal/diagnostics/types.ts @@ -0,0 +1,72 @@ +export interface DiagnosticPlatformData { + name?: string; + osArch?: string; + osName?: string; + osVersion?: string; + /** + * Platform specific identifiers. + * For instance `nodeVersion` + */ + [key: string]: string | undefined; +} + +export interface DiagnosticSdkData { + name?: string; + wrapperName?: string; + wrapperVersion?: string; +} + +export interface DiagnosticConfigData { + customBaseURI: boolean; + customStreamURI: boolean; + customEventsURI: boolean; + eventsCapacity: number; + connectTimeoutMillis: number; + socketTimeoutMillis: number; + eventsFlushIntervalMillis: number; + pollingIntervalMillis: number; + // startWaitMillis: n/a (SDK does not have this feature) + // samplingInterval: n/a (SDK does not have this feature) + reconnectTimeMillis: number; + streamingDisabled: boolean; + usingRelayDaemon: boolean; + offline: boolean; + allAttributesPrivate: boolean; + contextKeysCapacity: number; + contextKeysFlushIntervalMillis: number; + usingProxy: boolean; + usingProxyAuthenticator: boolean; + diagnosticRecordingIntervalMillis: number; + dataStoreType: string; +} + +export interface DiagnosticId { + diagnosticId: string; + sdkKeySuffix: string; +} + +export interface DiagnosticInitEvent { + kind: 'diagnostic-init'; + id: DiagnosticId; + creationDate: number; + sdk: DiagnosticSdkData; + configuration: DiagnosticConfigData; + platform: DiagnosticPlatformData; +} + +export interface StreamInitData { + timestamp: number; + failed: boolean; + durationMillis: number; +} + +export interface DiagnosticStatsEvent { + kind: 'diagnostic'; + id: DiagnosticId; + creationDate: number; + dataSinceDate: number; + droppedEvents: number; + deduplicatedUsers: number; + eventsInLastBatch: number; + streamInits: StreamInitData[]; +} diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index ade263dcc..b95178a1b 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -1,11 +1,12 @@ -import { LDEvaluationReason } from '../../api/data/LDEvaluationReason'; -import { LDLogger } from '../../api/logging/LDLogger'; +import { LDEvaluationReason, LDLogger } from '../../api'; +import { LDDeliveryStatus, LDEventType } from '../../api/subsystem'; import LDContextDeduplicator from '../../api/subsystem/LDContextDeduplicator'; import LDEventProcessor from '../../api/subsystem/LDEventProcessor'; -import LDEventSender, { LDDeliveryStatus, LDEventType } from '../../api/subsystem/LDEventSender'; import AttributeReference from '../../AttributeReference'; import ContextFilter from '../../ContextFilter'; -import ClientContext from '../../options/ClientContext'; +import { ClientContext } from '../../options'; +import { DiagnosticsManager } from '../diagnostics'; +import EventSender from './EventSender'; import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; import { isFeature, isIdentify } from './guards'; import InputEvent from './InputEvent'; @@ -66,72 +67,53 @@ export interface EventProcessorOptions { diagnosticRecordingInterval: number; } -interface LDDiagnosticsManager { - createInitEvent(): DiagnosticEvent; - createStatsEventAndReset( - droppedEvents: number, - deduplicatedUsers: number, - eventsInLastBatch: number, - ): DiagnosticEvent; -} - export default class EventProcessor implements LDEventProcessor { + private eventSender: EventSender; private summarizer = new EventSummarizer(); - private queue: OutputEvent[] = []; - private lastKnownPastTime = 0; - private droppedEvents = 0; - private deduplicatedUsers = 0; - private exceededCapacity = false; - private eventsInLastBatch = 0; - private shutdown = false; - private capacity: number; - private logger?: LDLogger; - private contextFilter: ContextFilter; // Using any here, because setInterval handles are not the same // between node and web. private diagnosticsTimer: any; - private flushTimer: any; - private flushUsersTimer: any = null; constructor( config: EventProcessorOptions, clientContext: ClientContext, - private readonly eventSender: LDEventSender, - private readonly contextDeduplicator: LDContextDeduplicator, - private readonly diagnosticsManager?: LDDiagnosticsManager, + private readonly contextDeduplicator?: LDContextDeduplicator, + private readonly diagnosticsManager?: DiagnosticsManager, ) { this.capacity = config.eventsCapacity; this.logger = clientContext.basicConfiguration.logger; + this.eventSender = new EventSender(clientContext); this.contextFilter = new ContextFilter( config.allAttributesPrivate, config.privateAttributes.map((ref) => new AttributeReference(ref)), ); - if (this.contextDeduplicator.flushInterval !== undefined) { + if (this.contextDeduplicator?.flushInterval !== undefined) { this.flushUsersTimer = setInterval(() => { - this.contextDeduplicator.flush(); + this.contextDeduplicator?.flush(); }, this.contextDeduplicator.flushInterval * 1000); } this.flushTimer = setInterval(async () => { try { await this.flush(); - } catch { - // Eat the errors. + } catch (e) { + // Log errors and swallow them + this.logger?.debug(`Flush failed: ${e}`); } }, config.flushInterval * 1000); @@ -203,7 +185,7 @@ export default class EventProcessor implements LDEventProcessor { const addDebugEvent = this.shouldDebugEvent(inputEvent); const isIdentifyEvent = isIdentify(inputEvent); - const shouldNotDeduplicate = this.contextDeduplicator.processContext(inputEvent.context); + const shouldNotDeduplicate = this.contextDeduplicator?.processContext(inputEvent.context); // If there is no cache, then it will never be in the cache. if (!shouldNotDeduplicate) { diff --git a/packages/shared/common/src/internal/events/EventSender.test.ts b/packages/shared/common/src/internal/events/EventSender.test.ts new file mode 100644 index 000000000..d8b196d01 --- /dev/null +++ b/packages/shared/common/src/internal/events/EventSender.test.ts @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Info, PlatformData, SdkData } from '../../api'; +import { LDDeliveryStatus, LDEventSenderResult, LDEventType } from '../../api/subsystem'; +import { ApplicationTags, ClientContext } from '../../options'; +import { basicPlatform } from '../mocks'; +import EventSender from './EventSender'; + +jest.mock('../../utils', () => { + const actual = jest.requireActual('../../utils'); + return { ...actual, sleep: jest.fn() }; +}); + +const basicConfig = { + tags: new ApplicationTags({ application: { id: 'testApplication1', version: '1.0.0' } }), + serviceEndpoints: { events: 'https://events.fake.com', streaming: '', polling: '' }, +}; +const testEventData1 = { eventId: 'test-event-data-1' }; +const testEventData2 = { eventId: 'test-event-data-2' }; +const info: Info = { + platformData(): PlatformData { + return { + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + }; + }, + sdkData(): SdkData { + return { + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }; + }, +}; + +const analyticsHeaders = (uuid: number) => ({ + authorization: 'sdk-key', + 'content-type': 'application/json', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchDarkly-event-schema': '4', + 'x-launchdarkly-payload-id': `${uuid}`, + 'x-launchdarkly-tags': 'application-id/testApplication1 application-version/1.0.0', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', +}); + +const diagnosticHeaders = { + authorization: 'sdk-key', + 'content-type': 'application/json', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchDarkly-event-schema': undefined, + 'x-launchdarkly-payload-id': undefined, + 'x-launchdarkly-tags': 'application-id/testApplication1 application-version/1.0.0', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', +}; + +describe('given an event sender', () => { + let eventSender: EventSender; + let mockFetch: jest.Mock; + let mockHeadersGet: jest.Mock; + let mockRandomUuid: jest.Mock; + let uuid: number; + const dateNowString = '2023-08-10'; + let eventSenderResult: LDEventSenderResult; + + const setupMockFetch = (responseStatusCode: number) => { + mockFetch = jest + .fn() + .mockResolvedValue({ headers: { get: mockHeadersGet }, status: responseStatusCode }); + basicPlatform.requests.fetch = mockFetch; + }; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(dateNowString)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockHeadersGet = jest.fn((key) => (key === 'date' ? new Date() : undefined)); + uuid = 0; + mockRandomUuid = jest.fn(() => { + uuid += 1; + return `${uuid}`; + }); + setupMockFetch(200); + basicPlatform.crypto.randomUUID = mockRandomUuid; + + eventSender = new EventSender( + new ClientContext('sdk-key', basicConfig, { ...basicPlatform, info }), + ); + + eventSenderResult = await eventSender.sendEventData( + LDEventType.AnalyticsEvents, + testEventData1, + ); + }); + + it('includes the correct headers for analytics', async () => { + const { status, serverTime, error } = eventSenderResult; + + expect(status).toEqual(LDDeliveryStatus.Succeeded); + expect(serverTime).toEqual(Date.now()); + expect(error).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith(`${basicConfig.serviceEndpoints.events}/bulk`, { + body: JSON.stringify(testEventData1), + headers: analyticsHeaders(uuid), + method: 'POST', + }); + }); + + it('includes the payload', async () => { + const { status: status1 } = eventSenderResult; + const { status: status2 } = await eventSender.sendEventData( + LDEventType.DiagnosticEvent, + testEventData2, + ); + + expect(status1).toEqual(LDDeliveryStatus.Succeeded); + expect(status2).toEqual(LDDeliveryStatus.Succeeded); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith(1, `${basicConfig.serviceEndpoints.events}/bulk`, { + body: JSON.stringify(testEventData1), + headers: analyticsHeaders(uuid), + method: 'POST', + }); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + `${basicConfig.serviceEndpoints.events}/diagnostic`, + { + body: JSON.stringify(testEventData2), + headers: diagnosticHeaders, + method: 'POST', + }, + ); + }); + + it('sends a unique payload for analytics events', async () => { + // send the same request again to assert unique uuids + await eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + `${basicConfig.serviceEndpoints.events}/bulk`, + expect.objectContaining({ + headers: analyticsHeaders(1), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + `${basicConfig.serviceEndpoints.events}/bulk`, + expect.objectContaining({ + headers: analyticsHeaders(2), + }), + ); + }); + + describe.each([400, 408, 429, 503])('given recoverable errors', (responseStatusCode) => { + beforeEach(async () => { + setupMockFetch(responseStatusCode); + eventSenderResult = await eventSender.sendEventData( + LDEventType.AnalyticsEvents, + testEventData1, + ); + }); + + it(`retries - ${responseStatusCode}`, async () => { + const { status, error } = eventSenderResult; + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(status).toEqual(LDDeliveryStatus.Failed); + expect(error.name).toEqual('LaunchDarklyUnexpectedResponseError'); + expect(error.message).toEqual( + `Received error ${responseStatusCode} for event posting - giving up permanently`, + ); + }); + }); + + describe.each([401, 403])('given unrecoverable errors', (responseStatusCode) => { + beforeEach(async () => { + setupMockFetch(responseStatusCode); + eventSenderResult = await eventSender.sendEventData( + LDEventType.AnalyticsEvents, + testEventData1, + ); + }); + + it(`does not retry - ${responseStatusCode}`, async () => { + const errorMessage = `Received error ${ + responseStatusCode === 401 ? '401 (invalid SDK key)' : responseStatusCode + } for event posting - giving up permanently`; + + const { status, error } = eventSenderResult; + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(status).toEqual(LDDeliveryStatus.FailedAndMustShutDown); + expect(error.name).toEqual('LaunchDarklyUnexpectedResponseError'); + expect(error.message).toEqual(errorMessage); + }); + }); +}); diff --git a/packages/shared/common/src/internal/events/EventSender.ts b/packages/shared/common/src/internal/events/EventSender.ts new file mode 100644 index 000000000..5a49347d0 --- /dev/null +++ b/packages/shared/common/src/internal/events/EventSender.ts @@ -0,0 +1,101 @@ +import { Crypto, Requests } from '../../api'; +import { + LDDeliveryStatus, + LDEventSender, + LDEventSenderResult, + LDEventType, +} from '../../api/subsystem'; +import { isHttpRecoverable, LDUnexpectedResponseError } from '../../errors'; +import { ClientContext } from '../../options'; +import { defaultHeaders, httpErrorMessage, sleep } from '../../utils'; + +export default class EventSender implements LDEventSender { + private crypto: Crypto; + private defaultHeaders: { + [key: string]: string; + }; + private diagnosticEventsUri: string; + private eventsUri: string; + private requests: Requests; + + constructor(clientContext: ClientContext) { + const { basicConfiguration, platform } = clientContext; + const { sdkKey, serviceEndpoints, tags } = basicConfiguration; + const { crypto, info, requests } = platform; + + this.defaultHeaders = defaultHeaders(sdkKey, info, tags); + this.eventsUri = `${serviceEndpoints.events}/bulk`; + this.diagnosticEventsUri = `${serviceEndpoints.events}/diagnostic`; + this.requests = requests; + this.crypto = crypto; + } + + private async tryPostingEvents( + events: any, + uri: string, + payloadId: string | undefined, + canRetry: boolean, + ): Promise { + const tryRes: LDEventSenderResult = { + status: LDDeliveryStatus.Succeeded, + }; + + const headers: Record = { + ...this.defaultHeaders, + 'content-type': 'application/json', + }; + + if (payloadId) { + headers['x-launchdarkly-payload-id'] = payloadId; + headers['x-launchDarkly-event-schema'] = '4'; + } + let error; + try { + const { status, headers: resHeaders } = await this.requests.fetch(uri, { + headers, + body: JSON.stringify(events), + method: 'POST', + }); + + const serverDate = Date.parse(resHeaders.get('date') || ''); + if (serverDate) { + tryRes.serverTime = serverDate; + } + + if (status <= 204) { + return tryRes; + } + + error = new LDUnexpectedResponseError( + httpErrorMessage({ status, message: 'some events were dropped' }, 'event posting'), + ); + + if (!isHttpRecoverable(status)) { + tryRes.status = LDDeliveryStatus.FailedAndMustShutDown; + tryRes.error = error; + return tryRes; + } + } catch (err) { + error = err; + } + + // recoverable but not retrying + if (error && !canRetry) { + tryRes.status = LDDeliveryStatus.Failed; + tryRes.error = error; + return tryRes; + } + + // wait 1 second before retrying + await sleep(); + + return this.tryPostingEvents(events, this.eventsUri, payloadId, false); + } + + async sendEventData(type: LDEventType, data: any): Promise { + const payloadId = type === LDEventType.AnalyticsEvents ? this.crypto.randomUUID() : undefined; + const uri = type === LDEventType.AnalyticsEvents ? this.eventsUri : this.diagnosticEventsUri; + + return this.tryPostingEvents(data, uri, payloadId, true); + } +} diff --git a/packages/shared/common/src/internal/events/NullEventProcessor.ts b/packages/shared/common/src/internal/events/NullEventProcessor.ts new file mode 100644 index 000000000..ba28e84e0 --- /dev/null +++ b/packages/shared/common/src/internal/events/NullEventProcessor.ts @@ -0,0 +1,11 @@ +import { LDEventProcessor } from '../../api/subsystem'; + +export default class NullEventProcessor implements LDEventProcessor { + close() {} + + async flush(): Promise { + // empty comment to keep ts and eslint happy + } + + sendEvent() {} +} diff --git a/packages/shared/common/src/internal/events/index.ts b/packages/shared/common/src/internal/events/index.ts index 7b32527fb..51ba822f8 100644 --- a/packages/shared/common/src/internal/events/index.ts +++ b/packages/shared/common/src/internal/events/index.ts @@ -3,5 +3,13 @@ import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; +import NullEventProcessor from './NullEventProcessor'; -export { InputCustomEvent, InputEvalEvent, InputEvent, InputIdentifyEvent, EventProcessor }; +export { + InputCustomEvent, + InputEvalEvent, + InputEvent, + InputIdentifyEvent, + EventProcessor, + NullEventProcessor, +}; diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index 7981d6b64..0eec6634d 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -1 +1,4 @@ +export * from './diagnostics'; export * from './events'; +export * from './stream'; +export * as mocks from './mocks'; diff --git a/packages/shared/common/src/internal/mocks/clientContext.ts b/packages/shared/common/src/internal/mocks/clientContext.ts new file mode 100644 index 000000000..070f7df2c --- /dev/null +++ b/packages/shared/common/src/internal/mocks/clientContext.ts @@ -0,0 +1,14 @@ +import { createSafeLogger } from '../../logging'; +import { ClientContext } from '../../options'; +import basicPlatform from './platform'; + +const clientContext = new ClientContext( + 'testSdkKey', + { + serviceEndpoints: { streaming: 'https://mockstream.ld.com', polling: '', events: '' }, + logger: createSafeLogger(), + }, + basicPlatform, +); + +export default clientContext; diff --git a/packages/shared/common/src/internal/mocks/contextDeduplicator.ts b/packages/shared/common/src/internal/mocks/contextDeduplicator.ts new file mode 100644 index 000000000..5d2b9ebd5 --- /dev/null +++ b/packages/shared/common/src/internal/mocks/contextDeduplicator.ts @@ -0,0 +1,18 @@ +import { LDContextDeduplicator } from '../../api/subsystem'; +import { Context } from '../../index'; + +export default class ContextDeduplicator implements LDContextDeduplicator { + flushInterval?: number | undefined = 0.1; + + seen: string[] = []; + + processContext(context: Context): boolean { + if (this.seen.indexOf(context.canonicalKey) >= 0) { + return false; + } + this.seen.push(context.canonicalKey); + return true; + } + + flush(): void {} +} diff --git a/packages/shared/sdk-server/__tests__/evaluation/mocks/hasher.ts b/packages/shared/common/src/internal/mocks/hasher.ts similarity index 85% rename from packages/shared/sdk-server/__tests__/evaluation/mocks/hasher.ts rename to packages/shared/common/src/internal/mocks/hasher.ts index bed0871a2..1bfd0335c 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/mocks/hasher.ts +++ b/packages/shared/common/src/internal/mocks/hasher.ts @@ -1,5 +1,4 @@ -// Mock hashing implementation. -import { Crypto, Hasher, Hmac } from '@launchdarkly/js-sdk-common'; +import { Crypto, Hasher, Hmac } from '../../api'; export const hasher: Hasher = { update: jest.fn(), diff --git a/packages/shared/common/src/internal/mocks/index.ts b/packages/shared/common/src/internal/mocks/index.ts new file mode 100644 index 000000000..5b0dd0097 --- /dev/null +++ b/packages/shared/common/src/internal/mocks/index.ts @@ -0,0 +1,15 @@ +import clientContext from './clientContext'; +import { crypto, hasher } from './hasher'; +import logger from './logger'; +import basicPlatform from './platform'; +import { MockStreamingProcessor, setupMockStreamingProcessor } from './streamingProcessor'; + +export { + basicPlatform, + clientContext, + crypto, + logger, + hasher, + MockStreamingProcessor, + setupMockStreamingProcessor, +}; diff --git a/packages/shared/common/src/internal/mocks/logger.ts b/packages/shared/common/src/internal/mocks/logger.ts new file mode 100644 index 000000000..9fe7004e0 --- /dev/null +++ b/packages/shared/common/src/internal/mocks/logger.ts @@ -0,0 +1,8 @@ +const logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; + +export default logger; diff --git a/packages/shared/sdk-server/__tests__/evaluation/mocks/platform.ts b/packages/shared/common/src/internal/mocks/platform.ts similarity index 67% rename from packages/shared/sdk-server/__tests__/evaluation/mocks/platform.ts rename to packages/shared/common/src/internal/mocks/platform.ts index a4ddab9b8..f64693304 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/mocks/platform.ts +++ b/packages/shared/common/src/internal/mocks/platform.ts @@ -8,16 +8,31 @@ import { Requests, Response, SdkData, -} from '@launchdarkly/js-sdk-common'; - +} from '../../api'; import { crypto } from './hasher'; const info: Info = { platformData(): PlatformData { - return {}; + return { + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + }; }, sdkData(): SdkData { - return {}; + return { + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }; }, }; diff --git a/packages/shared/common/src/internal/mocks/streamingProcessor.ts b/packages/shared/common/src/internal/mocks/streamingProcessor.ts new file mode 100644 index 000000000..36cfd5e26 --- /dev/null +++ b/packages/shared/common/src/internal/mocks/streamingProcessor.ts @@ -0,0 +1,33 @@ +import { EventName, ProcessStreamResponse } from '../../api'; +import { LDStreamingError } from '../../errors'; +import { ClientContext } from '../../options'; +import { DiagnosticsManager } from '../diagnostics'; +import { type StreamingErrorHandler } from '../stream'; + +export const MockStreamingProcessor = jest.fn(); + +export const setupMockStreamingProcessor = (shouldError: boolean = false) => { + MockStreamingProcessor.mockImplementation( + ( + sdkKey: string, + clientContext: ClientContext, + listeners: Map, + diagnosticsManager: DiagnosticsManager, + errorHandler: StreamingErrorHandler, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + streamInitialReconnectDelay, + ) => ({ + start: jest.fn(async () => { + if (shouldError) { + process.nextTick(() => errorHandler(new LDStreamingError('test-error', 401))); + } else { + // execute put which will resolve the init promise + process.nextTick( + () => listeners.get('put')?.processJson({ data: { flags: {}, segments: {} } }), + ); + } + }), + close: jest.fn(), + }), + ); +}; diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts new file mode 100644 index 000000000..d8ad4a5d4 --- /dev/null +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts @@ -0,0 +1,234 @@ +import { EventName, ProcessStreamResponse } from '../../api'; +import { LDStreamProcessor } from '../../api/subsystem'; +import { LDStreamingError } from '../../errors'; +import { defaultHeaders } from '../../utils'; +import { DiagnosticsManager } from '../diagnostics'; +import { basicPlatform, clientContext, logger } from '../mocks'; +import StreamingProcessor from './StreamingProcessor'; + +const dateNowString = '2023-08-10'; +const sdkKey = 'my-sdk-key'; +const { + basicConfiguration: { serviceEndpoints, tags }, + platform: { info }, +} = clientContext; +const event = { + data: { + flags: { + flagkey: { key: 'flagkey', version: 1 }, + }, + segments: { + segkey: { key: 'segkey', version: 2 }, + }, + }, +}; + +const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), +}); + +describe('given a stream processor with mock event source', () => { + let streamingProcessor: LDStreamProcessor; + let diagnosticsManager: DiagnosticsManager; + let listeners: Map; + let mockEventSource: any; + let mockListener: ProcessStreamResponse; + let mockErrorHandler: jest.Mock; + let simulatePutEvent: (e?: any) => void; + let simulateError: (e: { status: number; message: string }) => boolean; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(dateNowString)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + mockErrorHandler = jest.fn(); + clientContext.basicConfiguration.logger = logger; + + basicPlatform.requests = { + createEventSource: jest.fn((streamUri: string, options: any) => { + mockEventSource = createMockEventSource(streamUri, options); + return mockEventSource; + }), + } as any; + simulatePutEvent = (e: any = event) => { + mockEventSource.addEventListener.mock.calls[0][1](e); + }; + simulateError = (e: { status: number; message: string }): boolean => + mockEventSource.options.errorFilter(e); + + listeners = new Map(); + mockListener = { + deserializeData: jest.fn((data) => data), + processJson: jest.fn(), + }; + listeners.set('put', mockListener); + listeners.set('patch', mockListener); + + diagnosticsManager = new DiagnosticsManager(sdkKey, basicPlatform, {}); + streamingProcessor = new StreamingProcessor( + sdkKey, + clientContext, + listeners, + diagnosticsManager, + mockErrorHandler, + ); + + jest.spyOn(streamingProcessor, 'stop'); + streamingProcessor.start(); + }); + + afterEach(() => { + streamingProcessor.close(); + jest.resetAllMocks(); + }); + + it('uses expected uri and eventSource init args', () => { + expect(basicPlatform.requests.createEventSource).toBeCalledWith( + `${serviceEndpoints.streaming}/all`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, tags), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); + }); + + it('sets streamInitialReconnectDelay correctly', () => { + streamingProcessor = new StreamingProcessor( + sdkKey, + clientContext, + listeners, + diagnosticsManager, + mockErrorHandler, + 22, + ); + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenLastCalledWith( + `${serviceEndpoints.streaming}/all`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, tags), + initialRetryDelayMillis: 22000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); + }); + + it('adds listeners', () => { + expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( + 1, + 'put', + expect.any(Function), + ); + expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( + 2, + 'patch', + expect.any(Function), + ); + }); + + it('executes listeners', () => { + simulatePutEvent(); + const patchHandler = mockEventSource.addEventListener.mock.calls[1][1]; + patchHandler(event); + + expect(mockListener.deserializeData).toBeCalledTimes(2); + expect(mockListener.processJson).toBeCalledTimes(2); + }); + + it('passes error to callback if json data is malformed', async () => { + (mockListener.deserializeData as jest.Mock).mockReturnValue(false); + simulatePutEvent(); + + expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/)); + expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i)); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); + }); + + it('calls error handler if event.data prop is missing', async () => { + simulatePutEvent({ flags: {} }); + + expect(mockListener.deserializeData).not.toBeCalled(); + expect(mockListener.processJson).not.toBeCalled(); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i); + }); + + it('closes and stops', async () => { + streamingProcessor.close(); + + expect(streamingProcessor.stop).toBeCalled(); + expect(mockEventSource.close).toBeCalled(); + // @ts-ignore + expect(streamingProcessor.eventSource).toBeUndefined(); + }); + + it('creates a stream init event', async () => { + const startTime = Date.now(); + simulatePutEvent(); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeFalsy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + + describe.each([400, 408, 429, 500, 503])('given recoverable http errors', (status) => { + it(`continues retrying after error: ${status}`, () => { + const startTime = Date.now(); + const testError = { status, message: 'retry. recoverable.' }; + const willRetry = simulateError(testError); + + expect(willRetry).toBeTruthy(); + expect(mockErrorHandler).not.toBeCalled(); + expect(logger.warn).toBeCalledWith( + expect.stringMatching(new RegExp(`${status}.*will retry`)), + ); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeTruthy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + }); + + describe.each([401, 403])('given irrecoverable http errors', (status) => { + it(`stops retrying after error: ${status}`, () => { + const startTime = Date.now(); + const testError = { status, message: 'stopping. irrecoverable.' }; + const willRetry = simulateError(testError); + + expect(willRetry).toBeFalsy(); + expect(mockErrorHandler).toBeCalledWith( + new LDStreamingError(testError.message, testError.status), + ); + expect(logger.error).toBeCalledWith( + expect.stringMatching(new RegExp(`${status}.*permanently`)), + ); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeTruthy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.ts new file mode 100644 index 000000000..9461174c4 --- /dev/null +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.ts @@ -0,0 +1,141 @@ +import { EventName, EventSource, LDLogger, ProcessStreamResponse, Requests } from '../../api'; +import { LDStreamProcessor } from '../../api/subsystem'; +import { isHttpRecoverable, LDStreamingError } from '../../errors'; +import { ClientContext } from '../../options'; +import { defaultHeaders, httpErrorMessage } from '../../utils'; +import { DiagnosticsManager } from '../diagnostics'; +import { StreamingErrorHandler } from './types'; + +const STREAM_READ_TIMEOUT_MS = 5 * 60 * 1000; +const RETRY_RESET_INTERVAL_MS = 60 * 1000; + +const reportJsonError = ( + type: string, + data: string, + logger?: LDLogger, + errorHandler?: StreamingErrorHandler, +) => { + logger?.error(`Stream received invalid data in "${type}" message`); + logger?.debug(`Invalid JSON follows: ${data}`); + errorHandler?.(new LDStreamingError('Malformed JSON data in event stream')); +}; + +class StreamingProcessor implements LDStreamProcessor { + private readonly headers: { [key: string]: string | string[] }; + private readonly streamUri: string; + private readonly logger?: LDLogger; + + private eventSource?: EventSource; + private requests: Requests; + private connectionAttemptStartTime?: number; + + constructor( + sdkKey: string, + clientContext: ClientContext, + private readonly listeners: Map, + private readonly diagnosticsManager?: DiagnosticsManager, + private readonly errorHandler?: StreamingErrorHandler, + private readonly streamInitialReconnectDelay = 1, + ) { + const { basicConfiguration, platform } = clientContext; + const { logger, tags } = basicConfiguration; + const { info, requests } = platform; + + this.headers = defaultHeaders(sdkKey, info, tags); + this.logger = logger; + this.requests = requests; + this.streamUri = `${basicConfiguration.serviceEndpoints.streaming}/all`; + } + + private logConnectionStarted() { + this.connectionAttemptStartTime = Date.now(); + } + + private logConnectionResult(success: boolean) { + if (this.connectionAttemptStartTime && this.diagnosticsManager) { + this.diagnosticsManager.recordStreamInit( + this.connectionAttemptStartTime, + !success, + Date.now() - this.connectionAttemptStartTime, + ); + } + + this.connectionAttemptStartTime = undefined; + } + + start() { + this.logConnectionStarted(); + + const errorFilter = (err: { status: number; message: string }): boolean => { + if (err.status && !isHttpRecoverable(err.status)) { + this.logConnectionResult(false); + this.errorHandler?.(new LDStreamingError(err.message, err.status)); + this.logger?.error(httpErrorMessage(err, 'streaming request')); + return false; + } + + this.logger?.warn(httpErrorMessage(err, 'streaming request', 'will retry')); + this.logConnectionResult(false); + this.logConnectionStarted(); + return true; + }; + + // TLS is handled by the platform implementation. + + const eventSource = this.requests.createEventSource(this.streamUri, { + headers: this.headers, + errorFilter, + initialRetryDelayMillis: 1000 * this.streamInitialReconnectDelay, + readTimeoutMillis: STREAM_READ_TIMEOUT_MS, + retryResetIntervalMillis: RETRY_RESET_INTERVAL_MS, + }); + this.eventSource = eventSource; + + eventSource.onclose = () => { + this.logger?.info('Closed LaunchDarkly stream connection'); + }; + + eventSource.onerror = () => { + // The work is done by `errorFilter`. + }; + + eventSource.onopen = () => { + this.logger?.info('Opened LaunchDarkly stream connection'); + }; + + eventSource.onretrying = (e) => { + this.logger?.info(`Will retry stream connection in ${e.delayMillis} milliseconds`); + }; + + this.listeners.forEach(({ deserializeData, processJson }, eventName) => { + eventSource.addEventListener(eventName, (event) => { + this.logger?.debug(`Received ${eventName} event`); + + if (event?.data) { + this.logConnectionResult(true); + const { data } = event; + const dataJson = deserializeData(data); + + if (!dataJson) { + reportJsonError(eventName, data, this.logger, this.errorHandler); + return; + } + processJson(dataJson); + } else { + this.errorHandler?.(new LDStreamingError('Unexpected payload from event stream')); + } + }); + }); + } + + stop() { + this.eventSource?.close(); + this.eventSource = undefined; + } + + close() { + this.stop(); + } +} + +export default StreamingProcessor; diff --git a/packages/shared/common/src/internal/stream/index.ts b/packages/shared/common/src/internal/stream/index.ts new file mode 100644 index 000000000..55f6118a4 --- /dev/null +++ b/packages/shared/common/src/internal/stream/index.ts @@ -0,0 +1,4 @@ +import StreamingProcessor from './StreamingProcessor'; +import { type StreamingErrorHandler } from './types'; + +export { StreamingProcessor, type StreamingErrorHandler }; diff --git a/packages/shared/common/src/internal/stream/types.ts b/packages/shared/common/src/internal/stream/types.ts new file mode 100644 index 000000000..a2c1c42d4 --- /dev/null +++ b/packages/shared/common/src/internal/stream/types.ts @@ -0,0 +1,3 @@ +import { LDStreamingError } from '../../errors'; + +export type StreamingErrorHandler = (err: LDStreamingError) => void; diff --git a/packages/shared/common/src/options/ClientContext.ts b/packages/shared/common/src/options/ClientContext.ts index 57ca9ff58..ab6320b73 100644 --- a/packages/shared/common/src/options/ClientContext.ts +++ b/packages/shared/common/src/options/ClientContext.ts @@ -1,4 +1,5 @@ import { LDClientContext, LDLogger, Platform } from '../api'; +import ApplicationTags from './ApplicationTags'; import ServiceEndpoints from './ServiceEndpoints'; /** @@ -6,12 +7,14 @@ import ServiceEndpoints from './ServiceEndpoints'; * client SDKs. */ interface BasicConfiguration { + tags?: ApplicationTags; + logger?: LDLogger; /** * True if the SDK was configured to be completely offline. */ - offline: boolean; + offline?: boolean; /** * The configured SDK key. @@ -22,6 +25,11 @@ interface BasicConfiguration { * Defines the base service URIs used by SDK components. */ serviceEndpoints: ServiceEndpoints; + + /** + * Sets the initial reconnect delay for the streaming connection, in seconds. + */ + streamInitialReconnectDelay?: number; } /** @@ -35,16 +43,18 @@ export default class ClientContext implements LDClientContext { sdkKey: string, configuration: { logger?: LDLogger; - offline: boolean; + offline?: boolean; serviceEndpoints: ServiceEndpoints; + tags?: ApplicationTags; }, public readonly platform: Platform, ) { this.basicConfiguration = { + tags: configuration.tags, logger: configuration.logger, offline: configuration.offline, - sdkKey, serviceEndpoints: configuration.serviceEndpoints, + sdkKey, }; } } diff --git a/packages/shared/common/src/options/ServiceEndpoints.ts b/packages/shared/common/src/options/ServiceEndpoints.ts index a47cdcc5d..03531950c 100644 --- a/packages/shared/common/src/options/ServiceEndpoints.ts +++ b/packages/shared/common/src/options/ServiceEndpoints.ts @@ -6,13 +6,16 @@ function canonicalizeUri(uri: string): string { * Specifies the base service URIs used by SDK components. */ export default class ServiceEndpoints { - public readonly streaming: string; + public static DEFAULT_EVENTS = 'https://events.launchdarkly.com'; + public readonly streaming: string; public readonly polling: string; - public readonly events: string; - - public constructor(streaming: string, polling: string, events: string) { + public constructor( + streaming: string, + polling: string, + events: string = ServiceEndpoints.DEFAULT_EVENTS, + ) { this.streaming = canonicalizeUri(streaming); this.polling = canonicalizeUri(polling); this.events = canonicalizeUri(events); diff --git a/packages/shared/common/src/utils/date.ts b/packages/shared/common/src/utils/date.ts new file mode 100644 index 000000000..83be69d24 --- /dev/null +++ b/packages/shared/common/src/utils/date.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export function secondsToMillis(sec: number): number { + return Math.trunc(sec * 1000); +} diff --git a/packages/shared/common/src/utils/http.test.ts b/packages/shared/common/src/utils/http.test.ts new file mode 100644 index 000000000..cf4e30f75 --- /dev/null +++ b/packages/shared/common/src/utils/http.test.ts @@ -0,0 +1,119 @@ +import { Info, PlatformData, SdkData } from '../api'; +import { ApplicationTags } from '../options'; +import { defaultHeaders, httpErrorMessage } from './http'; + +describe('defaultHeaders', () => { + const makeInfo = ( + wrapperName?: string, + wrapperVersion?: string, + userAgentBase?: string, + ): Info => ({ + platformData(): PlatformData { + return {}; + }, + sdkData(): SdkData { + const sdkData: SdkData = { + version: '2.2.2', + userAgentBase, + wrapperName, + wrapperVersion, + }; + return sdkData; + }, + }); + + it('sets SDK key', () => { + const h = defaultHeaders('my-sdk-key', makeInfo()); + expect(h).toMatchObject({ authorization: 'my-sdk-key' }); + }); + + it('sets the default user agent', () => { + const h = defaultHeaders('my-sdk-key', makeInfo()); + expect(h).toMatchObject({ 'user-agent': 'NodeJSClient/2.2.2' }); + }); + + it('sets the SDK specific user agent', () => { + const h = defaultHeaders('my-sdk-key', makeInfo(undefined, undefined, 'CATS')); + expect(h).toMatchObject({ 'user-agent': 'CATS/2.2.2' }); + }); + + it('does not include wrapper header by default', () => { + const h = defaultHeaders('my-sdk-key', makeInfo()); + expect(h['x-launchdarkly-wrapper']).toBeUndefined(); + }); + + it('sets wrapper header with name only', () => { + const h = defaultHeaders('my-sdk-key', makeInfo('my-wrapper')); + expect(h).toMatchObject({ 'x-launchdarkly-wrapper': 'my-wrapper' }); + }); + + it('sets wrapper header with name and version', () => { + const h = defaultHeaders('my-sdk-key', makeInfo('my-wrapper', '2.0')); + expect(h).toMatchObject({ 'x-launchdarkly-wrapper': 'my-wrapper/2.0' }); + }); + + it('sets the X-LaunchDarkly-Tags header with valid tags.', () => { + const tags = new ApplicationTags({ + application: { + id: 'test-application', + version: 'test-version', + }, + }); + const h = defaultHeaders('my-sdk-key', makeInfo('my-wrapper'), tags); + expect(h).toMatchObject({ + 'x-launchdarkly-tags': 'application-id/test-application application-version/test-version', + }); + }); +}); + +describe('httpErrorMessage', () => { + test('I/O error', () => { + const error = { status: undefined, message: 'no status' }; + const context = 'fake error context message'; + const retryMessage = undefined; + + // @ts-ignore + const result = httpErrorMessage(error, context, retryMessage); + + expect(result).toBe( + 'Received I/O error (no status) for fake error context message - giving up permanently', + ); + }); + + test('invalid sdk key', () => { + const error = { status: 401, message: 'denied' }; + const context = 'fake error context message'; + const retryMessage = undefined; + + // @ts-ignore + const result = httpErrorMessage(error, context, retryMessage); + + expect(result).toBe( + 'Received error 401 (invalid SDK key) for fake error context message - giving up permanently', + ); + }); + + test('non-401 errors', () => { + const error = { status: 500, message: 'server error' }; + const context = 'fake error context message'; + const retryMessage = undefined; + + // @ts-ignore + const result = httpErrorMessage(error, context, retryMessage); + + expect(result).toBe( + 'Received error 500 for fake error context message - giving up permanently', + ); + }); + + test('with retry message', () => { + const error = { status: 500, message: 'denied' }; + const context = 'fake error context message'; + const retryMessage = 'will retry'; + + // @ts-ignore + const result = httpErrorMessage(error, context, retryMessage); + + expect(result).toBe('Received error 500 for fake error context message - will retry'); + }); +}); diff --git a/packages/shared/common/src/utils/http.ts b/packages/shared/common/src/utils/http.ts new file mode 100644 index 000000000..c29ea4759 --- /dev/null +++ b/packages/shared/common/src/utils/http.ts @@ -0,0 +1,48 @@ +import { Info } from '../api'; +import { ApplicationTags } from '../options'; + +export type LDHeaders = { + authorization: string; + 'user-agent': string; + 'x-launchdarkly-wrapper'?: string; + 'x-launchdarkly-tags'?: string; +}; + +export function defaultHeaders(sdkKey: string, info: Info, tags?: ApplicationTags): LDHeaders { + const { userAgentBase, version, wrapperName, wrapperVersion } = info.sdkData(); + + const headers: LDHeaders = { + authorization: sdkKey, + 'user-agent': `${userAgentBase ?? 'NodeJSClient'}/${version}`, + }; + + if (wrapperName) { + headers['x-launchdarkly-wrapper'] = wrapperVersion + ? `${wrapperName}/${wrapperVersion}` + : wrapperName; + } + + if (tags?.value) { + headers['x-launchdarkly-tags'] = tags.value; + } + + return headers; +} + +export function httpErrorMessage( + err: { + status: number; + message: string; + }, + context: string, + retryMessage?: string, +): string { + let desc; + if (err.status) { + desc = `error ${err.status}${err.status === 401 ? ' (invalid SDK key)' : ''}`; + } else { + desc = `I/O error (${err.message || err})`; + } + const action = retryMessage ?? 'giving up permanently'; + return `Received ${desc} for ${context} - ${action}`; +} diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index a6a867598..ea82ccfb1 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,5 +1,7 @@ +import { secondsToMillis } from './date'; +import { defaultHeaders, httpErrorMessage, LDHeaders } from './http'; import noop from './noop'; +import sleep from './sleep'; import { VoidFunction } from './VoidFunction'; -// eslint-disable-next-line import/prefer-default-export -export { noop, VoidFunction }; +export { defaultHeaders, httpErrorMessage, noop, LDHeaders, secondsToMillis, sleep, VoidFunction }; diff --git a/packages/shared/common/src/utils/sleep.ts b/packages/shared/common/src/utils/sleep.ts new file mode 100644 index 000000000..ff6f0e305 --- /dev/null +++ b/packages/shared/common/src/utils/sleep.ts @@ -0,0 +1,6 @@ +const sleep = async (delayMillis: number = 1000) => + new Promise((resolve) => { + setTimeout(resolve, delayMillis); + }); + +export default sleep; diff --git a/packages/shared/common/tsconfig.json b/packages/shared/common/tsconfig.json index cd3f7af3c..1a394bfbf 100644 --- a/packages/shared/common/tsconfig.json +++ b/packages/shared/common/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "es2017", + "target": "ES2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientDomImpl.ts index 3baf84319..f8bab88ba 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientDomImpl.ts @@ -2,30 +2,37 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { + internal, LDContext, LDEvaluationDetail, LDFlagSet, LDFlagValue, - Platform, + subsystem, } from '@launchdarkly/js-sdk-common'; import { LDClientDom } from './api/LDClientDom'; import LDOptions from './api/LDOptions'; import Configuration from './configuration'; +import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; +import createEventProcessor from './events/createEventProcessor'; +import { PlatformDom, Storage } from './platform/PlatformDom'; export default class LDClientDomImpl implements LDClientDom { config: Configuration; - - /** - * Immediately return an LDClient instance. No async or remote calls. - * - * @param clientSideId - * @param context - * @param options - * @param platform - */ - constructor(clientSideId: string, context: LDContext, options: LDOptions, platform: Platform) { - this.config = new Configuration(); + diagnosticsManager?: internal.DiagnosticsManager; + eventProcessor: subsystem.LDEventProcessor; + storage: Storage; + + constructor(clientSideID: string, context: LDContext, options: LDOptions, platform: PlatformDom) { + this.config = new Configuration(options); + this.storage = platform.storage; + this.diagnosticsManager = createDiagnosticsManager(clientSideID, this.config, platform); + this.eventProcessor = createEventProcessor( + clientSideID, + this.config, + platform, + this.diagnosticsManager, + ); } allFlags(): LDFlagSet { diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index fe3d8ae05..2fc7a4dbe 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -132,14 +132,6 @@ export default interface LDOptions { */ privateAttributes?: Array; - /** - * Whether analytics events should be sent only when you call variation (true), or also when you - * call allFlags (false). - * - * By default, this is false (events will be sent in both cases). - */ - sendEventsOnlyForVariation?: boolean; - /** * The capacity of the analytics events queue. * @@ -155,20 +147,22 @@ export default interface LDOptions { capacity?: number; /** - * The interval in between flushes of the analytics events queue, in milliseconds. + * The interval in between flushes of the analytics events queue, in seconds. * - * The default value is 2000ms. + * The default value is 2s. */ flushInterval?: number; /** - * How long (in milliseconds) to wait after a failure of the stream connection before trying to - * reconnect. + * Sets the initial reconnect delay for the streaming connection, in seconds. + * + * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially for any subsequent connection failures. * - * This only applies if streaming has been enabled by setting {@link streaming} to true or - * subscribing to `"change"` events. The default is 1000ms. + * The default value is 1. */ - streamReconnectDelay?: number; + streamInitialReconnectDelay?: number; /** * Set to true to opt out of sending diagnostics data. @@ -182,9 +176,9 @@ export default interface LDOptions { diagnosticOptOut?: boolean; /** - * The interval at which periodic diagnostic data is sent, in milliseconds. + * The interval at which periodic diagnostic data is sent, in seconds. * - * The default is 900000 (every 15 minutes) and the minimum value is 6000. See {@link diagnosticOptOut} + * The default is 900 (every 15 minutes) and the minimum value is 6. See {@link diagnosticOptOut} * for more information on the diagnostics data being sent. */ diagnosticRecordingInterval?: number; @@ -233,4 +227,12 @@ export default interface LDOptions { * Inspectors can be used for collecting information for monitoring, analytics, and debugging. */ inspectors?: LDInspection[]; + + /** + * 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). + */ + hash?: string; } diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts index 1d3c8ac43..4bca3af62 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -12,13 +12,13 @@ describe('Configuration', () => { expect(config).toMatchObject({ allAttributesPrivate: false, - baseUri: 'https://sdk.launchdarkly.com', + baseUri: 'https://clientsdk.launchdarkly.com', capacity: 100, diagnosticOptOut: false, - diagnosticRecordingInterval: 900000, + diagnosticRecordingInterval: 900, evaluationReasons: false, eventsUri: 'https://events.launchdarkly.com', - flushInterval: 2000, + flushInterval: 2, inspectors: [], logger: { destination: console.error, @@ -27,9 +27,8 @@ describe('Configuration', () => { }, privateAttributes: [], sendEvents: true, - sendEventsOnlyForVariation: false, sendLDHeaders: true, - streamReconnectDelay: 1000, + streamInitialReconnectDelay: 1, streamUri: 'https://clientstream.launchdarkly.com', useReport: false, }); @@ -72,12 +71,10 @@ describe('Configuration', () => { test('enforce minimum', () => { const config = new Configuration({ flushInterval: 1 }); - expect(config.flushInterval).toEqual(2000); + expect(config.flushInterval).toEqual(2); expect(console.error).toHaveBeenNthCalledWith( 1, - expect.stringContaining( - '"flushInterval" had invalid value of 1, using minimum of 2000 instead', - ), + expect.stringContaining('"flushInterval" had invalid value of 1, using minimum of 2 instead'), ); }); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index d3a10c8e7..0c21ca0e4 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -3,6 +3,7 @@ import { LDFlagSet, NumberWithMinimum, OptionMessages, + ServiceEndpoints, TypeValidators, } from '@launchdarkly/js-sdk-common'; @@ -11,22 +12,24 @@ import type LDOptions from '../api/LDOptions'; import validators from './validators'; export default class Configuration { + public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; + public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; + public readonly logger = createSafeLogger(); - public readonly baseUri = 'https://sdk.launchdarkly.com'; - public readonly eventsUri = 'https://events.launchdarkly.com'; - public readonly streamUri = 'https://clientstream.launchdarkly.com'; + public readonly baseUri = Configuration.DEFAULT_POLLING; + public readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; + public readonly streamUri = Configuration.DEFAULT_STREAM; public readonly capacity = 100; - public readonly diagnosticRecordingInterval = 900000; - public readonly flushInterval = 2000; - public readonly streamReconnectDelay = 1000; + public readonly diagnosticRecordingInterval = 900; + public readonly flushInterval = 2; + public readonly streamInitialReconnectDelay = 1; public readonly allAttributesPrivate = false; public readonly diagnosticOptOut = false; public readonly evaluationReasons = false; public readonly sendEvents = true; - public readonly sendEventsOnlyForVariation = false; public readonly sendLDHeaders = true; public readonly useReport = false; @@ -37,15 +40,20 @@ export default class Configuration { public readonly bootstrap?: 'localStorage' | LDFlagSet; public readonly requestHeaderTransform?: (headers: Map) => Map; public readonly stream?: boolean; + public readonly hash?: string; public readonly wrapperName?: string; public readonly wrapperVersion?: string; + public readonly serviceEndpoints: ServiceEndpoints; + // Allow indexing Configuration by a string [index: string]: any; constructor(pristineOptions: LDOptions = {}) { const errors = this.validateTypesAndNames(pristineOptions); errors.forEach((e: string) => this.logger.warn(e)); + + this.serviceEndpoints = new ServiceEndpoints(this.streamUri, this.baseUri, this.eventsUri); } validateTypesAndNames(pristineOptions: LDOptions): string[] { diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index eaecc93a8..af4a9670e 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -21,15 +21,14 @@ const validators: Record = { eventsUri: TypeValidators.String, capacity: TypeValidators.numberWithMin(1), - diagnosticRecordingInterval: TypeValidators.numberWithMin(2000), - flushInterval: TypeValidators.numberWithMin(2000), - streamReconnectDelay: TypeValidators.numberWithMin(0), + diagnosticRecordingInterval: TypeValidators.numberWithMin(2), + flushInterval: TypeValidators.numberWithMin(2), + streamInitialReconnectDelay: TypeValidators.numberWithMin(0), allAttributesPrivate: TypeValidators.Boolean, diagnosticOptOut: TypeValidators.Boolean, evaluationReasons: TypeValidators.Boolean, sendEvents: TypeValidators.Boolean, - sendEventsOnlyForVariation: TypeValidators.Boolean, sendLDHeaders: TypeValidators.Boolean, useReport: TypeValidators.Boolean, @@ -46,6 +45,7 @@ const validators: Record = { stream: TypeValidators.NullableBoolean, wrapperName: TypeValidators.String, wrapperVersion: TypeValidators.String, + hash: TypeValidators.String, }; export default validators; diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts new file mode 100644 index 000000000..64f741044 --- /dev/null +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts @@ -0,0 +1,61 @@ +import { secondsToMillis } from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; +import createDiagnosticsInitConfig, { + type DiagnosticsInitConfig, +} from './createDiagnosticsInitConfig'; + +describe('createDiagnosticsInitConfig', () => { + let initConfig: DiagnosticsInitConfig; + + beforeEach(() => { + initConfig = createDiagnosticsInitConfig(new Configuration()); + }); + + test('defaults', () => { + expect(initConfig).toEqual({ + allAttributesPrivate: false, + bootstrapMode: false, + customBaseURI: false, + customEventsURI: false, + customStreamURI: false, + diagnosticRecordingIntervalMillis: secondsToMillis(900), + eventsCapacity: 100, + eventsFlushIntervalMillis: secondsToMillis(2), + reconnectTimeMillis: secondsToMillis(1), + streamingDisabled: true, + usingSecureMode: false, + }); + }); + + test('non-default config', () => { + const custom = createDiagnosticsInitConfig( + new Configuration({ + baseUri: 'https://dev.ld.com', + streamUri: 'https://stream.ld.com', + eventsUri: 'https://events.ld.com', + capacity: 1, + flushInterval: 2, + streamInitialReconnectDelay: 3, + diagnosticRecordingInterval: 4, + stream: true, + allAttributesPrivate: true, + hash: 'test-hash', + bootstrap: 'localStorage', + }), + ); + expect(custom).toEqual({ + allAttributesPrivate: true, + bootstrapMode: true, + customBaseURI: true, + customEventsURI: true, + customStreamURI: true, + diagnosticRecordingIntervalMillis: 4000, + eventsCapacity: 1, + eventsFlushIntervalMillis: 2000, + reconnectTimeMillis: 3000, + streamingDisabled: false, + usingSecureMode: true, + }); + }); +}); diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts new file mode 100644 index 000000000..d854d1d69 --- /dev/null +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts @@ -0,0 +1,35 @@ +import { secondsToMillis, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; + +export type DiagnosticsInitConfig = { + // dom & server common properties + customBaseURI: boolean; + customStreamURI: boolean; + customEventsURI: boolean; + eventsCapacity: number; + eventsFlushIntervalMillis: number; + reconnectTimeMillis: number; + diagnosticRecordingIntervalMillis: number; + streamingDisabled: boolean; + allAttributesPrivate: boolean; + + // dom specific properties + usingSecureMode: boolean; + bootstrapMode: boolean; +}; +const createDiagnosticsInitConfig = (config: Configuration): DiagnosticsInitConfig => ({ + customBaseURI: config.baseUri !== Configuration.DEFAULT_POLLING, + customStreamURI: config.streamUri !== Configuration.DEFAULT_STREAM, + customEventsURI: config.eventsUri !== ServiceEndpoints.DEFAULT_EVENTS, + eventsCapacity: config.capacity, + eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), + reconnectTimeMillis: secondsToMillis(config.streamInitialReconnectDelay), + diagnosticRecordingIntervalMillis: secondsToMillis(config.diagnosticRecordingInterval), + streamingDisabled: !config.stream, + allAttributesPrivate: config.allAttributesPrivate, + usingSecureMode: !!config.hash, + bootstrapMode: !!config.bootstrap, +}); + +export default createDiagnosticsInitConfig; diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts new file mode 100644 index 000000000..c1ed9928a --- /dev/null +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts @@ -0,0 +1,22 @@ +import { internal, Platform } from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; +import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; + +const createDiagnosticsManager = ( + clientSideID: string, + config: Configuration, + platform: Platform, +) => { + if (config.sendEvents && !config.diagnosticOptOut) { + return new internal.DiagnosticsManager( + clientSideID, + platform, + createDiagnosticsInitConfig(config), + ); + } + + return undefined; +}; + +export default createDiagnosticsManager; diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts new file mode 100644 index 000000000..503705339 --- /dev/null +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -0,0 +1,21 @@ +import { ClientContext, internal, subsystem } from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; +import { PlatformDom } from '../platform/PlatformDom'; + +const createEventProcessor = ( + clientSideID: string, + config: Configuration, + platform: PlatformDom, + diagnosticsManager?: internal.DiagnosticsManager, +): subsystem.LDEventProcessor => + config.sendEvents + ? new internal.EventProcessor( + { ...config, eventsCapacity: config.capacity }, + new ClientContext(clientSideID, config, platform), + undefined, + diagnosticsManager, + ) + : new internal.NullEventProcessor(); + +export default createEventProcessor; diff --git a/packages/shared/sdk-client/src/platform/PlatformDom.ts b/packages/shared/sdk-client/src/platform/PlatformDom.ts new file mode 100644 index 000000000..6e7c4f288 --- /dev/null +++ b/packages/shared/sdk-client/src/platform/PlatformDom.ts @@ -0,0 +1,13 @@ +import { Platform } from '@launchdarkly/js-sdk-common'; + +export interface Storage { + get(key: string): Promise; + + set(key: string, value: string): Promise; + + clear(): Promise; +} + +export interface PlatformDom extends Platform { + storage: Storage; +} diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json index 621613945..365daad86 100644 --- a/packages/shared/sdk-client/tsconfig.json +++ b/packages/shared/sdk-client/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "es6", + "target": "ES2017", "lib": ["es6", "DOM"], "module": "commonjs", "strict": true, diff --git a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts index 5861068c2..ecca60277 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts @@ -1,9 +1,12 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { LDClientImpl } from '../src'; import TestData from '../src/integrations/test_data/TestData'; -import basicPlatform from './evaluation/mocks/platform'; import TestLogger, { LogLevel } from './Logger'; import makeCallbacks from './makeCallbacks'; +const { mocks } = internal; + const defaultUser = { key: 'user' }; describe('given an LDClient with test data', () => { @@ -16,7 +19,7 @@ describe('given an LDClient with test data', () => { td = new TestData(); client = new LDClientImpl( 'sdk-key', - basicPlatform, + mocks.basicPlatform, { updateProcessor: td.getFactory(), sendEvents: false, @@ -282,7 +285,7 @@ describe('given an offline client', () => { td = new TestData(); client = new LDClientImpl( 'sdk-key', - basicPlatform, + mocks.basicPlatform, { offline: true, updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts index a5665de72..08e03fdc4 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts @@ -1,14 +1,29 @@ -import { LDClientImpl } from '../src'; -import { LDFeatureStore, LDStreamProcessor } from '../src/api/subsystems'; -import NullUpdateProcessor from '../src/data_sources/NullUpdateProcessor'; +import { internal, subsystem } from '@launchdarkly/js-sdk-common'; + +import { LDClientImpl, LDFeatureStore } from '../src'; import TestData from '../src/integrations/test_data/TestData'; import AsyncStoreFacade from '../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../src/store/VersionedDataKinds'; -import basicPlatform from './evaluation/mocks/platform'; import TestLogger, { LogLevel } from './Logger'; import makeCallbacks from './makeCallbacks'; +jest.mock('@launchdarkly/js-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-sdk-common'); + return { + ...actual, + ...{ + internal: { + ...actual.internal, + StreamingProcessor: actual.internal.mocks.MockStreamingProcessor, + }, + }, + }; +}); +const { + mocks: { basicPlatform, setupMockStreamingProcessor }, +} = internal; + const defaultUser = { key: 'user' }; describe('given an LDClient with test data', () => { @@ -208,7 +223,7 @@ describe('given an offline client', () => { }); }); -class InertUpdateProcessor implements LDStreamProcessor { +class InertUpdateProcessor implements subsystem.LDStreamProcessor { // eslint-disable-next-line @typescript-eslint/no-unused-vars start(fn?: ((err?: any) => void) | undefined) { // Never initialize. @@ -283,12 +298,12 @@ describe('given a client that is un-initialized and store that is initialized', }, segments: {}, }); + setupMockStreamingProcessor(true); client = new LDClientImpl( 'sdk-key', basicPlatform, { - updateProcessor: new NullUpdateProcessor(), sendEvents: false, featureStore: store, }, diff --git a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts index 393387ae5..1a9eb6ab3 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts @@ -2,9 +2,10 @@ import { Context, internal } from '@launchdarkly/js-sdk-common'; import { LDClientImpl } from '../src'; import TestData from '../src/integrations/test_data/TestData'; -import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; +const { mocks } = internal; + const defaultUser = { key: 'user' }; const anonymousUser = { key: 'anon-user', anonymous: true }; @@ -25,7 +26,7 @@ describe('given a client with mock event processor', () => { td = new TestData(); client = new LDClientImpl( 'sdk-key', - basicPlatform, + mocks.basicPlatform, { updateProcessor: td.getFactory(), }, diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts index d50c92f31..0fcaada06 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts @@ -1,4 +1,4 @@ -import { Crypto, Hasher, Hmac } from '@launchdarkly/js-sdk-common'; +import { Crypto, Hasher, Hmac, internal } from '@launchdarkly/js-sdk-common'; import { BigSegmentStore } from '../src/api/interfaces'; import { LDBigSegmentsOptions } from '../src/api/options/LDBigSegmentsOptions'; @@ -6,9 +6,10 @@ import makeBigSegmentRef from '../src/evaluation/makeBigSegmentRef'; import TestData from '../src/integrations/test_data/TestData'; import LDClientImpl from '../src/LDClientImpl'; import { makeSegmentMatchClause } from './evaluation/flags'; -import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; +const { mocks } = internal; + const user = { key: 'userkey' }; const bigSegment = { key: 'segmentkey', @@ -76,7 +77,7 @@ describe('given test data with big segments', () => { client = new LDClientImpl( 'sdk-key', - { ...basicPlatform, crypto }, + { ...mocks.basicPlatform, crypto }, { updateProcessor: td.getFactory(), sendEvents: false, @@ -115,7 +116,7 @@ describe('given test data with big segments', () => { client = new LDClientImpl( 'sdk-key', - { ...basicPlatform, crypto }, + { ...mocks.basicPlatform, crypto }, { updateProcessor: td.getFactory(), sendEvents: false, @@ -154,7 +155,7 @@ describe('given test data with big segments', () => { client = new LDClientImpl( 'sdk-key', - { ...basicPlatform, crypto }, + { ...mocks.basicPlatform, crypto }, { updateProcessor: td.getFactory(), sendEvents: false, @@ -181,7 +182,7 @@ describe('given test data with big segments', () => { beforeEach(async () => { client = new LDClientImpl( 'sdk-key', - { ...basicPlatform, crypto }, + { ...mocks.basicPlatform, crypto }, { updateProcessor: td.getFactory(), sendEvents: false, diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts index 6d97ccc4d..4766bdf4e 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts @@ -1,13 +1,18 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; +import { internal } from '@launchdarkly/js-sdk-common'; + import { AttributeReference, LDClientImpl } from '../src'; import { Op } from '../src/evaluation/data/Clause'; import TestData from '../src/integrations/test_data/TestData'; import { makeFlagWithSegmentMatch } from './evaluation/flags'; -import basicPlatform from './evaluation/mocks/platform'; import TestLogger from './Logger'; import makeCallbacks from './makeCallbacks'; +const { + mocks: { basicPlatform }, +} = internal; + describe('given an LDClient with test data', () => { let client: LDClientImpl; let td: TestData; diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts index 60fc3ce30..f10be75cd 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts @@ -1,134 +1,100 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { LDClientImpl } from '../src'; -import basicPlatform from './evaluation/mocks/platform'; -import TestLogger from './Logger'; -import makeCallbacks from './makeCallbacks'; - -it('fires ready event in offline mode', (done) => { - const client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { offline: true }, - { ...makeCallbacks(false), onReady: () => done() }, - ); - client.close(); -}); - -it('fires the failed event if initialization fails', (done) => { - const client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { - updateProcessor: { - start: (fn: (err: any) => void) => { - setTimeout(() => { - fn(new Error('BAD THINGS')); - }, 0); - }, - stop: () => {}, - close: () => {}, - }, - }, - { ...makeCallbacks(false), onFailed: () => done() }, - ); +import { internal } from '@launchdarkly/js-sdk-common'; - client.close(); -}); +import { LDClientImpl, LDOptions } from '../src'; -it('isOffline returns true in offline mode', (done) => { - const client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { offline: true }, - { - ...makeCallbacks(false), - onReady: () => { - expect(client.isOffline()).toEqual(true); - done(); +jest.mock('@launchdarkly/js-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-sdk-common'); + return { + ...actual, + ...{ + internal: { + ...actual.internal, + StreamingProcessor: actual.internal.mocks.MockStreamingProcessor, }, }, - ); - - client.close(); + }; }); +const { + mocks: { basicPlatform, setupMockStreamingProcessor }, +} = internal; -describe('when waiting for initialization', () => { +describe('LDClientImpl', () => { let client: LDClientImpl; - let resolve: Function; + const callbacks = { + onFailed: jest.fn().mockName('onFailed'), + onError: jest.fn().mockName('onError'), + onReady: jest.fn().mockName('onReady'), + onUpdate: jest.fn().mockName('onUpdate'), + hasEventListeners: jest.fn().mockName('hasEventListeners'), + }; + const createClient = (options: LDOptions = {}) => + new LDClientImpl('sdk-key', basicPlatform, options, callbacks); beforeEach(() => { - client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { - updateProcessor: { - start: (fn: (err?: any) => void) => { - resolve = fn; - }, - stop: () => {}, - close: () => {}, - }, - sendEvents: false, - logger: new TestLogger(), - }, - makeCallbacks(false), - ); + setupMockStreamingProcessor(); }); afterEach(() => { client.close(); + jest.resetAllMocks(); }); - it('resolves when ready', async () => { - resolve(); - await client.waitForInitialization(); + it('fires ready event in online mode', async () => { + client = createClient(); + const initializedClient = await client.waitForInitialization(); + + expect(initializedClient).toEqual(client); + expect(client.initialized()).toBeTruthy(); + expect(callbacks.onReady).toBeCalled(); + expect(callbacks.onFailed).not.toBeCalled(); + expect(callbacks.onError).not.toBeCalled(); + }); + + it('fires ready event in offline mode', async () => { + client = createClient({ offline: true }); + const initializedClient = await client.waitForInitialization(); + + expect(initializedClient).toEqual(client); + expect(client.initialized()).toBeTruthy(); + expect(callbacks.onReady).toBeCalled(); + expect(callbacks.onFailed).not.toBeCalled(); + expect(callbacks.onError).not.toBeCalled(); + }); + + it('initialization fails: failed event fires and initialization promise rejects', async () => { + setupMockStreamingProcessor(true); + client = createClient(); + + await expect(client.waitForInitialization()).rejects.toThrow('failed'); + + expect(client.initialized()).toBeFalsy(); + expect(callbacks.onReady).not.toBeCalled(); + expect(callbacks.onFailed).toBeCalled(); + expect(callbacks.onError).toBeCalled(); + }); + + it('isOffline returns true in offline mode', () => { + client = createClient({ offline: true }); + expect(client.isOffline()).toEqual(true); + }); + + it('does not crash when closing an offline client', () => { + client = createClient({ offline: true }); + expect(() => client.close()).not.toThrow(); }); it('resolves immediately if the client is already ready', async () => { - resolve(); + client = createClient(); await client.waitForInitialization(); await client.waitForInitialization(); }); - it('creates only one Promise', async () => { + it('creates only one Promise when waiting for initialization', async () => { + client = createClient(); const p1 = client.waitForInitialization(); const p2 = client.waitForInitialization(); - resolve(); + expect(p2).toBe(p1); }); }); - -it('does not crash when closing an offline client', () => { - const client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { offline: true }, - makeCallbacks(false), - ); - - expect(() => client.close()).not.toThrow(); - client.close(); -}); - -it('the wait for initialization promise is rejected if initialization fails', (done) => { - const client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { - updateProcessor: { - start: (fn: (err: any) => void) => { - setTimeout(() => { - fn(new Error('BAD THINGS')); - }, 0); - }, - stop: () => {}, - close: () => {}, - }, - sendEvents: false, - }, - makeCallbacks(false), - ); - - client.waitForInitialization().catch(() => done()); - client.close(); -}); diff --git a/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts b/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts index 2a2f376ea..4cfceffe2 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts @@ -1,6 +1,11 @@ -import { ClientContext, Context, Filesystem, WatchHandle } from '@launchdarkly/js-sdk-common'; +import { + ClientContext, + Context, + Filesystem, + internal, + WatchHandle, +} from '@launchdarkly/js-sdk-common'; -import promisify from '../../src/async/promisify'; import { Flag } from '../../src/evaluation/data/Flag'; import { Segment } from '../../src/evaluation/data/Segment'; import Evaluator from '../../src/evaluation/Evaluator'; @@ -9,9 +14,9 @@ import Configuration from '../../src/options/Configuration'; import AsyncStoreFacade from '../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; -import basicPlatform from '../evaluation/mocks/platform'; import TestLogger from '../Logger'; +const { mocks } = internal; const flag1Key = 'flag1'; const flag2Key = 'flag2'; const flag2Value = 'value2'; @@ -119,545 +124,321 @@ describe('given a mock filesystem and memory feature store', () => { let filesystem: MockFilesystem; let logger: TestLogger; let featureStore: InMemoryFeatureStore; - let asyncFeatureStore: AsyncStoreFacade; + let createFileDataSource: any; + let mockInitSuccessHandler: jest.Mock; + let mockErrorHandler: jest.Mock; beforeEach(() => { + jest.useFakeTimers(); + + mockInitSuccessHandler = jest.fn(); + mockErrorHandler = jest.fn(); filesystem = new MockFilesystem(); logger = new TestLogger(); featureStore = new InMemoryFeatureStore(); asyncFeatureStore = new AsyncStoreFacade(featureStore); + jest.spyOn(filesystem, 'readFile'); + jest.spyOn(filesystem, 'watch'); + jest.spyOn(featureStore, 'init'); + + const defaultData = { + flagValues: { + key: 'value', + }, + }; + const defaultDataString = JSON.stringify(defaultData); + const defaultTestFilePath = 'testFile.json'; + const defaultTestFilePathData = [{ path: defaultTestFilePath, data: defaultDataString }]; + + // setup a filesystem of paths pointing to data + // returns an array of paths + const setupFileSystem = (testFiles: { path: string; data: string }[]) => + testFiles.map(({ path, data }) => { + filesystem.fileData[path] = { timestamp: 0, data }; + return path; + }); + + createFileDataSource = async ( + start: boolean = true, + files: { path: string; data: string }[] = defaultTestFilePathData, + simulateMissingFile: boolean = false, + autoUpdate: boolean = false, + yamlParser?: (data: string) => any, + ) => { + const filePaths = setupFileSystem(files); + if (simulateMissingFile) { + filePaths.push('missing-file.json'); + } + const factory = new FileDataSourceFactory({ + paths: filePaths, + autoUpdate, + yamlParser, + }); + + const fileDataSource = factory.create( + new ClientContext( + '', + new Configuration({ + featureStore, + logger, + }), + { + ...mocks.basicPlatform, + fileSystem: filesystem as unknown as Filesystem, + }, + ), + featureStore, + mockInitSuccessHandler, + mockErrorHandler, + ); + + if (start) { + fileDataSource.start(); + } + + await jest.runAllTimersAsync(); + return fileDataSource; + }; }); afterEach(() => { jest.resetAllMocks(); + jest.useRealTimers(); }); it('does not load flags prior to start', async () => { - filesystem.fileData['testfile.json'] = { timestamp: 0, data: '{"flagValues":{"key":"value"}}' }; - jest.spyOn(filesystem, 'readFile'); - - const factory = new FileDataSourceFactory({ - paths: ['testfile.json'], - }); + await createFileDataSource(false); - factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); expect(await asyncFeatureStore.initialized()).toBeFalsy(); - expect(await asyncFeatureStore.all(VersionedDataKinds.Features)).toEqual({}); expect(await asyncFeatureStore.all(VersionedDataKinds.Segments)).toEqual({}); // There was no file access. - expect(filesystem.readFile).toHaveBeenCalledTimes(0); + expect(filesystem.readFile).not.toHaveBeenCalled(); }); - it('loads all properties', (done) => { - filesystem.fileData['testfile.json'] = { timestamp: 0, data: allPropertiesJson }; - jest.spyOn(filesystem, 'readFile'); - - const factory = new FileDataSourceFactory({ - paths: ['testfile.json'], - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); + it('loads all properties', async () => { + await createFileDataSource(true, [{ path: 'allProperties.json', data: allPropertiesJson }]); - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); + expect(mockInitSuccessHandler).toBeCalled(); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); - const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); - expect(sorted(Object.keys(flags))).toEqual([flag1Key, flag2Key]); + const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); + expect(sorted(Object.keys(flags))).toEqual([flag1Key, flag2Key]); - const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); - expect(segments).toEqual({ seg1: segment1 }); - expect(filesystem.readFile).toHaveBeenCalledTimes(1); - done(); - }); + const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); + expect(segments).toEqual({ seg1: segment1 }); + expect(filesystem.readFile).toHaveBeenCalledTimes(1); }); - it('does not load if a file it not found', (done) => { - const factory = new FileDataSourceFactory({ - paths: ['missing-file.json'], - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - fds.start(async (err) => { - expect(err).toBeDefined(); - expect(await asyncFeatureStore.initialized()).toBeFalsy(); + it('does not load if a file it not found', async () => { + await createFileDataSource(true, undefined, true); - expect(await asyncFeatureStore.all(VersionedDataKinds.Features)).toEqual({}); - expect(await asyncFeatureStore.all(VersionedDataKinds.Segments)).toEqual({}); - done(); - }); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/not found/i); + expect(filesystem.readFile).toHaveBeenCalledWith('missing-file.json'); + expect(await asyncFeatureStore.initialized()).toBeFalsy(); + expect(await asyncFeatureStore.all(VersionedDataKinds.Features)).toEqual({}); + expect(await asyncFeatureStore.all(VersionedDataKinds.Segments)).toEqual({}); }); - it('does not load if a file was malformed', (done) => { - filesystem.fileData['malformed_file.json'] = { timestamp: 0, data: '{sorry' }; - jest.spyOn(filesystem, 'readFile'); - const factory = new FileDataSourceFactory({ - paths: ['malformed_file.json'], - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - fds.start(async (err) => { - expect(err).toBeDefined(); - expect(await asyncFeatureStore.initialized()).toBeFalsy(); + it('does not load if a file was malformed', async () => { + await createFileDataSource(true, [{ path: 'allProperties.json', data: '{malformed' }]); - expect(await asyncFeatureStore.all(VersionedDataKinds.Features)).toEqual({}); - expect(await asyncFeatureStore.all(VersionedDataKinds.Segments)).toEqual({}); - expect(filesystem.readFile).toHaveBeenCalledWith('malformed_file.json'); - done(); - }); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/expected.*json at position/i); + expect(await asyncFeatureStore.initialized()).toBeFalsy(); + expect(await asyncFeatureStore.all(VersionedDataKinds.Features)).toEqual({}); + expect(await asyncFeatureStore.all(VersionedDataKinds.Segments)).toEqual({}); }); - it('can load multiple files', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; - filesystem.fileData['file2.json'] = { timestamp: 0, data: segmentOnlyJson }; + it('can load multiple files', async () => { + await createFileDataSource(true, [ + { path: 'file1.json', data: flagOnlyJson }, + { path: 'file2.json', data: segmentOnlyJson }, + ]); - jest.spyOn(filesystem, 'readFile'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json', 'file2.json'], - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); + const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); + expect(sorted(Object.keys(flags))).toEqual([flag1Key]); - const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); - expect(sorted(Object.keys(flags))).toEqual([flag1Key]); + const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); + expect(segments).toEqual({ seg1: segment1 }); - const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); - expect(segments).toEqual({ seg1: segment1 }); - expect(filesystem.readFile).toHaveBeenCalledTimes(2); - done(); - }); + expect(filesystem.readFile).toHaveBeenCalledTimes(2); + expect(filesystem.readFile).toHaveBeenNthCalledWith(1, 'file1.json'); + expect(filesystem.readFile).toHaveBeenNthCalledWith(2, 'file2.json'); }); - it('does not allow duplicate keys', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; - filesystem.fileData['file2.json'] = { timestamp: 0, data: flagOnlyJson }; - - jest.spyOn(filesystem, 'readFile'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json', 'file2.json'], - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); + it('does not allow duplicate keys', async () => { + await createFileDataSource(true, [ + { path: 'file1.json', data: flagOnlyJson }, + { path: 'file2.json', data: flagOnlyJson }, + ]); - fds.start(async (err) => { - expect(err).toBeDefined(); - expect(await asyncFeatureStore.initialized()).toBeFalsy(); - expect(filesystem.readFile).toHaveBeenCalledTimes(2); - done(); - }); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/duplicate.*flag1/); + expect(await asyncFeatureStore.initialized()).toBeFalsy(); + expect(filesystem.readFile).toHaveBeenCalledTimes(2); }); - it('does not create watchers if auto-update if off', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; - filesystem.fileData['file2.json'] = { timestamp: 0, data: segmentOnlyJson }; - - jest.spyOn(filesystem, 'watch'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json', 'file2.json'], - }); + it('does not create watchers if auto-update if off', async () => { + await createFileDataSource(true, [ + { path: 'file1.json', data: flagOnlyJson }, + { path: 'file2.json', data: segmentOnlyJson }, + ]); - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); - expect(filesystem.watch).toHaveBeenCalledTimes(0); - done(); - }); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); + expect(filesystem.watch).not.toBeCalled(); }); - it('can evaluate simple loaded flags', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: allPropertiesJson }; + it('can evaluate simple loaded flags', async () => { + await createFileDataSource(true, [{ path: 'file1.json', data: allPropertiesJson }]); - const factory = new FileDataSourceFactory({ - paths: ['file1.json'], + const evaluator = new Evaluator(mocks.basicPlatform, { + getFlag: async (key) => + ((await asyncFeatureStore.get(VersionedDataKinds.Features, key)) as Flag) ?? undefined, + getSegment: async (key) => + ((await asyncFeatureStore.get(VersionedDataKinds.Segments, key)) as Segment) ?? undefined, + getBigSegmentsMembership: () => Promise.resolve(undefined), }); + const flag = await asyncFeatureStore.get(VersionedDataKinds.Features, flag2Key); + const res = await evaluator.evaluate(flag as Flag, Context.fromLDContext({ key: 'userkey' })!); - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - fds.start(async () => { - const evaluator = new Evaluator(basicPlatform, { - getFlag: async (key) => - ((await asyncFeatureStore.get(VersionedDataKinds.Features, key)) as Flag) ?? undefined, - getSegment: async (key) => - ((await asyncFeatureStore.get(VersionedDataKinds.Segments, key)) as Segment) ?? undefined, - getBigSegmentsMembership: () => Promise.resolve(undefined), - }); - - const flag = await asyncFeatureStore.get(VersionedDataKinds.Features, flag2Key); - const res = await evaluator.evaluate( - flag as Flag, - Context.fromLDContext({ key: 'userkey' })!, - ); - expect(res.detail.value).toEqual(flag2Value); - done(); - }); + expect(res.detail.value).toEqual(flag2Value); }); - it('can evaluate full loaded flags', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: allPropertiesJson }; - - const factory = new FileDataSourceFactory({ - paths: ['file1.json'], + it('can evaluate full loaded flags', async () => { + await createFileDataSource(true, [{ path: 'file1.json', data: allPropertiesJson }]); + const evaluator = new Evaluator(mocks.basicPlatform, { + getFlag: async (key) => + ((await asyncFeatureStore.get(VersionedDataKinds.Features, key)) as Flag) ?? undefined, + getSegment: async (key) => + ((await asyncFeatureStore.get(VersionedDataKinds.Segments, key)) as Segment) ?? undefined, + getBigSegmentsMembership: () => Promise.resolve(undefined), }); + const flag = await asyncFeatureStore.get(VersionedDataKinds.Features, flag1Key); + const res = await evaluator.evaluate(flag as Flag, Context.fromLDContext({ key: 'userkey' })!); - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - fds.start(async () => { - const evaluator = new Evaluator(basicPlatform, { - getFlag: async (key) => - ((await asyncFeatureStore.get(VersionedDataKinds.Features, key)) as Flag) ?? undefined, - getSegment: async (key) => - ((await asyncFeatureStore.get(VersionedDataKinds.Segments, key)) as Segment) ?? undefined, - getBigSegmentsMembership: () => Promise.resolve(undefined), - }); - - const flag = await asyncFeatureStore.get(VersionedDataKinds.Features, flag1Key); - const res = await evaluator.evaluate( - flag as Flag, - Context.fromLDContext({ key: 'userkey' })!, - ); - expect(res.detail.value).toEqual('on'); - done(); - }); + expect(res.detail.value).toEqual('on'); }); - it('register watchers when auto update is enabled and unregisters them when it is closed', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; - filesystem.fileData['file2.json'] = { timestamp: 0, data: segmentOnlyJson }; - - jest.spyOn(filesystem, 'watch'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json', 'file2.json'], - autoUpdate: true, - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, + it('register watchers when auto update is enabled and unregisters them when it is closed', async () => { + const fds = await createFileDataSource( + true, + [ + { path: 'file1.json', data: flagOnlyJson }, + { path: 'file2.json', data: segmentOnlyJson }, + ], + false, + true, ); - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); - expect(filesystem.watch).toHaveBeenCalledTimes(2); - expect(filesystem.watches['file1.json'].length).toEqual(1); - expect(filesystem.watches['file2.json'].length).toEqual(1); - fds.close(); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); + expect(filesystem.watch).toHaveBeenCalledTimes(2); + expect(filesystem.watches['file1.json'].length).toEqual(1); + expect(filesystem.watches['file2.json'].length).toEqual(1); + fds.close(); - expect(filesystem.watches['file1.json'].length).toEqual(0); - expect(filesystem.watches['file2.json'].length).toEqual(0); - done(); - }); + expect(filesystem.watches['file1.json'].length).toEqual(0); + expect(filesystem.watches['file2.json'].length).toEqual(0); }); - it('reloads modified files when auto update is enabled', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; + it('reloads modified files when auto update is enabled', async () => { + await createFileDataSource(true, [{ path: 'file1.json', data: flagOnlyJson }], false, true); - jest.spyOn(filesystem, 'watch'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json'], - autoUpdate: true, - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); - const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); - expect(Object.keys(flags).length).toEqual(1); + const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); + expect(Object.keys(flags).length).toEqual(1); - const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); - expect(Object.keys(segments).length).toEqual(0); + const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); + expect(Object.keys(segments).length).toEqual(0); - // Need to update the timestamp, or it will think the file has not changed. - filesystem.fileData['file1.json'] = { timestamp: 100, data: segmentOnlyJson }; - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + // Need to update the timestamp, or it will think the file has not changed. + filesystem.fileData['file1.json'] = { timestamp: 100, data: segmentOnlyJson }; + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - // The handling of the file loading is async, and additionally we debounce - // the callback. So we have to wait a bit to account for the awaits and the debounce. - setTimeout(async () => { - const flags2 = await asyncFeatureStore.all(VersionedDataKinds.Features); - expect(Object.keys(flags2).length).toEqual(0); + await jest.runAllTimersAsync(); + const flags2 = await asyncFeatureStore.all(VersionedDataKinds.Features); + expect(Object.keys(flags2).length).toEqual(0); - const segments2 = await asyncFeatureStore.all(VersionedDataKinds.Segments); - expect(Object.keys(segments2).length).toEqual(1); - done(); - }, 100); - }); + const segments2 = await asyncFeatureStore.all(VersionedDataKinds.Segments); + expect(Object.keys(segments2).length).toEqual(1); }); - it('debounces the callback for file loading', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; + it('debounces the callback for file loading', async () => { + await createFileDataSource(true, [{ path: 'file1.json', data: flagOnlyJson }], false, true); - jest.spyOn(filesystem, 'watch'); - jest.spyOn(featureStore, 'init'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json'], - autoUpdate: true, - }); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); + const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); + expect(Object.keys(flags).length).toEqual(1); - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); - - const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); - expect(Object.keys(flags).length).toEqual(1); - - const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); - expect(Object.keys(segments).length).toEqual(0); - - // Trigger several change callbacks. - filesystem.fileData['file1.json'] = { timestamp: 100, data: segmentOnlyJson }; - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - filesystem.fileData['file1.json'] = { timestamp: 101, data: segmentOnlyJson }; - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - filesystem.fileData['file1.json'] = { timestamp: 102, data: segmentOnlyJson }; - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - filesystem.fileData['file1.json'] = { timestamp: 103, data: segmentOnlyJson }; - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - - // The handling of the file loading is async, and additionally we debounce - // the callback. So we have to wait a bit to account for the awaits and the debounce. - setTimeout(async () => { - // Once for the start, and then again for the coalesced update. - expect(featureStore.init).toHaveBeenCalledTimes(2); - done(); - }, 100); - }); + const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); + expect(Object.keys(segments).length).toEqual(0); + + // Trigger several change callbacks. + filesystem.fileData['file1.json'] = { timestamp: 100, data: segmentOnlyJson }; + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + filesystem.fileData['file1.json'] = { timestamp: 101, data: segmentOnlyJson }; + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + filesystem.fileData['file1.json'] = { timestamp: 102, data: segmentOnlyJson }; + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + filesystem.fileData['file1.json'] = { timestamp: 103, data: segmentOnlyJson }; + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + + // The handling of the file loading is async, and additionally we debounce + // the callback. So we have to wait a bit to account for the awaits and the debounce. + // Once for the start, and then again for the coalesced update. + await jest.runAllTimersAsync(); + expect(featureStore.init).toHaveBeenCalledTimes(2); }); - it('does not callback if the timestamp has not changed', (done) => { - filesystem.fileData['file1.json'] = { timestamp: 0, data: flagOnlyJson }; - - jest.spyOn(filesystem, 'watch'); - jest.spyOn(featureStore, 'init'); - const factory = new FileDataSourceFactory({ - paths: ['file1.json'], - autoUpdate: true, - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); + it('does not callback if the timestamp has not changed', async () => { + await createFileDataSource(true, [{ path: 'file1.json', data: flagOnlyJson }], false, true); - fds.start(async () => { - expect(await asyncFeatureStore.initialized()).toBeTruthy(); + expect(await asyncFeatureStore.initialized()).toBeTruthy(); - const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); - expect(Object.keys(flags).length).toEqual(1); + const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); + expect(Object.keys(flags).length).toEqual(1); - const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); - expect(Object.keys(segments).length).toEqual(0); + const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); + expect(Object.keys(segments).length).toEqual(0); - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); + filesystem.watches['file1.json'][0].cb('change', 'file1.json'); - // The handling of the file loading is async, and additionally we debounce - // the callback. So we have to wait a bit to account for the awaits and the debounce. - setTimeout(async () => { - // Once for the start. - expect(featureStore.init).toHaveBeenCalledTimes(1); - done(); - }, 100); - }); + await jest.runAllTimersAsync(); + // Once for the start. + expect(featureStore.init).toHaveBeenCalledTimes(1); }); it.each([['yml'], ['yaml']])( 'does not initialize when a yaml file is specified, but no parser is provided %s', async (ext) => { - jest.spyOn(filesystem, 'readFile'); const fileName = `yamlfile.${ext}`; - filesystem.fileData[fileName] = { timestamp: 0, data: '' }; - const factory = new FileDataSourceFactory({ - paths: [fileName], - }); + await createFileDataSource(true, [{ path: fileName, data: '' }]); - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, - ); - - const err = await promisify((cb) => { - fds.start(cb); - }); - - expect((err as any).message).toEqual( + expect(mockErrorHandler.mock.lastCall[0].message).toEqual( `Attempted to parse yaml file (yamlfile.${ext}) without parser.`, ); expect(await asyncFeatureStore.initialized()).toBeFalsy(); - expect(await asyncFeatureStore.all(VersionedDataKinds.Features)).toEqual({}); expect(await asyncFeatureStore.all(VersionedDataKinds.Segments)).toEqual({}); }, ); it.each([['yml'], ['yaml']])('uses the yaml parser when specified %s', async (ext) => { - const parser = jest.fn(() => JSON.parse(allPropertiesJson)); - - jest.spyOn(filesystem, 'readFile'); + const yamlParser = jest.fn(() => JSON.parse(allPropertiesJson)); const fileName = `yamlfile.${ext}`; - filesystem.fileData[fileName] = { timestamp: 0, data: 'the data' }; - const factory = new FileDataSourceFactory({ - paths: [fileName], - yamlParser: parser, - }); - - const fds = factory.create( - new ClientContext( - '', - new Configuration({ - featureStore, - logger, - }), - { ...basicPlatform, fileSystem: filesystem as unknown as Filesystem }, - ), - featureStore, + await createFileDataSource( + true, + [{ path: fileName, data: 'the data' }], + undefined, + undefined, + yamlParser, ); - const err = await promisify((cb) => { - fds.start(cb); - }); - - expect(err).toBeUndefined(); + expect(mockErrorHandler).not.toBeCalled(); expect(await asyncFeatureStore.initialized()).toBeTruthy(); const flags = await asyncFeatureStore.all(VersionedDataKinds.Features); @@ -666,6 +447,6 @@ describe('given a mock filesystem and memory feature store', () => { const segments = await asyncFeatureStore.all(VersionedDataKinds.Segments); expect(segments).toEqual({ seg1: segment1 }); expect(filesystem.readFile).toHaveBeenCalledTimes(1); - expect(parser).toHaveBeenCalledWith('the data'); + expect(yamlParser).toHaveBeenCalledWith('the data'); }); }); diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index b1361307f..f4d1320cf 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -1,16 +1,16 @@ -import { ClientContext } from '@launchdarkly/js-sdk-common'; +import { ClientContext, internal } from '@launchdarkly/js-sdk-common'; -import { LDFeatureStore } from '../../src/api/subsystems'; -import promisify from '../../src/async/promisify'; +import { LDFeatureStore } from '../../src'; import PollingProcessor from '../../src/data_sources/PollingProcessor'; import Requestor from '../../src/data_sources/Requestor'; import Configuration from '../../src/options/Configuration'; import AsyncStoreFacade from '../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; -import basicPlatform from '../evaluation/mocks/platform'; import TestLogger, { LogLevel } from '../Logger'; +const { mocks } = internal; + describe('given an event processor', () => { const requestor = { requestAllData: jest.fn(), @@ -23,6 +23,7 @@ describe('given an event processor', () => { let storeFacade: AsyncStoreFacade; let config: Configuration; let processor: PollingProcessor; + let initSuccessHandler: jest.Mock; beforeEach(() => { store = new InMemoryFeatureStore(); @@ -32,10 +33,13 @@ describe('given an event processor', () => { pollInterval: longInterval, logger: new TestLogger(), }); + initSuccessHandler = jest.fn(); + processor = new PollingProcessor( config, requestor as unknown as Requestor, - config.featureStoreFactory(new ClientContext('', config, basicPlatform)), + config.featureStoreFactory(new ClientContext('', config, mocks.basicPlatform)), + initSuccessHandler, ); }); @@ -49,25 +53,25 @@ describe('given an event processor', () => { }); it('polls immediately on start', () => { - processor.start(() => {}); + processor.start(); expect(requestor.requestAllData).toHaveBeenCalledTimes(1); }); - it('calls callback on success', (done) => { + it('calls callback on success', () => { requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData)); - - processor.start(() => done()); + processor.start(); + expect(initSuccessHandler).toBeCalled(); }); it('initializes the feature store', async () => { requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData)); - await promisify((cb) => processor.start(cb)); - + processor.start(); const flags = await storeFacade.all(VersionedDataKinds.Features); - expect(flags).toEqual(allData.flags); const segments = await storeFacade.all(VersionedDataKinds.Segments); + + expect(flags).toEqual(allData.flags); expect(segments).toEqual(allData.segments); }); }); @@ -83,6 +87,8 @@ describe('given a polling processor with a short poll duration', () => { let store: LDFeatureStore; let config: Configuration; let processor: PollingProcessor; + let initSuccessHandler: jest.Mock; + let errorHandler: jest.Mock; beforeEach(() => { store = new InMemoryFeatureStore(); @@ -91,24 +97,29 @@ describe('given a polling processor with a short poll duration', () => { pollInterval: shortInterval, logger: new TestLogger(), }); + initSuccessHandler = jest.fn(); + errorHandler = jest.fn(); + // Configuration will not let us set this as low as needed for the test. Object.defineProperty(config, 'pollInterval', { value: 0.1 }); processor = new PollingProcessor( config, requestor as unknown as Requestor, - config.featureStoreFactory(new ClientContext('', config, basicPlatform)), + config.featureStoreFactory(new ClientContext('', config, mocks.basicPlatform)), + initSuccessHandler, + errorHandler, ); }); afterEach(() => { processor.stop(); - jest.restoreAllMocks(); + jest.resetAllMocks(); }); it('polls repeatedly', (done) => { requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData)); - processor.start(() => {}); + processor.start(); setTimeout(() => { expect(requestor.requestAllData.mock.calls.length).toBeGreaterThanOrEqual(4); done(); @@ -126,10 +137,11 @@ describe('given a polling processor with a short poll duration', () => { undefined, ), ); - processor.start((e) => { - expect(e).toBeUndefined(); - }); + processor.start(); + + expect(initSuccessHandler).not.toBeCalled(); + expect(errorHandler).not.toBeCalled(); setTimeout(() => { expect(requestor.requestAllData.mock.calls.length).toBeGreaterThanOrEqual(2); const testLogger = config.logger as TestLogger; @@ -142,9 +154,11 @@ describe('given a polling processor with a short poll duration', () => { it('continues polling after receiving invalid JSON', (done) => { requestor.requestAllData = jest.fn((cb) => cb(undefined, '{sad')); - processor.start((e) => { - expect(e).toBeDefined(); - }); + + processor.start(); + + expect(initSuccessHandler).not.toBeCalled(); + expect(errorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); setTimeout(() => { expect(requestor.requestAllData.mock.calls.length).toBeGreaterThanOrEqual(2); @@ -165,9 +179,9 @@ describe('given a polling processor with a short poll duration', () => { undefined, ), ); - processor.start((e) => { - expect(e).toBeDefined(); - }); + processor.start(); + expect(initSuccessHandler).not.toBeCalled(); + expect(errorHandler.mock.lastCall[0].message).toMatch(new RegExp(`${status}.*permanently`)); setTimeout(() => { expect(requestor.requestAllData.mock.calls.length).toBe(1); diff --git a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts index 0077d8caa..fd47069a0 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts @@ -3,6 +3,7 @@ import { EventSource, EventSourceInitDict, Headers, + internal, Options, Requests, Response, @@ -11,7 +12,8 @@ import { import promisify from '../../src/async/promisify'; import Requestor from '../../src/data_sources/Requestor'; import Configuration from '../../src/options/Configuration'; -import basicPlatform from '../evaluation/mocks/platform'; + +const { mocks } = internal; describe('given a requestor', () => { let requestor: Requestor; @@ -80,7 +82,7 @@ describe('given a requestor', () => { }, }; - requestor = new Requestor('sdkKey', new Configuration({}), basicPlatform.info, requests); + requestor = new Requestor('sdkKey', new Configuration({}), mocks.basicPlatform.info, requests); }); it('gets data', (done) => { diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts index ecceb74ad..4d66ee0cf 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts @@ -1,297 +1,211 @@ -import { - EventSource, - EventSourceInitDict, - Info, - Options, - PlatformData, - Requests, - Response, - SdkData, -} from '@launchdarkly/js-sdk-common'; - -import promisify from '../../src/async/promisify'; -import defaultHeaders from '../../src/data_sources/defaultHeaders'; +// FOR REVIEW ONLY: this has been moved to shared/common and is only here for PR +// review. Delete when review is finished. +import { EventName, ProcessStreamResponse } from '../../api'; +import { LDStreamProcessor } from '../../api/subsystem'; +import { LDStreamingError } from '../../errors'; import StreamingProcessor from '../../src/data_sources/StreamingProcessor'; -import DiagnosticsManager from '../../src/events/DiagnosticsManager'; -import NullEventSource from '../../src/events/NullEventSource'; -import Configuration from '../../src/options/Configuration'; -import AsyncStoreFacade from '../../src/store/AsyncStoreFacade'; -import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; -import VersionedDataKinds from '../../src/store/VersionedDataKinds'; -import basicPlatform from '../evaluation/mocks/platform'; -import TestLogger, { LogLevel } from '../Logger'; +import { defaultHeaders } from '../../utils'; +import { DiagnosticsManager } from '../diagnostics'; +import { basicPlatform, clientContext, logger } from '../mocks'; +const dateNowString = '2023-08-10'; const sdkKey = 'my-sdk-key'; - -const info: Info = { - platformData(): PlatformData { - return {}; - }, - sdkData(): SdkData { - const sdkData: SdkData = { - version: '2.2.2', - }; - return sdkData; +const { + basicConfiguration: { serviceEndpoints, tags }, + platform: { info }, +} = clientContext; +const event = { + data: { + flags: { + flagkey: { key: 'flagkey', version: 1 }, + }, + segments: { + segkey: { key: 'segkey', version: 2 }, + }, }, }; -function createRequests(cb: (es: NullEventSource) => void): Requests { - return { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - fetch(url: string, options?: Options | undefined): Promise { - throw new Error('Function not implemented.'); - }, - createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - const es = new NullEventSource(url, eventSourceInitDict); - cb(es); - return es; - }, - }; -} +const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), +}); describe('given a stream processor with mock event source', () => { - let es: NullEventSource; - let requests: Requests; - let featureStore: InMemoryFeatureStore; - let streamProcessor: StreamingProcessor; - let config: Configuration; - let asyncStore: AsyncStoreFacade; - let logger: TestLogger; + let streamingProcessor: LDStreamProcessor; let diagnosticsManager: DiagnosticsManager; + let listeners: Map; + let mockEventSource: any; + let mockListener: ProcessStreamResponse; + let mockErrorHandler: jest.Mock; + let simulatePutEvent: (e?: any) => void; + let simulateError: (e: { status: number; message: string }) => boolean; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(dateNowString)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); beforeEach(() => { - requests = createRequests((nes) => { - es = nes; - }); - featureStore = new InMemoryFeatureStore(); - asyncStore = new AsyncStoreFacade(featureStore); - logger = new TestLogger(); - config = new Configuration({ - streamUri: 'http://test', - baseUri: 'http://base.test', - eventsUri: 'http://events.test', - featureStore, - logger, - }); - diagnosticsManager = new DiagnosticsManager('sdk-key', config, basicPlatform, featureStore); - streamProcessor = new StreamingProcessor( + mockErrorHandler = jest.fn(); + clientContext.basicConfiguration.logger = logger; + + basicPlatform.requests = { + createEventSource: jest.fn((streamUri: string, options: any) => { + mockEventSource = createMockEventSource(streamUri, options); + return mockEventSource; + }), + } as any; + simulatePutEvent = (e: any = event) => { + mockEventSource.addEventListener.mock.calls[0][1](e); + }; + simulateError = (e: { status: number; message: string }): boolean => + mockEventSource.options.errorFilter(e); + + listeners = new Map(); + mockListener = { + deserializeData: jest.fn((data) => data), + processJson: jest.fn(), + }; + listeners.set('put', mockListener); + listeners.set('patch', mockListener); + + diagnosticsManager = new DiagnosticsManager(sdkKey, basicPlatform, {}); + streamingProcessor = new StreamingProcessor( sdkKey, - config, - requests, - info, - featureStore, + clientContext, + listeners, diagnosticsManager, + mockErrorHandler, ); + + jest.spyOn(streamingProcessor, 'stop'); + streamingProcessor.start(); }); - async function promiseStart() { - return promisify((cb) => streamProcessor.start(cb)); - } + afterEach(() => { + streamingProcessor.close(); + jest.resetAllMocks(); + }); - function expectJsonError(err: { message?: string }) { - expect(err).toBeDefined(); - expect(err.message).toEqual('Malformed JSON data in event stream'); - logger.expectMessages([ + it('uses expected uri', () => { + expect(basicPlatform.requests.createEventSource).toBeCalledWith( + `${serviceEndpoints.streaming}/all`, { - level: LogLevel.Error, - matches: /Stream received invalid data in/, + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, tags), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, }, - ]); - } - - it('uses expected URL', () => { - streamProcessor.start(); - expect(es.url).toEqual(`${config.serviceEndpoints.streaming}/all`); + ); }); - it('sets expected headers', () => { - streamProcessor.start(); - expect(es.options.headers).toMatchObject(defaultHeaders(sdkKey, config, info)); + it('adds listeners', () => { + expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( + 1, + 'put', + expect.any(Function), + ); + expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( + 2, + 'patch', + expect.any(Function), + ); }); - describe('when putting a message', () => { - const putData = { - data: { - flags: { - flagkey: { key: 'flagkey', version: 1 }, - }, - segments: { - segkey: { key: 'segkey', version: 2 }, - }, - }, - }; - - it('causes flags and segments to be stored', async () => { - streamProcessor.start(); - es.handlers.put({ data: JSON.stringify(putData) }); - const initialized = await asyncStore.initialized(); - expect(initialized).toBeTruthy(); - - const f = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); - expect(f?.version).toEqual(1); - const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s?.version).toEqual(2); - }); - - it('calls initialization callback', async () => { - const promise = promiseStart(); - es.handlers.put({ data: JSON.stringify(putData) }); - expect(await promise).toBeUndefined(); - }); - - it('passes error to callback if data is invalid', async () => { - streamProcessor.start(); - - const promise = promiseStart(); - es.handlers.put({ data: '{not-good' }); - const result = await promise; - expectJsonError(result as any); - }); - - it('creates a stream init event', async () => { - const startTime = Date.now(); - streamProcessor.start(); - es.handlers.put({ data: JSON.stringify(putData) }); - await asyncStore.initialized(); + it('executes listeners', () => { + simulatePutEvent(); + const patchHandler = mockEventSource.addEventListener.mock.calls[1][1]; + patchHandler(event); - const event = diagnosticsManager.createStatsEventAndReset(0, 0, 0); - expect(event.streamInits.length).toEqual(1); - const si = event.streamInits[0]; - expect(si.timestamp).toBeGreaterThanOrEqual(startTime); - expect(si.failed).toBeFalsy(); - expect(si.durationMillis).toBeGreaterThanOrEqual(0); - }); + expect(mockListener.deserializeData).toBeCalledTimes(2); + expect(mockListener.processJson).toBeCalledTimes(2); }); - describe('when patching a message', () => { - it('updates a patched flag', async () => { - streamProcessor.start(); - const patchData = { - path: '/flags/flagkey', - data: { key: 'flagkey', version: 1 }, - }; - - es.handlers.patch({ data: JSON.stringify(patchData) }); - - const f = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); - expect(f!.version).toEqual(1); - }); - - it('updates a patched segment', async () => { - streamProcessor.start(); - const patchData = { - path: '/segments/segkey', - data: { key: 'segkey', version: 1 }, - }; - - es.handlers.patch({ data: JSON.stringify(patchData) }); + it('passes error to callback if json data is malformed', async () => { + (mockListener.deserializeData as jest.Mock).mockReturnValue(false); + simulatePutEvent(); - const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s!.version).toEqual(1); - }); - - it('passes error to callback if data is invalid', async () => { - streamProcessor.start(); - - const promise = promiseStart(); - es.handlers.patch({ data: '{not-good' }); - const result = await promise; - expectJsonError(result as any); - }); + expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/)); + expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i)); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); }); - describe('when deleting a message', () => { - it('deletes a flag', async () => { - streamProcessor.start(); - const flag = { key: 'flagkey', version: 1 }; - await asyncStore.upsert(VersionedDataKinds.Features, flag); - const f = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); - expect(f!.version).toEqual(1); - - const deleteData = { path: `/flags/${flag.key}`, version: 2 }; - - es.handlers.delete({ data: JSON.stringify(deleteData) }); - - const f2 = await asyncStore.get(VersionedDataKinds.Features, 'flagkey'); - expect(f2).toBe(null); - }); - - it('deletes a segment', async () => { - streamProcessor.start(); - const segment = { key: 'segkey', version: 1 }; - await asyncStore.upsert(VersionedDataKinds.Segments, segment); - const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s!.version).toEqual(1); - - const deleteData = { path: `/segments/${segment.key}`, version: 2 }; - - es.handlers.delete({ data: JSON.stringify(deleteData) }); + it('calls error handler if event.data prop is missing', async () => { + simulatePutEvent({ flags: {} }); - const s2 = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); - expect(s2).toBe(null); - }); + expect(mockListener.deserializeData).not.toBeCalled(); + expect(mockListener.processJson).not.toBeCalled(); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i); + }); - it('passes error to callback if data is invalid', async () => { - streamProcessor.start(); + it.only('closes and stops', async () => { + streamingProcessor.close(); - const promise = promiseStart(); - es.handlers.delete({ data: '{not-good' }); - const result = await promise; - expectJsonError(result as any); - }); + expect(streamingProcessor.stop).toBeCalled(); + expect(mockEventSource.close).toBeCalled(); + // @ts-ignore + expect(streamingProcessor.eventSource).toBeUndefined(); }); - describe.each([400, 408, 429, 500, 503, undefined])('given recoverable http errors', (status) => { - const err = { - status, - message: 'sorry', - }; + it('creates a stream init event', async () => { + const startTime = Date.now(); + simulatePutEvent(); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeFalsy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + describe.each([400, 408, 429, 500, 503])('given recoverable http errors', (status) => { it(`continues retrying after error: ${status}`, () => { const startTime = Date.now(); - streamProcessor.start(); - es.simulateError(err as any); - - logger.expectMessages([ - { - level: LogLevel.Warn, - matches: status - ? new RegExp(`error ${err.status}.*will retry`) - : /Received I\/O error \(sorry\) for streaming request - will retry/, - }, - ]); - - const event = diagnosticsManager.createStatsEventAndReset(0, 0, 0); - expect(event.streamInits.length).toEqual(1); - const si = event.streamInits[0]; - expect(si.timestamp).toBeGreaterThanOrEqual(startTime); + const testError = { status, message: 'retry. recoverable.' }; + const willRetry = simulateError(testError); + + expect(willRetry).toBeTruthy(); + expect(mockErrorHandler).not.toBeCalled(); + expect(logger.warn).toBeCalledWith( + expect.stringMatching(new RegExp(`${status}.*will retry`)), + ); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); expect(si.failed).toBeTruthy(); expect(si.durationMillis).toBeGreaterThanOrEqual(0); }); }); - describe.each([401, 403])('given unrecoverable http errors', (status) => { - const startTime = Date.now(); - const err = { - status, - message: 'sorry', - }; - + describe.each([401, 403])('given irrecoverable http errors', (status) => { it(`stops retrying after error: ${status}`, () => { - streamProcessor.start(); - es.simulateError(err as any); - - logger.expectMessages([ - { - level: LogLevel.Error, - matches: /Received error.*giving up permanently/, - }, - ]); - - const event = diagnosticsManager.createStatsEventAndReset(0, 0, 0); - expect(event.streamInits.length).toEqual(1); - const si = event.streamInits[0]; - expect(si.timestamp).toBeGreaterThanOrEqual(startTime); + const startTime = Date.now(); + const testError = { status, message: 'stopping. irrecoverable.' }; + const willRetry = simulateError(testError); + + expect(willRetry).toBeFalsy(); + expect(mockErrorHandler).toBeCalledWith( + new LDStreamingError(testError.message, testError.status), + ); + expect(logger.error).toBeCalledWith( + expect.stringMatching(new RegExp(`${status}.*permanently`)), + ); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); expect(si.failed).toBeTruthy(); expect(si.durationMillis).toBeGreaterThanOrEqual(0); }); diff --git a/packages/shared/sdk-server/__tests__/data_sources/defaultHeaders.test.ts b/packages/shared/sdk-server/__tests__/data_sources/defaultHeaders.test.ts deleted file mode 100644 index e34761540..000000000 --- a/packages/shared/sdk-server/__tests__/data_sources/defaultHeaders.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Info, PlatformData, SdkData } from '@launchdarkly/js-sdk-common'; - -import defaultHeaders from '../../src/data_sources/defaultHeaders'; -import Configuration from '../../src/options/Configuration'; - -const makeInfo = (wrapperName?: string, wrapperVersion?: string): Info => ({ - platformData(): PlatformData { - return {}; - }, - sdkData(): SdkData { - const sdkData: SdkData = { - version: '2.2.2', - wrapperName, - wrapperVersion, - }; - return sdkData; - }, -}); - -it('sets SDK key', () => { - const config = new Configuration({}); - const h = defaultHeaders('my-sdk-key', config, makeInfo()); - expect(h).toMatchObject({ authorization: 'my-sdk-key' }); -}); - -it('sets user agent', () => { - const config = new Configuration({}); - const h = defaultHeaders('my-sdk-key', config, makeInfo()); - expect(h).toMatchObject({ 'user-agent': 'NodeJSClient/2.2.2' }); -}); - -it('does not include wrapper header by default', () => { - const config = new Configuration({}); - const h = defaultHeaders('my-sdk-key', config, makeInfo()); - expect(h['x-launchdarkly-wrapper']).toBeUndefined(); -}); - -it('sets wrapper header with name only', () => { - const config = new Configuration({}); - const h = defaultHeaders('my-sdk-key', config, makeInfo('my-wrapper')); - expect(h).toMatchObject({ 'x-launchdarkly-wrapper': 'my-wrapper' }); -}); - -it('sets wrapper header with name and version', () => { - const config = new Configuration({}); - const h = defaultHeaders('my-sdk-key', config, makeInfo('my-wrapper', '2.0')); - expect(h).toMatchObject({ 'x-launchdarkly-wrapper': 'my-wrapper/2.0' }); -}); - -it('sets the X-LaunchDarkly-Tags header with valid tags.', () => { - const config = new Configuration({ - application: { - id: 'test-application', - version: 'test-version', - }, - }); - const h = defaultHeaders('my-sdk-key', config, makeInfo('my-wrapper')); - expect(h).toMatchObject({ - 'x-launchdarkly-tags': 'application-id/test-application application-version/test-version', - }); -}); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts index cae445c2b..48c8b2055 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts @@ -2,10 +2,11 @@ // We cannot fully validate bucketing in the common tests. Platform implementations // should contain a consistency test. // Testing here can only validate we are providing correct inputs to the hashing algorithm. -import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; import Bucketer from '../../src/evaluation/Bucketer'; -import { crypto, hasher } from './mocks/hasher'; + +const { mocks } = internal; describe.each< [ @@ -65,7 +66,7 @@ describe.each< const validatedContext = Context.fromLDContext(context); const attrRef = new AttributeReference(attr); - const bucketer = new Bucketer(crypto); + const bucketer = new Bucketer(mocks.crypto); const [bucket, hadContext] = bucketer.bucket( validatedContext!, key, @@ -75,12 +76,12 @@ describe.each< seed, ); - // The hasher always returns the same value. This just checks that it converts it to a number + // The mocks.hasher always returns the same value. This just checks that it converts it to a number // in the expected way. expect(bucket).toBeCloseTo(0.07111111110140983, 5); expect(hadContext).toBeTruthy(); - expect(hasher.update).toHaveBeenCalledWith(expected); - expect(hasher.digest).toHaveBeenCalledWith('hex'); + expect(mocks.hasher.update).toHaveBeenCalledWith(expected); + expect(mocks.hasher.digest).toHaveBeenCalledWith('hex'); }); afterEach(() => { @@ -104,7 +105,7 @@ describe.each([ }); const attrRef = new AttributeReference(attr); - const bucketer = new Bucketer(crypto); + const bucketer = new Bucketer(mocks.crypto); const [bucket, hadContext] = bucketer.bucket( validatedContext!, 'key', @@ -115,8 +116,8 @@ describe.each([ ); expect(bucket).toEqual(0); expect(hadContext).toEqual(kind === 'org'); - expect(hasher.update).toBeCalledTimes(0); - expect(hasher.digest).toBeCalledTimes(0); + expect(mocks.hasher.update).toBeCalledTimes(0); + expect(mocks.hasher.digest).toBeCalledTimes(0); }); afterEach(() => { diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts index 325330c97..a7619b1a3 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts @@ -1,12 +1,13 @@ -import { Context } from '@launchdarkly/js-sdk-common'; +import { Context, internal } from '@launchdarkly/js-sdk-common'; import { Flag } from '../../src/evaluation/data/Flag'; import { Rollout } from '../../src/evaluation/data/Rollout'; import Evaluator from '../../src/evaluation/Evaluator'; import noQueries from './mocks/noQueries'; -import basicPlatform from './mocks/platform'; -const evaluator = new Evaluator(basicPlatform, noQueries); +const { mocks } = internal; + +const evaluator = new Evaluator(mocks.basicPlatform, noQueries); describe('given a flag with a rollout', () => { const seed = 61; diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts index 30e202d8d..82a5ef13e 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts @@ -1,4 +1,4 @@ -import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; import { Clause } from '../../src/evaluation/data/Clause'; import { Flag } from '../../src/evaluation/data/Flag'; @@ -10,9 +10,10 @@ import { makeClauseThatMatchesUser, } from './flags'; import noQueries from './mocks/noQueries'; -import basicPlatform from './mocks/platform'; -const evaluator = new Evaluator(basicPlatform, noQueries); +const { mocks } = internal; + +const evaluator = new Evaluator(mocks.basicPlatform, noQueries); // Either a legacy user, or context with equivalent user. describe('given user clauses and contexts', () => { diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts index 69ccd8287..a96c09a5a 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts @@ -1,6 +1,6 @@ // Tests of flag evaluation at the rule level. Clause-level behavior is covered // in detail in Evaluator.clause.tests and (TODO: File for segments). -import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; import { Clause } from '../../src/evaluation/data/Clause'; import { Flag } from '../../src/evaluation/data/Flag'; @@ -12,13 +12,14 @@ import { makeFlagWithRules, } from './flags'; import noQueries from './mocks/noQueries'; -import basicPlatform from './mocks/platform'; + +const { mocks } = internal; const basicUser: LDContext = { key: 'userkey' }; const basicSingleKindUser: LDContext = { kind: 'user', key: 'userkey' }; const basicMultiKindUser: LDContext = { kind: 'multi', user: { key: 'userkey' } }; -const evaluator = new Evaluator(basicPlatform, noQueries); +const evaluator = new Evaluator(mocks.basicPlatform, noQueries); describe('when evaluating user equivalent contexts', () => { const matchClause = makeClauseThatMatchesUser(basicUser); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts index 5f94defa9..b1a7f83ba 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts @@ -7,6 +7,7 @@ import { Crypto, Hasher, Hmac, + internal, LDContext, } from '@launchdarkly/js-sdk-common'; @@ -20,7 +21,8 @@ import { makeClauseThatMatchesUser, makeFlagWithSegmentMatch, } from './flags'; -import basicPlatform from './mocks/platform'; + +const { mocks } = internal; const basicUser: LDContext = { key: 'userkey' }; const basicSingleKindUser: LDContext = { kind: 'user', key: 'userkey' }; @@ -60,7 +62,10 @@ describe('when evaluating user equivalent contexts for segments', () => { included: [basicUser.key], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(true); @@ -75,7 +80,10 @@ describe('when evaluating user equivalent contexts for segments', () => { excluded: [basicUser.key], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(false); @@ -90,7 +98,7 @@ describe('when evaluating user equivalent contexts for segments', () => { excluded: [basicUser.key], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [] })); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(false); @@ -103,7 +111,7 @@ describe('when evaluating user equivalent contexts for segments', () => { included: ['foo'], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); const user = { key: 'bar' }; const res = await evaluator.evaluate(flag, Context.fromLDContext(user)); @@ -119,7 +127,10 @@ describe('when evaluating user equivalent contexts for segments', () => { excluded: [basicUser.key], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(true); @@ -140,7 +151,10 @@ describe('when evaluating user equivalent contexts for segments', () => { ], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(true); @@ -162,7 +176,7 @@ describe('when evaluating user equivalent contexts for segments', () => { ], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(basicUser)); expect(res.detail.reason).toEqual({ kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }); @@ -183,7 +197,10 @@ describe('when evaluating user equivalent contexts for segments', () => { ], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(false); @@ -215,7 +232,7 @@ describe('when evaluating user equivalent contexts for segments', () => { ], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(user)); expect(res.detail.value).toBe(true); @@ -233,7 +250,7 @@ describe('when evaluating user equivalent contexts for segments', () => { ], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(user)); expect(res.detail.value).toBe(false); @@ -265,7 +282,7 @@ describe('when evaluating user equivalent contexts for segments', () => { }, }; - const bucketingPlatform = { ...basicPlatform, crypto }; + const bucketingPlatform = { ...mocks.basicPlatform, crypto }; const context = Context.fromLDContext({ contextKind: 'user', key: 'userkey' }); const segment1: Segment = { @@ -322,7 +339,10 @@ describe('Evaluator - segment match for non-user contexts', () => { includedContexts: [{ contextKind: 'org', values: [singleKind.key] }], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(true); @@ -354,7 +374,7 @@ describe('Evaluator - segment match for non-user contexts', () => { version: 1, }; const evaluator = new Evaluator( - basicPlatform, + mocks.basicPlatform, new TestQueries({ segments: [segment1, segment2] }), ); const flag = makeFlagWithSegmentMatch(segment2); @@ -381,7 +401,7 @@ describe('Evaluator - segment match for non-user contexts', () => { ], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(singleKind)); expect(res.detail.reason).toEqual({ kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }); @@ -419,7 +439,7 @@ describe('Evaluator - segment match for non-user contexts', () => { version: 1, }; const evaluator = new Evaluator( - basicPlatform, + mocks.basicPlatform, new TestQueries({ segments: [segment1, segment2] }), ); const flag = makeFlagWithSegmentMatch(segment2); @@ -435,7 +455,10 @@ describe('Evaluator - segment match for non-user contexts', () => { includedContexts: [{ contextKind: 'org', values: ['otherKey'] }], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(false); @@ -450,7 +473,10 @@ describe('Evaluator - segment match for non-user contexts', () => { excludedContexts: [{ contextKind: 'org', values: [singleKind.key] }], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator( + mocks.basicPlatform, + new TestQueries({ segments: [segment] }), + ); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(false); @@ -463,7 +489,7 @@ describe('Evaluator - segment match for non-user contexts', () => { includedContexts: [{ contextKind: 'notOrg', values: [singleKind.key] }], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); const res = await evaluator.evaluate(flag, Context.fromLDContext(context)); expect(res.detail.value).toBe(false); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index 3a39e66e5..7a5628b1c 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -1,11 +1,12 @@ -import { Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; import { Flag } from '../../src/evaluation/data/Flag'; import EvalResult from '../../src/evaluation/EvalResult'; import Evaluator from '../../src/evaluation/Evaluator'; import Reasons from '../../src/evaluation/Reasons'; import noQueries from './mocks/noQueries'; -import basicPlatform from './mocks/platform'; + +const { mocks } = internal; const offBaseFlag = { key: 'feature0', @@ -32,7 +33,7 @@ describe.each<[Flag, LDContext, EvalResult | undefined]>([ EvalResult.forSuccess('two', Reasons.Off, 2), ], ])('Given off flags and an evaluator', (flag, context, expected) => { - const evaluator = new Evaluator(basicPlatform, noQueries); + const evaluator = new Evaluator(mocks.basicPlatform, noQueries); it(`produces the expected evaluation result for context: ${context.key} ${ // @ts-ignore @@ -136,7 +137,7 @@ describe.each<[Flag, LDContext, EvalResult | undefined]>([ EvalResult.forSuccess('one', Reasons.TargetMatch, 1), ], ])('given flag configurations with different targets that match', (flag, context, expected) => { - const evaluator = new Evaluator(basicPlatform, noQueries); + const evaluator = new Evaluator(mocks.basicPlatform, noQueries); it(`produces the expected evaluation result for context: ${context.key} ${ // @ts-ignore context.kind diff --git a/packages/shared/sdk-server/__tests__/events/DiagnosticsManager.test.ts b/packages/shared/sdk-server/__tests__/events/DiagnosticsManager.test.ts deleted file mode 100644 index 127cd9f5d..000000000 --- a/packages/shared/sdk-server/__tests__/events/DiagnosticsManager.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { - EventSource, - EventSourceInitDict, - Info, - Options, - Platform, - PlatformData, - Requests, - Response, - SdkData, -} from '@launchdarkly/js-sdk-common'; - -import { DataKind } from '../../src/api/interfaces'; -import { - LDFeatureStore, - LDFeatureStoreDataStorage, - LDFeatureStoreItem, - LDFeatureStoreKindData, - LDKeyedFeatureStoreItem, -} from '../../src/api/subsystems'; -import DiagnosticsManager from '../../src/events/DiagnosticsManager'; -import Configuration from '../../src/options/Configuration'; -import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; -import { crypto } from '../evaluation/mocks/hasher'; - -const info: Info = { - platformData(): PlatformData { - return { - os: { - name: 'An OS', - version: '1.0.1', - arch: 'An Arch', - }, - name: 'The SDK Name', - additional: { - nodeVersion: '42', - }, - }; - }, - sdkData(): SdkData { - return { - name: 'An SDK', - version: '2.0.2', - }; - }, -}; - -const requests: Requests = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - fetch(url: string, options?: Options): Promise { - throw new Error('Function not implemented.'); - }, - - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - throw new Error('Function not implemented.'); - }, - - /** - * Returns true if a proxy is configured. - */ - usingProxy: () => false, - - /** - * Returns true if the proxy uses authentication. - */ - usingProxyAuth: () => false, -}; - -const basicPlatform: Platform = { - info, - crypto, - requests, -}; - -describe('given a diagnostics manager', () => { - let manager: DiagnosticsManager; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => 7777); - manager = new DiagnosticsManager( - 'my-sdk-key', - new Configuration({}), - basicPlatform, - new InMemoryFeatureStore(), - ); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('uses the last 6 characters of the SDK key in the diagnostic id', () => { - const { id } = manager.createInitEvent(); - expect(id.sdkKeySuffix).toEqual('dk-key'); - }); - - it('creates random UUID', () => { - const manager2 = new DiagnosticsManager( - 'my-sdk-key', - new Configuration({}), - basicPlatform, - new InMemoryFeatureStore(), - ); - - const { id } = manager.createInitEvent(); - const { id: id2 } = manager2.createInitEvent(); - expect(id.diagnosticId).toBeTruthy(); - expect(id2.diagnosticId).toBeTruthy(); - expect(id.diagnosticId).not.toEqual(id2.diagnosticId); - }); - - it('puts the start time into the init event', () => { - const { creationDate } = manager.createInitEvent(); - expect(creationDate).toEqual(7777); - }); - - it('puts SDK data into the init event', () => { - const { sdk } = manager.createInitEvent(); - expect(sdk).toEqual({ - name: 'An SDK', - version: '2.0.2', - }); - }); - - it('puts platform data into the init event', () => { - const { platform } = manager.createInitEvent(); - expect(platform).toEqual({ - name: 'The SDK Name', - osName: 'An OS', - osVersion: '1.0.1', - osArch: 'An Arch', - nodeVersion: '42', - }); - }); - - it('creates periodic event from stats, then resets', () => { - manager.recordStreamInit(7778, true, 1000); - manager.recordStreamInit(7779, false, 550); - - jest.spyOn(Date, 'now').mockImplementation(() => 8888); - - const event1 = manager.createStatsEventAndReset(4, 5, 6); - - expect(event1).toMatchObject({ - kind: 'diagnostic', - dataSinceDate: 7777, - droppedEvents: 4, - deduplicatedUsers: 5, - eventsInLastBatch: 6, - streamInits: [ - { - timestamp: 7778, - failed: true, - durationMillis: 1000, - }, - { - timestamp: 7779, - failed: false, - durationMillis: 550, - }, - ], - }); - - expect(event1.creationDate).toEqual(8888); - - jest.spyOn(Date, 'now').mockImplementation(() => 9999); - const event2 = manager.createStatsEventAndReset(1, 2, 3); - - expect(event2).toMatchObject({ - kind: 'diagnostic', - dataSinceDate: event1.creationDate, - droppedEvents: 1, - deduplicatedUsers: 2, - eventsInLastBatch: 3, - streamInits: [], - }); - - expect(event2.creationDate).toEqual(9999); - }); -}); - -const fakeStore: LDFeatureStore = { - getDescription: () => 'WeirdStore', - get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void { - throw new Error('Function not implemented.'); - }, - all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void): void { - throw new Error('Function not implemented.'); - }, - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - throw new Error('Function not implemented.'); - }, - delete(kind: DataKind, key: string, version: number, callback: () => void): void { - throw new Error('Function not implemented.'); - }, - upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { - throw new Error('Function not implemented.'); - }, - initialized(callback: (isInitialized: boolean) => void): void { - throw new Error('Function not implemented.'); - }, - close(): void { - throw new Error('Function not implemented.'); - }, -}; - -describe.each([ - [ - {}, - { - allAttributesPrivate: false, - connectTimeoutMillis: 5000, - customBaseURI: false, - customEventsURI: false, - customStreamURI: false, - dataStoreType: 'memory', - diagnosticRecordingIntervalMillis: 900000, - eventsCapacity: 10000, - eventsFlushIntervalMillis: 5000, - offline: false, - pollingIntervalMillis: 30000, - reconnectTimeMillis: 1000, - socketTimeoutMillis: 5000, - streamingDisabled: false, - contextKeysCapacity: 1000, - contextKeysFlushIntervalMillis: 300000, - usingProxy: false, - usingProxyAuthenticator: false, - usingRelayDaemon: false, - }, - ], - [ - { baseUri: 'http://other' }, - { - customBaseURI: true, - customEventsURI: false, - customStreamURI: false, - }, - ], - [ - { eventsUri: 'http://other' }, - { - customBaseURI: false, - customEventsURI: true, - customStreamURI: false, - }, - ], - [ - { streamUri: 'http://other' }, - { - customBaseURI: false, - customEventsURI: false, - customStreamURI: true, - }, - ], - [{ allAttributesPrivate: true }, { allAttributesPrivate: true }], - [{ timeout: 6 }, { connectTimeoutMillis: 6000, socketTimeoutMillis: 6000 }], - [{ diagnosticRecordingInterval: 999 }, { diagnosticRecordingIntervalMillis: 999000 }], - [{ capacity: 999 }, { eventsCapacity: 999 }], - [{ flushInterval: 33 }, { eventsFlushIntervalMillis: 33000 }], - [{ stream: false }, { streamingDisabled: true }], - [{ streamInitialReconnectDelay: 33 }, { reconnectTimeMillis: 33000 }], - [{ contextKeysCapacity: 111 }, { contextKeysCapacity: 111 }], - [{ contextKeysFlushInterval: 33 }, { contextKeysFlushIntervalMillis: 33000 }], - [{ useLdd: true }, { usingRelayDaemon: true }], - [{ featureStore: fakeStore }, { dataStoreType: 'WeirdStore' }], -])('given diagnostics managers with different configurations', (configIn, configOut) => { - let manager: DiagnosticsManager; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => 7777); - manager = new DiagnosticsManager( - 'my-sdk-key', - new Configuration(configIn), - basicPlatform, - // @ts-ignore - configIn.featureStore ?? new InMemoryFeatureStore(), - ); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('translates the configuration correctly', () => { - const event = manager.createInitEvent(); - expect(event.configuration).toMatchObject(configOut); - }); -}); - -describe.each([true, false])('Given proxy and proxy auth=%p', (auth) => { - let manager: DiagnosticsManager; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => 7777); - jest.spyOn(basicPlatform.requests, 'usingProxy').mockImplementation(() => true); - jest.spyOn(basicPlatform.requests, 'usingProxyAuth').mockImplementation(() => auth); - manager = new DiagnosticsManager( - 'my-sdk-key', - new Configuration({}), - basicPlatform, - new InMemoryFeatureStore(), - ); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('it gets the proxy configuration from the platform', () => { - const event = manager.createInitEvent(); - expect(event.configuration).toMatchObject({ - usingProxy: true, - usingProxyAuthenticator: auth, - }); - }); -}); diff --git a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts index 443654344..f6f4d7466 100644 --- a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts @@ -17,11 +17,10 @@ import { } from '@launchdarkly/js-sdk-common'; import ContextDeduplicator from '../../src/events/ContextDeduplicator'; -import DiagnosticsManager from '../../src/events/DiagnosticsManager'; -import EventSender from '../../src/events/EventSender'; import Configuration from '../../src/options/Configuration'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; -import basicPlatform from '../evaluation/mocks/platform'; + +const { mocks } = internal; const SDK_KEY = 'sdk-key'; @@ -172,21 +171,22 @@ describe('given an event processor with diagnostics manager', () => { // we need to make an object and replace the value. const testConfig = { ...config, diagnosticRecordingInterval: 0.1 }; - const diagnosticsManager = new DiagnosticsManager( + const diagnosticsManager = new internal.DiagnosticsManager( 'sdk-key', - testConfig, { - ...basicPlatform, + ...mocks.basicPlatform, // Replace info and requests. info, requests, crypto, }, - store, + { + config1: 'test', + }, ); const clientContext = new ClientContext(SDK_KEY, testConfig, { - ...basicPlatform, + ...mocks.basicPlatform, info, requests, }); @@ -194,7 +194,6 @@ describe('given an event processor with diagnostics manager', () => { eventProcessor = new internal.EventProcessor( testConfig, clientContext, - new EventSender(config, clientContext), new ContextDeduplicator(config), diagnosticsManager, ); @@ -209,25 +208,7 @@ describe('given an event processor with diagnostics manager', () => { expect(requestState.requestsMade.length).toEqual(1); expect(JSON.parse(requestState.requestsMade[0].options.body!)).toEqual({ configuration: { - allAttributesPrivate: false, - connectTimeoutMillis: 5000, - contextKeysCapacity: 1000, - contextKeysFlushIntervalMillis: 300000, - customBaseURI: false, - customEventsURI: false, - customStreamURI: false, - dataStoreType: 'memory', - diagnosticRecordingIntervalMillis: 100, - eventsCapacity: 3, - eventsFlushIntervalMillis: 5000, - offline: false, - pollingIntervalMillis: 30000, - reconnectTimeMillis: 1000, - socketTimeoutMillis: 5000, - streamingDisabled: false, - usingProxy: false, - usingProxyAuthenticator: false, - usingRelayDaemon: false, + config1: 'test', }, creationDate: 1000, id: { diff --git a/packages/shared/sdk-server/__tests__/events/EventSender.test.ts b/packages/shared/sdk-server/__tests__/events/EventSender.test.ts deleted file mode 100644 index 670fd6364..000000000 --- a/packages/shared/sdk-server/__tests__/events/EventSender.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { AsyncQueue } from 'launchdarkly-js-test-helpers'; - -import { - ClientContext, - EventSource, - EventSourceInitDict, - Headers, - Info, - Options, - PlatformData, - Requests, - Response, - SdkData, -} from '@launchdarkly/js-sdk-common'; -import { LDDeliveryStatus, LDEventType } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; - -import EventSender from '../../src/events/EventSender'; -import Configuration from '../../src/options/Configuration'; -import basicPlatform from '../evaluation/mocks/platform'; - -describe('given an event sender', () => { - let queue: AsyncQueue<{ url: string; options?: Options }>; - let eventSender: EventSender; - let requestStatus = 200; - let requestHeaders: Record = {}; - - beforeEach(() => { - queue = new AsyncQueue(); - requestHeaders = {}; - - const info: Info = { - platformData(): PlatformData { - return { - os: { - name: 'An OS', - version: '1.0.1', - arch: 'An Arch', - }, - name: 'The SDK Name', - additional: { - nodeVersion: '42', - }, - }; - }, - sdkData(): SdkData { - return { - name: 'An SDK', - version: '2.0.2', - }; - }, - }; - - const requests: Requests = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - fetch(url: string, options?: Options): Promise { - queue.add({ url, options }); - - return new Promise((a, r) => { - const headers: Headers = { - get(name: string): string | null { - return requestHeaders[name] ?? null; - }, - keys(): Iterable { - throw new Error('Function not implemented.'); - }, - values(): Iterable { - throw new Error('Function not implemented.'); - }, - entries(): Iterable<[string, string]> { - throw new Error('Function not implemented.'); - }, - has(name: string): boolean { - throw new Error('Function not implemented.'); - }, - }; - - const res: Response = { - headers, - status: requestStatus, - text(): Promise { - throw new Error('Function not implemented.'); - }, - json(): Promise { - throw new Error('Function not implemented.'); - }, - }; - a(res); - }); - }, - - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - throw new Error('Function not implemented.'); - }, - }; - - const config = new Configuration({}); - eventSender = new EventSender( - config, - new ClientContext('sdk-key', config, { ...basicPlatform, requests, info }), - ); - }); - - it('indicates a success for a success status', async () => { - const res = await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - expect(res.status).toEqual(LDDeliveryStatus.Succeeded); - }); - - it('includes the correct headers for analytics', async () => { - await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - const req1 = await queue.take(); - expect(req1.options?.headers).toMatchObject({ - authorization: 'sdk-key', - 'user-agent': 'NodeJSClient/2.0.2', - 'x-launchDarkly-event-schema': '4', - 'content-type': 'application/json', - }); - expect(req1.options?.headers!['x-launchdarkly-payload-id']).toBeDefined(); - }); - - it('includes the payload', async () => { - await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - await eventSender.sendEventData(LDEventType.DiagnosticEvent, { something: false }); - const req1 = await queue.take(); - const req2 = await queue.take(); - - expect(req1.options?.body).toEqual(JSON.stringify({ something: true })); - expect(req2.options?.body).toEqual(JSON.stringify({ something: false })); - }); - - it('includes the correct headers for diagnostics', async () => { - await eventSender.sendEventData(LDEventType.DiagnosticEvent, { something: true }); - const req1 = await queue.take(); - expect(req1.options?.headers).toEqual({ - authorization: 'sdk-key', - 'user-agent': 'NodeJSClient/2.0.2', - 'content-type': 'application/json', - }); - }); - - it('sends a unique payload for analytics events', async () => { - await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - const req1 = await queue.take(); - await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - const req2 = await queue.take(); - expect(req1.options!.headers!['x-launchdarkly-payload-id']).not.toEqual( - req2.options!.headers!['x-launchdarkly-payload-id'], - ); - }); - - it('can get server time', async () => { - requestHeaders.date = new Date(1000).toISOString(); - const res = await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - expect(res.serverTime).toEqual(new Date(1000).getTime()); - }); - - describe.each([400, 408, 429, 503])('given recoverable errors', (status) => { - it(`retries - ${status}`, async () => { - requestStatus = status; - const res = await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - expect(res.status).toEqual(LDDeliveryStatus.Failed); - expect(res.error).toBeDefined(); - - expect(queue.length()).toEqual(2); - }); - }); - - describe.each([401, 403])('given unrecoverable errors', (status) => { - it(`does not retry - ${status}`, async () => { - requestStatus = status; - const res = await eventSender.sendEventData(LDEventType.AnalyticsEvents, { something: true }); - expect(res.status).toEqual(LDDeliveryStatus.FailedAndMustShutDown); - expect(queue.length()).toEqual(1); - }); - }); -}); diff --git a/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts b/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts index 81690750b..f95be91a5 100644 --- a/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts +++ b/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts @@ -1,6 +1,5 @@ -import { ClientContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, ClientContext, internal } from '@launchdarkly/js-sdk-common'; -import { AttributeReference } from '../../../src'; import { Flag } from '../../../src/evaluation/data/Flag'; import { FlagRule } from '../../../src/evaluation/data/FlagRule'; import TestData from '../../../src/integrations/test_data/TestData'; @@ -8,7 +7,8 @@ import Configuration from '../../../src/options/Configuration'; import AsyncStoreFacade from '../../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../../src/store/VersionedDataKinds'; -import basicPlatform from '../../evaluation/mocks/platform'; + +const { mocks } = internal; const basicBooleanFlag: Flag = { fallthrough: { @@ -21,134 +21,151 @@ const basicBooleanFlag: Flag = { version: 1, }; -it('initializes the data store with flags configured the data store is created', async () => { - const td = new TestData(); - td.update(td.flag('new-flag').variationForAll(true)); +describe('TestData', () => { + let initSuccessHandler: jest.Mock; - const store = new InMemoryFeatureStore(); - const processor = td.getFactory()( - new ClientContext('', new Configuration({}), basicPlatform), - store, - ); + beforeEach(() => { + initSuccessHandler = jest.fn(); + }); - processor.start(); - const facade = new AsyncStoreFacade(store); + afterEach(() => { + jest.resetAllMocks(); + }); - const res = await facade.get(VersionedDataKinds.Features, 'new-flag'); + it('initializes the data store with flags configured when the data store is created', async () => { + const td = new TestData(); + td.update(td.flag('new-flag').variationForAll(true)); - expect(res).toEqual(basicBooleanFlag); -}); + const store = new InMemoryFeatureStore(); + const facade = new AsyncStoreFacade(store); -it('updates the data store when update is called', async () => { - const td = new TestData(); - const store = new InMemoryFeatureStore(); - const processor = td.getFactory()( - new ClientContext('', new Configuration({}), basicPlatform), - store, - ); - - processor.start(); - const facade = new AsyncStoreFacade(store); - - // In this test the update is after initialization. - await td.update(td.flag('new-flag').variationForAll(true)); - const res = await facade.get(VersionedDataKinds.Features, 'new-flag'); - expect(res).toEqual(basicBooleanFlag); -}); + const processor = td.getFactory()( + new ClientContext('', new Configuration({}), mocks.basicPlatform), + store, + initSuccessHandler, + ); + processor.start(); + const res = await facade.get(VersionedDataKinds.Features, 'new-flag'); -it('can include pre-configured items', async () => { - const td = new TestData(); - td.usePreconfiguredFlag({ key: 'my-flag', version: 1000, on: true }); - td.usePreconfiguredSegment({ key: 'my-segment', version: 2000 }); - - const store = new InMemoryFeatureStore(); - const processor = td.getFactory()( - new ClientContext('', new Configuration({}), basicPlatform), - store, - ); - - processor.start(); - - td.usePreconfiguredFlag({ key: 'my-flag', on: false }); - td.usePreconfiguredFlag({ key: 'my-flag-2', version: 1000, on: true }); - td.usePreconfiguredSegment({ key: 'my-segment', included: ['x'] }); - td.usePreconfiguredSegment({ key: 'my-segment-2', version: 2000 }); - - const facade = new AsyncStoreFacade(store); - const allFlags = await facade.all(VersionedDataKinds.Features); - const allSegments = await facade.all(VersionedDataKinds.Segments); - - expect(allFlags).toEqual({ - 'my-flag': { - key: 'my-flag', - on: false, - version: 1001, - }, - 'my-flag-2': { - key: 'my-flag-2', - on: true, - version: 1000, - }, + expect(initSuccessHandler).toBeCalled(); + expect(res).toEqual(basicBooleanFlag); }); - expect(allSegments).toEqual({ - 'my-segment': { - included: ['x'], - key: 'my-segment', - version: 2001, - }, - 'my-segment-2': { - key: 'my-segment-2', - version: 2000, - }, + it('updates the data store when update is called', async () => { + const td = new TestData(); + const store = new InMemoryFeatureStore(); + const processor = td.getFactory()( + new ClientContext('', new Configuration({}), mocks.basicPlatform), + store, + initSuccessHandler, + ); + + processor.start(); + const facade = new AsyncStoreFacade(store); + + // In this test the update is after initialization. + await td.update(td.flag('new-flag').variationForAll(true)); + const res = await facade.get(VersionedDataKinds.Features, 'new-flag'); + expect(res).toEqual(basicBooleanFlag); + }); + + it('can include pre-configured items', async () => { + const td = new TestData(); + td.usePreconfiguredFlag({ key: 'my-flag', version: 1000, on: true }); + td.usePreconfiguredSegment({ key: 'my-segment', version: 2000 }); + + const store = new InMemoryFeatureStore(); + const processor = td.getFactory()( + new ClientContext('', new Configuration({}), mocks.basicPlatform), + store, + initSuccessHandler, + ); + + processor.start(); + + td.usePreconfiguredFlag({ key: 'my-flag', on: false }); + td.usePreconfiguredFlag({ key: 'my-flag-2', version: 1000, on: true }); + td.usePreconfiguredSegment({ key: 'my-segment', included: ['x'] }); + td.usePreconfiguredSegment({ key: 'my-segment-2', version: 2000 }); + + const facade = new AsyncStoreFacade(store); + const allFlags = await facade.all(VersionedDataKinds.Features); + const allSegments = await facade.all(VersionedDataKinds.Segments); + + expect(allFlags).toEqual({ + 'my-flag': { + key: 'my-flag', + on: false, + version: 1001, + }, + 'my-flag-2': { + key: 'my-flag-2', + on: true, + version: 1000, + }, + }); + + expect(allSegments).toEqual({ + 'my-segment': { + included: ['x'], + key: 'my-segment', + version: 2001, + }, + 'my-segment-2': { + key: 'my-segment-2', + version: 2000, + }, + }); }); -}); -it.each([true, false])('does not update the store after stop/close is called', async (stop) => { - const td = new TestData(); + it.each([true, false])('does not update the store after stop/close is called', async (stop) => { + const td = new TestData(); - const store = new InMemoryFeatureStore(); - const processor = td.getFactory()( - new ClientContext('', new Configuration({}), basicPlatform), - store, - ); + const store = new InMemoryFeatureStore(); + const processor = td.getFactory()( + new ClientContext('', new Configuration({}), mocks.basicPlatform), + store, + initSuccessHandler, + ); - processor.start(); - td.update(td.flag('new-flag').variationForAll(true)); - if (stop) { - processor.stop(); - } else { - processor.close(); - } - td.update(td.flag('new-flag-2').variationForAll(true)); + processor.start(); + td.update(td.flag('new-flag').variationForAll(true)); + if (stop) { + processor.stop(); + } else { + processor.close(); + } + td.update(td.flag('new-flag-2').variationForAll(true)); - const facade = new AsyncStoreFacade(store); + const facade = new AsyncStoreFacade(store); - const flag1 = await facade.get(VersionedDataKinds.Features, 'new-flag'); - const flag2 = await facade.get(VersionedDataKinds.Features, 'new-flag-2'); + const flag1 = await facade.get(VersionedDataKinds.Features, 'new-flag'); + const flag2 = await facade.get(VersionedDataKinds.Features, 'new-flag-2'); - expect(flag1).toBeDefined(); - expect(flag2).toBeNull(); -}); + expect(flag1).toBeDefined(); + expect(flag2).toBeNull(); + }); -it('can update a flag that already exists in the store', async () => { - const td = new TestData(); + it('can update a flag that already exists in the store', async () => { + const td = new TestData(); - const store = new InMemoryFeatureStore(); + const store = new InMemoryFeatureStore(); - const processor = td.getFactory()( - new ClientContext('', new Configuration({}), basicPlatform), - store, - ); + const processor = td.getFactory()( + new ClientContext('', new Configuration({}), mocks.basicPlatform), + store, + initSuccessHandler, + ); - processor.start(); - td.update(td.flag('new-flag').variationForAll(true)); - td.update(td.flag('new-flag').variationForAll(false)); + processor.start(); + td.update(td.flag('new-flag').variationForAll(true)); + td.update(td.flag('new-flag').variationForAll(false)); - const facade = new AsyncStoreFacade(store); - const res = (await facade.get(VersionedDataKinds.Features, 'new-flag')) as Flag; - expect(res.version).toEqual(2); - expect(res.fallthrough.variation).toEqual(1); + const facade = new AsyncStoreFacade(store); + const res = (await facade.get(VersionedDataKinds.Features, 'new-flag')) as Flag; + expect(res.version).toEqual(2); + expect(res.fallthrough.variation).toEqual(1); + }); }); describe('given a TestData instance', () => { diff --git a/packages/shared/sdk-server/jest.config.js b/packages/shared/sdk-server/jest.config.js index f106eb3bc..6753062cc 100644 --- a/packages/shared/sdk-server/jest.config.js +++ b/packages/shared/sdk-server/jest.config.js @@ -1,6 +1,6 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, - testMatch: ['**/__tests__/**/*test.ts?(x)'], + testMatch: ['**/*.test.ts?(x)'], testEnvironment: 'node', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: ['src/**/*.ts'], diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 94b9b53de..6868ae7ae 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -1,28 +1,32 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable class-methods-use-this */ +import * as process from 'process'; + import { ClientContext, Context, internal, + LDClientError, LDContext, LDEvaluationDetail, LDLogger, + LDPollingError, + LDStreamingError, Platform, subsystem, } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDFlagsState, LDFlagsStateOptions, LDOptions, LDStreamProcessor } from './api'; +import { LDClient, LDFlagsState, LDFlagsStateOptions, LDOptions } from './api'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; import ClientMessages from './ClientMessages'; +import { createStreamListeners } from './data_sources/createStreamListeners'; import DataSourceUpdates from './data_sources/DataSourceUpdates'; -import NullUpdateProcessor from './data_sources/NullUpdateProcessor'; import PollingProcessor from './data_sources/PollingProcessor'; import Requestor from './data_sources/Requestor'; -import StreamingProcessor from './data_sources/StreamingProcessor'; -import { LDClientError } from './errors'; +import createDiagnosticsInitConfig from './diagnostics/createDiagnosticsInitConfig'; import { allSeriesAsync } from './evaluation/collection'; import { Flag } from './evaluation/data/Flag'; import { Segment } from './evaluation/data/Segment'; @@ -31,11 +35,8 @@ import EvalResult from './evaluation/EvalResult'; import Evaluator from './evaluation/Evaluator'; import { Queries } from './evaluation/Queries'; import ContextDeduplicator from './events/ContextDeduplicator'; -import DiagnosticsManager from './events/DiagnosticsManager'; import EventFactory from './events/EventFactory'; -import EventSender from './events/EventSender'; import isExperiment from './events/isExperiment'; -import NullEventProcessor from './events/NullEventProcessor'; import FlagsStateBuilder from './FlagsStateBuilder'; import Configuration from './options/Configuration'; import AsyncStoreFacade from './store/AsyncStoreFacade'; @@ -66,7 +67,7 @@ export default class LDClientImpl implements LDClient { private featureStore: AsyncStoreFacade; - private updateProcessor: LDStreamProcessor; + private updateProcessor?: subsystem.LDStreamProcessor; private eventFactoryDefault = new EventFactory(false); @@ -94,7 +95,7 @@ export default class LDClientImpl implements LDClient { private onReady: () => void; - private diagnosticsManager?: DiagnosticsManager; + private diagnosticsManager?: internal.DiagnosticsManager; /** * Intended for use by platform specific client implementations. @@ -117,6 +118,7 @@ export default class LDClientImpl implements LDClient { const { onUpdate, hasEventListeners } = callbacks; const config = new Configuration(options); + if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); } @@ -128,46 +130,25 @@ export default class LDClientImpl implements LDClient { const dataSourceUpdates = new DataSourceUpdates(featureStore, hasEventListeners, onUpdate); if (config.sendEvents && !config.offline && !config.diagnosticOptOut) { - this.diagnosticsManager = new DiagnosticsManager(sdkKey, config, platform, featureStore); - } - - const makeDefaultProcessor = () => - config.stream - ? new StreamingProcessor( - sdkKey, - config, - this.platform.requests, - this.platform.info, - dataSourceUpdates, - this.diagnosticsManager, - ) - : new PollingProcessor( - config, - new Requestor(sdkKey, config, this.platform.info, this.platform.requests), - dataSourceUpdates, - ); - - if (config.offline || config.useLdd) { - this.updateProcessor = new NullUpdateProcessor(); - } else { - this.updateProcessor = - config.updateProcessorFactory?.(clientContext, dataSourceUpdates) ?? makeDefaultProcessor(); + this.diagnosticsManager = new internal.DiagnosticsManager( + sdkKey, + platform, + createDiagnosticsInitConfig(config, platform, featureStore), + ); } if (!config.sendEvents || config.offline) { - this.eventProcessor = new NullEventProcessor(); + this.eventProcessor = new internal.NullEventProcessor(); } else { this.eventProcessor = new internal.EventProcessor( config, clientContext, - new EventSender(config, clientContext), new ContextDeduplicator(config), this.diagnosticsManager, ); } const asyncFacade = new AsyncStoreFacade(featureStore); - this.featureStore = asyncFacade; const manager = new BigSegmentsManager( @@ -194,25 +175,44 @@ export default class LDClientImpl implements LDClient { }; this.evaluator = new Evaluator(this.platform, queries); - this.updateProcessor.start((err) => { - if (err) { - let error; - if ((err.status && err.status === 401) || (err.code && err.code === 401)) { - error = new Error('Authentication failed. Double check your SDK key.'); - } else { - error = err; - } - - this.onError(error); - this.onFailed(error); - this.initReject?.(error); - this.initState = InitState.Failed; - } else if (!this.initialized()) { - this.initState = InitState.Initialized; - this.initResolve?.(this); - this.onReady(); - } + const listeners = createStreamListeners(dataSourceUpdates, this.logger, { + put: () => this.initSuccess(), }); + const makeDefaultProcessor = () => + config.stream + ? new internal.StreamingProcessor( + sdkKey, + clientContext, + listeners, + this.diagnosticsManager, + (e) => this.dataSourceErrorHandler(e), + this.config.streamInitialReconnectDelay, + ) + : new PollingProcessor( + config, + new Requestor(sdkKey, config, this.platform.info, this.platform.requests), + dataSourceUpdates, + () => this.initSuccess(), + (e) => this.dataSourceErrorHandler(e), + ); + + if (!(config.offline || config.useLdd)) { + this.updateProcessor = + config.updateProcessorFactory?.( + clientContext, + dataSourceUpdates, + () => this.initSuccess(), + (e) => this.dataSourceErrorHandler(e), + ) ?? makeDefaultProcessor(); + } + + if (this.updateProcessor) { + this.updateProcessor.start(); + } else { + // Deferring the start callback should allow client construction to complete before we start + // emitting events. Allowing the client an opportunity to register events. + setTimeout(() => this.initSuccess(), 0); + } } initialized(): boolean { @@ -347,7 +347,7 @@ export default class LDClientImpl implements LDClient { close(): void { this.eventProcessor.close(); - this.updateProcessor.close(); + this.updateProcessor?.close(); this.featureStore.close(); this.bigSegmentsManager.close(); } @@ -452,4 +452,27 @@ export default class LDClientImpl implements LDClient { } return this.variationInternal(flagKey, context, defaultValue, eventFactory); } + + private dataSourceErrorHandler(e: LDStreamingError | LDPollingError) { + const error = + e instanceof LDStreamingError && e.code === 401 + ? new Error('Authentication failed. Double check your SDK key.') + : e; + + this.onError(error); + this.onFailed(error); + + if (!this.initialized()) { + this.initState = InitState.Failed; + this.initReject?.(error); + } + } + + private initSuccess() { + if (!this.initialized()) { + this.initState = InitState.Initialized; + this.initResolve?.(this); + this.onReady(); + } + } } diff --git a/packages/shared/sdk-server/src/api/index.ts b/packages/shared/sdk-server/src/api/index.ts index ffd30fc3c..1018e6eff 100644 --- a/packages/shared/sdk-server/src/api/index.ts +++ b/packages/shared/sdk-server/src/api/index.ts @@ -3,7 +3,6 @@ export * from './options'; export * from './LDClient'; export * from './interfaces/DataKind'; export * from './subsystems/LDFeatureStore'; -export * from './subsystems/LDStreamProcessor'; // These are items that should be less frequently used, and therefore they // are namespaced to reduce clutter amongst the top level exports. diff --git a/packages/shared/sdk-server/src/api/options/LDOptions.ts b/packages/shared/sdk-server/src/api/options/LDOptions.ts index a9c77c2b5..f36590b4c 100644 --- a/packages/shared/sdk-server/src/api/options/LDOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDOptions.ts @@ -1,7 +1,6 @@ -import { LDClientContext, LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDClientContext, LDLogger, subsystem, VoidFunction } from '@launchdarkly/js-sdk-common'; -import { LDDataSourceUpdates, LDStreamProcessor } from '../subsystems'; -import { LDFeatureStore } from '../subsystems/LDFeatureStore'; +import { LDDataSourceUpdates, LDFeatureStore } from '../subsystems'; import { LDBigSegmentsOptions } from './LDBigSegmentsOptions'; import { LDProxyOptions } from './LDProxyOptions'; import { LDTLSOptions } from './LDTLSOptions'; @@ -93,7 +92,9 @@ export interface LDOptions { | (( clientContext: LDClientContext, dataSourceUpdates: LDDataSourceUpdates, - ) => LDStreamProcessor); + initSuccessHandler: VoidFunction, + errorHandler?: (e: Error) => void, + ) => subsystem.LDStreamProcessor); /** * The interval in between flushes of the analytics events queue, in seconds. diff --git a/packages/shared/sdk-server/src/api/subsystems/index.ts b/packages/shared/sdk-server/src/api/subsystems/index.ts index 23bb2a2d3..4e21d2794 100644 --- a/packages/shared/sdk-server/src/api/subsystems/index.ts +++ b/packages/shared/sdk-server/src/api/subsystems/index.ts @@ -1,4 +1,3 @@ export * from './LDFeatureRequestor'; export * from './LDFeatureStore'; -export * from './LDStreamProcessor'; export * from './LDDataSourceUpdates'; diff --git a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts index c40209e0b..69bfc7187 100644 --- a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts +++ b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts @@ -1,14 +1,20 @@ -import { Filesystem, LDLogger } from '@launchdarkly/js-sdk-common'; - -import { LDStreamProcessor } from '../api'; +import { + Filesystem, + LDFileDataSourceError, + LDLogger, + subsystem, + VoidFunction, +} from '@launchdarkly/js-sdk-common'; + +import { DataKind, LDFeatureStore, LDFeatureStoreDataStorage } from '../api'; import { FileDataSourceOptions } from '../api/integrations'; -import { DataKind } from '../api/interfaces'; -import { LDFeatureStore, LDFeatureStoreDataStorage } from '../api/subsystems'; import { Flag } from '../evaluation/data/Flag'; import { processFlag, processSegment } from '../store/serialization'; import VersionedDataKinds from '../store/VersionedDataKinds'; import FileLoader from './FileLoader'; +export type FileDataSourceErrorHandler = (err: LDFileDataSourceError) => void; + function makeFlagWithValue(key: string, value: any): Flag { return { key, @@ -19,7 +25,7 @@ function makeFlagWithValue(key: string, value: any): Flag { }; } -export default class FileDataSource implements LDStreamProcessor { +export default class FileDataSource implements subsystem.LDStreamProcessor { private logger?: LDLogger; private yamlParser?: (data: string) => any; @@ -28,8 +34,6 @@ export default class FileDataSource implements LDStreamProcessor { private allData: LDFeatureStoreDataStorage = {}; - private initCallback?: (err?: any) => void; - /** * This is internal because we want instances to only be created with the * factory. @@ -39,6 +43,8 @@ export default class FileDataSource implements LDStreamProcessor { options: FileDataSourceOptions, filesystem: Filesystem, private readonly featureStore: LDFeatureStore, + private initSuccessHandler: VoidFunction = () => {}, + private readonly errorHandler?: FileDataSourceErrorHandler, ) { this.fileLoader = new FileLoader( filesystem, @@ -51,7 +57,7 @@ export default class FileDataSource implements LDStreamProcessor { this.processFileData(results); } catch (err) { // If this was during start, then the initCallback will be present. - this.initCallback?.(err); + this.errorHandler?.(err as LDFileDataSourceError); this.logger?.error(`Error processing files: ${err}`); } }, @@ -61,8 +67,7 @@ export default class FileDataSource implements LDStreamProcessor { this.yamlParser = options.yamlParser; } - start(fn?: ((err?: any) => void) | undefined): void { - this.initCallback = fn; + start(): void { // Use an immediately invoked function expression to allow handling of the // async loading without making start async itself. (async () => { @@ -71,7 +76,7 @@ export default class FileDataSource implements LDStreamProcessor { } catch (err) { // There was an issue loading/watching the files. // Report back to the caller. - fn?.(err); + this.errorHandler?.(err as LDFileDataSourceError); } })(); } @@ -118,8 +123,8 @@ export default class FileDataSource implements LDStreamProcessor { this.featureStore.init(this.allData, () => { // Call the init callback if present. // Then clear the callback so we cannot call it again. - this.initCallback?.(); - this.initCallback = undefined; + this.initSuccessHandler(); + this.initSuccessHandler = () => {}; }); } diff --git a/packages/shared/sdk-server/src/data_sources/NullUpdateProcessor.ts b/packages/shared/sdk-server/src/data_sources/NullUpdateProcessor.ts deleted file mode 100644 index 849535fb4..000000000 --- a/packages/shared/sdk-server/src/data_sources/NullUpdateProcessor.ts +++ /dev/null @@ -1,21 +0,0 @@ -// This is an empty implementation, so it doesn't use this, and it has empty methods, and it -// has unused variables. - -/* eslint-disable class-methods-use-this */ - -/* eslint-disable @typescript-eslint/no-empty-function */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { LDStreamProcessor } from '../api'; - -export default class NullUpdateProcessor implements LDStreamProcessor { - start(fn?: ((err?: any) => void) | undefined) { - // Deferring the start callback should allow client construction to complete before we start - // emitting events. Allowing the client an opportunity to register events. - setTimeout(() => fn?.(), 0); - } - - stop() {} - - close() {} -} diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index a7af081a4..fbf7502e6 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -1,18 +1,24 @@ -import { LDLogger } from '@launchdarkly/js-sdk-common'; +import { + httpErrorMessage, + isHttpRecoverable, + LDLogger, + LDPollingError, + subsystem, + VoidFunction, +} from '@launchdarkly/js-sdk-common'; -import { LDStreamProcessor } from '../api'; import { LDDataSourceUpdates } from '../api/subsystems'; -import { isHttpRecoverable, LDPollingError } from '../errors'; import Configuration from '../options/Configuration'; -import { deserializePoll } from '../store/serialization'; +import { deserializePoll } from '../store'; import VersionedDataKinds from '../store/VersionedDataKinds'; -import httpErrorMessage from './httpErrorMessage'; import Requestor from './Requestor'; +export type PollingErrorHandler = (err: LDPollingError) => void; + /** * @internal */ -export default class PollingProcessor implements LDStreamProcessor { +export default class PollingProcessor implements subsystem.LDStreamProcessor { private stopped = false; private logger?: LDLogger; @@ -25,13 +31,14 @@ export default class PollingProcessor implements LDStreamProcessor { config: Configuration, private readonly requestor: Requestor, private readonly featureStore: LDDataSourceUpdates, + private readonly initSuccessHandler: VoidFunction = () => {}, + private readonly errorHandler?: PollingErrorHandler, ) { this.logger = config.logger; this.pollInterval = config.pollInterval; - this.featureStore = featureStore; } - private poll(fn?: ((err?: any) => void) | undefined) { + private poll() { if (this.stopped) { return; } @@ -39,7 +46,7 @@ export default class PollingProcessor implements LDStreamProcessor { const reportJsonError = (data: string) => { this.logger?.error('Polling received invalid data'); this.logger?.debug(`Invalid JSON follows: ${data}`); - fn?.(new LDPollingError('Malformed JSON data in polling response')); + this.errorHandler?.(new LDPollingError('Malformed JSON data in polling response')); }; const startTime = Date.now(); @@ -53,7 +60,7 @@ export default class PollingProcessor implements LDStreamProcessor { if (err.status && !isHttpRecoverable(err.status)) { const message = httpErrorMessage(err, 'polling request'); this.logger?.error(message); - fn?.(new LDPollingError(message)); + this.errorHandler?.(new LDPollingError(message)); // It is not recoverable, return and do not trigger another // poll. return; @@ -71,10 +78,10 @@ export default class PollingProcessor implements LDStreamProcessor { [VersionedDataKinds.Segments.namespace]: parsed.segments, }; this.featureStore.init(initData, () => { - fn?.(); + this.initSuccessHandler(); // Triggering the next poll after the init has completed. this.timeoutHandle = setTimeout(() => { - this.poll(fn); + this.poll(); }, sleepFor); }); // The poll will be triggered by the feature store initialization @@ -86,13 +93,13 @@ export default class PollingProcessor implements LDStreamProcessor { // Falling through, there was some type of error and we need to trigger // a new poll. this.timeoutHandle = setTimeout(() => { - this.poll(fn); + this.poll(); }, sleepFor); }); } - start(fn?: ((err?: any) => void) | undefined) { - this.poll(fn); + start() { + this.poll(); } stop() { diff --git a/packages/shared/sdk-server/src/data_sources/Requestor.ts b/packages/shared/sdk-server/src/data_sources/Requestor.ts index b0bd20f80..6ffb2200d 100644 --- a/packages/shared/sdk-server/src/data_sources/Requestor.ts +++ b/packages/shared/sdk-server/src/data_sources/Requestor.ts @@ -1,9 +1,14 @@ -import { Info, Options, Requests, Response } from '@launchdarkly/js-sdk-common'; +import { + defaultHeaders, + Info, + LDStreamingError, + Options, + Requests, + Response, +} from '@launchdarkly/js-sdk-common'; import { LDFeatureRequestor } from '../api/subsystems'; -import { LDStreamingError } from '../errors'; import Configuration from '../options/Configuration'; -import defaultHeaders from './defaultHeaders'; /** * @internal @@ -27,7 +32,7 @@ export default class Requestor implements LDFeatureRequestor { info: Info, private readonly requests: Requests, ) { - this.headers = defaultHeaders(sdkKey, config, info); + this.headers = defaultHeaders(sdkKey, info, config.tags); this.uri = `${config.serviceEndpoints.polling}/sdk/latest-all`; } diff --git a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts deleted file mode 100644 index 411657aff..000000000 --- a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { EventSource, Info, LDLogger, Requests } from '@launchdarkly/js-sdk-common'; - -import { LDStreamProcessor } from '../api'; -import { LDDataSourceUpdates } from '../api/subsystems'; -import { isHttpRecoverable, LDStreamingError } from '../errors'; -import DiagnosticsManager from '../events/DiagnosticsManager'; -import Configuration from '../options/Configuration'; -import { deserializeAll, deserializeDelete, deserializePatch } from '../store/serialization'; -import VersionedDataKinds, { VersionedDataKind } from '../store/VersionedDataKinds'; -import defaultHeaders from './defaultHeaders'; -import httpErrorMessage from './httpErrorMessage'; - -const STREAM_READ_TIMEOUT_MS = 5 * 60 * 1000; -const RETRY_RESET_INTERVAL_MS = 60 * 1000; - -function getKeyFromPath(kind: VersionedDataKind, path: string): string | undefined { - return path.startsWith(kind.streamApiPath) - ? path.substring(kind.streamApiPath.length) - : undefined; -} - -/** - * @internal - */ -export default class StreamingProcessor implements LDStreamProcessor { - private headers: { [key: string]: string | string[] }; - - private eventSource?: EventSource; - - private logger?: LDLogger; - - private streamUri: string; - - private streamInitialReconnectDelay: number; - - private requests: Requests; - - private connectionAttemptStartTime?: number; - - constructor( - sdkKey: string, - config: Configuration, - requests: Requests, - info: Info, - private readonly featureStore: LDDataSourceUpdates, - private readonly diagnosticsManager?: DiagnosticsManager, - ) { - this.headers = defaultHeaders(sdkKey, config, info); - this.logger = config.logger; - this.streamInitialReconnectDelay = config.streamInitialReconnectDelay; - this.requests = requests; - - this.streamUri = `${config.serviceEndpoints.streaming}/all`; - } - - private logConnectionStarted() { - this.connectionAttemptStartTime = Date.now(); - } - - private logConnectionResult(success: boolean) { - if (this.connectionAttemptStartTime && this.diagnosticsManager) { - this.diagnosticsManager.recordStreamInit( - this.connectionAttemptStartTime, - !success, - Date.now() - this.connectionAttemptStartTime, - ); - } - - this.connectionAttemptStartTime = undefined; - } - - start(fn?: ((err?: any) => void) | undefined) { - this.logConnectionStarted(); - - const errorFilter = (err: { status: number; message: string }): boolean => { - if (err.status && !isHttpRecoverable(err.status)) { - this.logConnectionResult(false); - fn?.(new LDStreamingError(err.message, err.status)); - this.logger?.error(httpErrorMessage(err, 'streaming request')); - return false; - } - - this.logger?.warn(httpErrorMessage(err, 'streaming request', 'will retry')); - this.logConnectionResult(false); - this.logConnectionStarted(); - return true; - }; - - const reportJsonError = (type: string, data: string) => { - this.logger?.error(`Stream received invalid data in "${type}" message`); - this.logger?.debug(`Invalid JSON follows: ${data}`); - fn?.(new LDStreamingError('Malformed JSON data in event stream')); - }; - - // TLS is handled by the platform implementation. - - const eventSource = this.requests.createEventSource(this.streamUri, { - headers: this.headers, - errorFilter, - initialRetryDelayMillis: 1000 * this.streamInitialReconnectDelay, - readTimeoutMillis: STREAM_READ_TIMEOUT_MS, - retryResetIntervalMillis: RETRY_RESET_INTERVAL_MS, - }); - this.eventSource = eventSource; - - eventSource.onclose = () => { - this.logger?.info('Closed LaunchDarkly stream connection'); - }; - - eventSource.onerror = () => { - // The work is done by `errorFilter`. - }; - - eventSource.onopen = () => { - this.logger?.info('Opened LaunchDarkly stream connection'); - }; - - eventSource.onretrying = (e) => { - this.logger?.info(`Will retry stream connection in ${e.delayMillis} milliseconds`); - }; - - eventSource.addEventListener('put', (event) => { - this.logger?.debug('Received put event'); - if (event && event.data) { - this.logConnectionResult(true); - const parsed = deserializeAll(event.data); - if (!parsed) { - reportJsonError('put', event.data); - return; - } - const initData = { - [VersionedDataKinds.Features.namespace]: parsed.data.flags, - [VersionedDataKinds.Segments.namespace]: parsed.data.segments, - }; - - this.featureStore.init(initData, () => fn?.()); - } else { - fn?.(new LDStreamingError('Unexpected payload from event stream')); - } - }); - - eventSource.addEventListener('patch', (event) => { - this.logger?.debug('Received patch event'); - if (event && event.data) { - const parsed = deserializePatch(event.data); - if (!parsed) { - reportJsonError('patch', event.data); - return; - } - if (parsed.kind) { - const key = getKeyFromPath(parsed.kind, parsed.path); - if (key) { - this.logger?.debug(`Updating ${key} in ${parsed.kind.namespace}`); - this.featureStore.upsert(parsed.kind, parsed.data, () => {}); - } - } - } else { - fn?.(new LDStreamingError('Unexpected payload from event stream')); - } - }); - - eventSource.addEventListener('delete', (event) => { - this.logger?.debug('Received delete event'); - if (event && event.data) { - const parsed = deserializeDelete(event.data); - if (!parsed) { - reportJsonError('delete', event.data); - return; - } - if (parsed.kind) { - const key = getKeyFromPath(parsed.kind, parsed.path); - if (key) { - this.logger?.debug(`Deleting ${key} in ${parsed.kind.namespace}`); - this.featureStore.upsert( - parsed.kind, - { - key, - version: parsed.version, - deleted: true, - }, - () => {}, - ); - } - } - } else { - fn?.(new LDStreamingError('Unexpected payload from event stream')); - } - }); - } - - stop() { - this.eventSource?.close(); - this.eventSource = undefined; - } - - close() { - this.stop(); - } -} diff --git a/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts b/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts new file mode 100644 index 000000000..7f67f6fe4 --- /dev/null +++ b/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts @@ -0,0 +1,186 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + +import { LDDataSourceUpdates } from '../api/subsystems'; +import { deserializeAll, deserializeDelete, deserializePatch } from '../store/serialization'; +import VersionedDataKinds from '../store/VersionedDataKinds'; +import { createStreamListeners } from './createStreamListeners'; + +jest.mock('../store/serialization'); + +const { + mocks: { logger }, +} = internal; + +const allData = { + data: { + flags: { + flagkey: { key: 'flagkey', version: 1 }, + }, + segments: { + segkey: { key: 'segkey', version: 2 }, + }, + }, +}; + +const patchData = { + path: '/flags/flagkey', + data: { key: 'flagkey', version: 1 }, + kind: VersionedDataKinds.Features, +}; + +const deleteData = { path: '/flags/flagkey', version: 2, kind: VersionedDataKinds.Features }; + +describe('createStreamListeners', () => { + let dataSourceUpdates: LDDataSourceUpdates; + let onPutCompleteHandler: jest.Mock; + let onPatchCompleteHandler: jest.Mock; + let onDeleteCompleteHandler: jest.Mock; + let onCompleteHandlers: { + put: jest.Mock; + patch: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(() => { + dataSourceUpdates = { + init: jest.fn(), + upsert: jest.fn(), + }; + onPutCompleteHandler = jest.fn(); + onPatchCompleteHandler = jest.fn(); + onDeleteCompleteHandler = jest.fn(); + onCompleteHandlers = { + put: onPutCompleteHandler, + patch: onPatchCompleteHandler, + delete: onDeleteCompleteHandler, + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('put', () => { + test('creates put patch delete handlers', () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + + expect(listeners.size).toEqual(3); + expect(listeners.has('put')).toBeTruthy(); + expect(listeners.has('patch')).toBeTruthy(); + expect(listeners.has('delete')).toBeTruthy(); + }); + + test('createPutListener', () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { deserializeData, processJson } = listeners.get('put')!; + + expect(deserializeData).toBe(deserializeAll); + expect(processJson).toBeDefined(); + }); + + test('data source init is called', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('put')!; + const { + data: { flags, segments }, + } = allData; + + processJson(allData); + + expect(logger.debug).toBeCalledWith(expect.stringMatching(/initializing/i)); + expect(dataSourceUpdates.init).toBeCalledWith( + { + features: flags, + segments, + }, + onPutCompleteHandler, + ); + }); + }); + + describe('patch', () => { + test('createPatchListener', () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { deserializeData, processJson } = listeners.get('patch')!; + + expect(deserializeData).toBe(deserializePatch); + expect(processJson).toBeDefined(); + }); + + test('data source upsert is called', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('patch')!; + const { data, kind } = patchData; + + processJson(patchData); + + expect(logger.debug).toBeCalledWith(expect.stringMatching(/updating/i)); + expect(dataSourceUpdates.upsert).toBeCalledWith(kind, data, onPatchCompleteHandler); + }); + + test('data source upsert not called missing kind', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('patch')!; + const missingKind = { ...patchData, kind: undefined }; + + processJson(missingKind); + + expect(dataSourceUpdates.upsert).not.toBeCalled(); + }); + + test('data source upsert not called wrong namespace path', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('patch')!; + const wrongKey = { ...patchData, path: '/wrong/flagkey' }; + + processJson(wrongKey); + + expect(dataSourceUpdates.upsert).not.toBeCalled(); + }); + }); + + describe('delete', () => { + test('createDeleteListener', () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { deserializeData, processJson } = listeners.get('delete')!; + + expect(deserializeData).toBe(deserializeDelete); + expect(processJson).toBeDefined(); + }); + + test('data source upsert is called', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('delete')!; + const { kind, version } = deleteData; + + processJson(deleteData); + + expect(logger.debug).toBeCalledWith(expect.stringMatching(/deleting/i)); + expect(dataSourceUpdates.upsert).toBeCalledWith( + kind, + { key: 'flagkey', version, deleted: true }, + onDeleteCompleteHandler, + ); + }); + + test('data source upsert not called missing kind', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('delete')!; + const missingKind = { ...deleteData, kind: undefined }; + + processJson(missingKind); + + expect(dataSourceUpdates.upsert).not.toBeCalled(); + }); + + test('data source upsert not called wrong namespace path', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('delete')!; + const wrongKey = { ...deleteData, path: '/wrong/flagkey' }; + + processJson(wrongKey); + + expect(dataSourceUpdates.upsert).not.toBeCalled(); + }); + }); +}); diff --git a/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts b/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts new file mode 100644 index 000000000..391e14191 --- /dev/null +++ b/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts @@ -0,0 +1,95 @@ +import { + EventName, + LDLogger, + ProcessStreamResponse, + VoidFunction, +} from '@launchdarkly/js-sdk-common'; + +import { LDDataSourceUpdates } from '../api/subsystems'; +import { + AllData, + DeleteData, + deserializeAll, + deserializeDelete, + deserializePatch, + PatchData, +} from '../store/serialization'; +import VersionedDataKinds from '../store/VersionedDataKinds'; + +export const createPutListener = ( + dataSourceUpdates: LDDataSourceUpdates, + logger?: LDLogger, + onPutCompleteHandler: VoidFunction = () => {}, +) => ({ + deserializeData: deserializeAll, + processJson: async ({ data: { flags, segments } }: AllData) => { + const initData = { + [VersionedDataKinds.Features.namespace]: flags, + [VersionedDataKinds.Segments.namespace]: segments, + }; + + logger?.debug('Initializing all data'); + dataSourceUpdates.init(initData, onPutCompleteHandler); + }, +}); + +export const createPatchListener = ( + dataSourceUpdates: LDDataSourceUpdates, + logger?: LDLogger, + onPatchCompleteHandler: VoidFunction = () => {}, +) => ({ + deserializeData: deserializePatch, + processJson: async ({ data, kind, path }: PatchData) => { + if (kind) { + const key = VersionedDataKinds.getKeyFromPath(kind, path); + if (key) { + logger?.debug(`Updating ${key} in ${kind.namespace}`); + dataSourceUpdates.upsert(kind, data, onPatchCompleteHandler); + } + } + }, +}); + +export const createDeleteListener = ( + dataSourceUpdates: LDDataSourceUpdates, + logger?: LDLogger, + onDeleteCompleteHandler: VoidFunction = () => {}, +) => ({ + deserializeData: deserializeDelete, + processJson: async ({ kind, path, version }: DeleteData) => { + if (kind) { + const key = VersionedDataKinds.getKeyFromPath(kind, path); + if (key) { + logger?.debug(`Deleting ${key} in ${kind.namespace}`); + dataSourceUpdates.upsert( + kind, + { + key, + version, + deleted: true, + }, + onDeleteCompleteHandler, + ); + } + } + }, +}); + +export const createStreamListeners = ( + dataSourceUpdates: LDDataSourceUpdates, + logger?: LDLogger, + onCompleteHandlers?: { + put?: VoidFunction; + patch?: VoidFunction; + delete?: VoidFunction; + }, +) => { + const listeners = new Map(); + listeners.set('put', createPutListener(dataSourceUpdates, logger, onCompleteHandlers?.put)); + listeners.set('patch', createPatchListener(dataSourceUpdates, logger, onCompleteHandlers?.patch)); + listeners.set( + 'delete', + createDeleteListener(dataSourceUpdates, logger, onCompleteHandlers?.delete), + ); + return listeners; +}; diff --git a/packages/shared/sdk-server/src/data_sources/defaultHeaders.ts b/packages/shared/sdk-server/src/data_sources/defaultHeaders.ts deleted file mode 100644 index 9f17672fe..000000000 --- a/packages/shared/sdk-server/src/data_sources/defaultHeaders.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ApplicationTags, Info } from '@launchdarkly/js-sdk-common'; - -export interface DefaultHeaderOptions { - tags: ApplicationTags; -} - -export default function defaultHeaders( - sdkKey: string, - config: DefaultHeaderOptions, - info: Info, -): { [key: string]: string } { - const sdkData = info.sdkData(); - const headers: { [key: string]: string } = { - authorization: sdkKey, - 'user-agent': `NodeJSClient/${sdkData.version}`, - }; - - if (sdkData.wrapperName) { - headers['x-launchdarkly-wrapper'] = sdkData.wrapperVersion - ? `${sdkData.wrapperName}/${sdkData.wrapperVersion}` - : sdkData.wrapperName; - } - - const tags = config.tags.value; - if (tags) { - headers['x-launchdarkly-tags'] = tags; - } - - return headers; -} diff --git a/packages/shared/sdk-server/src/data_sources/httpErrorMessage.ts b/packages/shared/sdk-server/src/data_sources/httpErrorMessage.ts deleted file mode 100644 index 4555072bc..000000000 --- a/packages/shared/sdk-server/src/data_sources/httpErrorMessage.ts +++ /dev/null @@ -1,17 +0,0 @@ -export default function httpErrorMessage( - err: { - status: number; - message: string; - }, - context: string, - retryMessage?: string, -): string { - let desc; - if (err.status) { - desc = `error ${err.status}${err.status === 401 ? ' (invalid SDK key)' : ''}`; - } else { - desc = `I/O error (${err.message || err})`; - } - const action = retryMessage ?? 'giving up permanently'; - return `Received ${desc} for ${context} - ${action}`; -} diff --git a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts new file mode 100644 index 000000000..a62abb837 --- /dev/null +++ b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts @@ -0,0 +1,116 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + +import { LDOptions } from '../api'; +import Configuration from '../options/Configuration'; +import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; + +const { + mocks: { basicPlatform }, +} = internal; + +const mockFeatureStore = { + getDescription: jest.fn(() => 'Mock Feature Store'), +}; + +describe.each([ + [ + {}, + { + allAttributesPrivate: false, + connectTimeoutMillis: 5000, + customBaseURI: false, + customEventsURI: false, + customStreamURI: false, + dataStoreType: 'Mock Feature Store', + diagnosticRecordingIntervalMillis: 900000, + eventsCapacity: 10000, + eventsFlushIntervalMillis: 5000, + offline: false, + pollingIntervalMillis: 30000, + reconnectTimeMillis: 1000, + socketTimeoutMillis: 5000, + streamingDisabled: false, + contextKeysCapacity: 1000, + contextKeysFlushIntervalMillis: 300000, + usingProxy: false, + usingProxyAuthenticator: false, + usingRelayDaemon: false, + }, + ], + [ + { baseUri: 'http://other' }, + { + customBaseURI: true, + customEventsURI: false, + customStreamURI: false, + }, + ], + [ + { eventsUri: 'http://other' }, + { + customBaseURI: false, + customEventsURI: true, + customStreamURI: false, + }, + ], + [ + { streamUri: 'http://other' }, + { + customBaseURI: false, + customEventsURI: false, + customStreamURI: true, + }, + ], + [{ allAttributesPrivate: true }, { allAttributesPrivate: true }], + [{ timeout: 6 }, { connectTimeoutMillis: 6000, socketTimeoutMillis: 6000 }], + [{ diagnosticRecordingInterval: 999 }, { diagnosticRecordingIntervalMillis: 999000 }], + [{ capacity: 999 }, { eventsCapacity: 999 }], + [{ flushInterval: 33 }, { eventsFlushIntervalMillis: 33000 }], + [{ stream: false }, { streamingDisabled: true }], + [{ streamInitialReconnectDelay: 33 }, { reconnectTimeMillis: 33000 }], + [{ contextKeysCapacity: 111 }, { contextKeysCapacity: 111 }], + [{ contextKeysFlushInterval: 33 }, { contextKeysFlushIntervalMillis: 33000 }], + [{ useLdd: true }, { usingRelayDaemon: true }], + [{ featureStore: undefined }, { dataStoreType: 'memory' }], +])('given diagnostics managers with different configurations', (optionsIn, configOut) => { + let configuration: Configuration; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => 7777); + configuration = new Configuration(optionsIn as LDOptions); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('translates the configuration correctly', () => { + const c = createDiagnosticsInitConfig(configuration, basicPlatform, mockFeatureStore as any); + + expect(c).toMatchObject(configOut); + }); +}); + +describe.each([true, false])('Given proxy && proxyAuth = %p', (auth) => { + beforeEach(() => { + basicPlatform.requests.usingProxy = jest.fn(() => auth); + basicPlatform.requests.usingProxyAuth = jest.fn(() => auth); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('it gets the proxy configuration from the basicPlatform', () => { + const c = createDiagnosticsInitConfig( + new Configuration(), + basicPlatform, + mockFeatureStore as any, + ); + + expect(c).toMatchObject({ + usingProxy: auth, + usingProxyAuthenticator: auth, + }); + }); +}); diff --git a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts new file mode 100644 index 000000000..764efef6e --- /dev/null +++ b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts @@ -0,0 +1,37 @@ +import { Platform, secondsToMillis } from '@launchdarkly/js-sdk-common'; + +import { LDFeatureStore } from '../api'; +import Configuration, { defaultValues } from '../options/Configuration'; + +const createDiagnosticsInitConfig = ( + config: Configuration, + platform: Platform, + featureStore: LDFeatureStore, +) => ({ + customBaseURI: config.serviceEndpoints.polling !== defaultValues.baseUri, + customStreamURI: config.serviceEndpoints.streaming !== defaultValues.streamUri, + customEventsURI: config.serviceEndpoints.events !== defaultValues.eventsUri, + eventsCapacity: config.eventsCapacity, + + // Node doesn't distinguish between these two kinds of timeouts. It is unlikely other web + // based implementations would be able to either. + connectTimeoutMillis: secondsToMillis(config.timeout), + socketTimeoutMillis: secondsToMillis(config.timeout), + eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), + pollingIntervalMillis: secondsToMillis(config.pollInterval), + reconnectTimeMillis: secondsToMillis(config.streamInitialReconnectDelay), + contextKeysFlushIntervalMillis: secondsToMillis(config.contextKeysFlushInterval), + diagnosticRecordingIntervalMillis: secondsToMillis(config.diagnosticRecordingInterval), + + streamingDisabled: !config.stream, + usingRelayDaemon: config.useLdd, + offline: config.offline, + allAttributesPrivate: config.allAttributesPrivate, + contextKeysCapacity: config.contextKeysCapacity, + + usingProxy: !!platform.requests.usingProxy?.(), + usingProxyAuthenticator: !!platform.requests.usingProxyAuth?.(), + dataStoreType: featureStore.getDescription?.() ?? 'memory', +}); + +export default createDiagnosticsInitConfig; diff --git a/packages/shared/sdk-server/src/events/DiagnosticsManager.ts b/packages/shared/sdk-server/src/events/DiagnosticsManager.ts deleted file mode 100644 index e9fb1a806..000000000 --- a/packages/shared/sdk-server/src/events/DiagnosticsManager.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { Platform } from '@launchdarkly/js-sdk-common'; - -import { LDFeatureStore } from '../api/subsystems'; -import Configuration, { defaultValues } from '../options/Configuration'; - -interface DiagnosticPlatformData { - name?: string; - osArch?: string; - osName?: string; - osVersion?: string; - /** - * Platform specific identifiers. - * For instance `nodeVersion` - */ - [key: string]: string | undefined; -} - -interface DiagnosticSdkData { - name?: string; - wrapperName?: string; - wrapperVersion?: string; -} - -interface DiagnosticConfigData { - customBaseURI: boolean; - customStreamURI: boolean; - customEventsURI: boolean; - eventsCapacity: number; - connectTimeoutMillis: number; - socketTimeoutMillis: number; - eventsFlushIntervalMillis: number; - pollingIntervalMillis: number; - // startWaitMillis: n/a (SDK does not have this feature) - // samplingInterval: n/a (SDK does not have this feature) - reconnectTimeMillis: number; - streamingDisabled: boolean; - usingRelayDaemon: boolean; - offline: boolean; - allAttributesPrivate: boolean; - contextKeysCapacity: number; - contextKeysFlushIntervalMillis: number; - usingProxy: boolean; - usingProxyAuthenticator: boolean; - diagnosticRecordingIntervalMillis: number; - dataStoreType: string; -} - -interface DiagnosticId { - diagnosticId: string; - sdkKeySuffix: string; -} - -export interface DiagnosticInitEvent { - kind: 'diagnostic-init'; - id: DiagnosticId; - creationDate: number; - sdk: DiagnosticSdkData; - configuration: DiagnosticConfigData; - platform: DiagnosticPlatformData; -} - -interface StreamInitData { - timestamp: number; - failed: boolean; - durationMillis: number; -} - -export interface DiagnosticStatsEvent { - kind: 'diagnostic'; - id: DiagnosticId; - creationDate: number; - dataSinceDate: number; - droppedEvents: number; - deduplicatedUsers: number; - eventsInLastBatch: number; - streamInits: StreamInitData[]; -} - -function secondsToMillis(sec: number): number { - return Math.trunc(sec * 1000); -} - -/** - * Maintains information for diagnostic events. - * - * @internal - */ -export default class DiagnosticsManager { - private startTime: number; - - private streamInits: StreamInitData[] = []; - - private id: DiagnosticId; - - private dataSinceDate: number; - - constructor( - sdkKey: string, - private readonly config: Configuration, - private readonly platform: Platform, - private readonly featureStore: LDFeatureStore, - ) { - this.startTime = Date.now(); - this.dataSinceDate = this.startTime; - this.id = { - diagnosticId: platform.crypto.randomUUID(), - sdkKeySuffix: sdkKey.length > 6 ? sdkKey.substring(sdkKey.length - 6) : sdkKey, - }; - } - - /** - * Creates the initial event that is sent by the event processor when the SDK starts up. This will - * not be repeated during the lifetime of the SDK client. - */ - createInitEvent(): DiagnosticInitEvent { - const sdkData = this.platform.info.sdkData(); - const platformData = this.platform.info.platformData(); - - return { - kind: 'diagnostic-init', - id: this.id, - creationDate: this.startTime, - sdk: sdkData, - configuration: { - customBaseURI: this.config.serviceEndpoints.polling !== defaultValues.baseUri, - customStreamURI: this.config.serviceEndpoints.streaming !== defaultValues.streamUri, - customEventsURI: this.config.serviceEndpoints.events !== defaultValues.eventsUri, - eventsCapacity: this.config.eventsCapacity, - // Node doesn't distinguish between these two kinds of timeouts. It is unlikely other web - // based implementations would be able to either. - connectTimeoutMillis: secondsToMillis(this.config.timeout), - socketTimeoutMillis: secondsToMillis(this.config.timeout), - eventsFlushIntervalMillis: secondsToMillis(this.config.flushInterval), - pollingIntervalMillis: secondsToMillis(this.config.pollInterval), - reconnectTimeMillis: secondsToMillis(this.config.streamInitialReconnectDelay), - streamingDisabled: !this.config.stream, - usingRelayDaemon: this.config.useLdd, - offline: this.config.offline, - allAttributesPrivate: this.config.allAttributesPrivate, - contextKeysCapacity: this.config.contextKeysCapacity, - contextKeysFlushIntervalMillis: secondsToMillis(this.config.contextKeysFlushInterval), - usingProxy: !!this.platform.requests.usingProxy?.(), - usingProxyAuthenticator: !!this.platform.requests.usingProxyAuth?.(), - diagnosticRecordingIntervalMillis: secondsToMillis(this.config.diagnosticRecordingInterval), - dataStoreType: this.featureStore.getDescription?.() ?? 'memory', - }, - platform: { - name: platformData.name, - osArch: platformData.os?.arch, - osName: platformData.os?.name, - osVersion: platformData.os?.version, - ...(platformData.additional || {}), - }, - }; - } - - /** - * Records a stream connection attempt (called by the stream processor). - * - * @param timestamp Time of the *beginning* of the connection attempt. - * @param failed True if the connection failed, or we got a read timeout before receiving a "put". - * @param durationMillis Elapsed time between starting timestamp and when we either gave up/lost - * the connection or received a successful "put". - */ - recordStreamInit(timestamp: number, failed: boolean, durationMillis: number) { - const item = { timestamp, failed, durationMillis }; - this.streamInits.push(item); - } - - /** - * Creates a periodic event containing time-dependent stats, and resets the state of the manager - * with regard to those stats. - * - * Note: the reason droppedEvents, deduplicatedUsers, and eventsInLastBatch are passed into this - * function, instead of being properties of the DiagnosticsManager, is that the event processor is - * the one who's calling this function and is also the one who's tracking those stats. - */ - createStatsEventAndReset( - droppedEvents: number, - deduplicatedUsers: number, - eventsInLastBatch: number, - ): DiagnosticStatsEvent { - const currentTime = Date.now(); - const evt: DiagnosticStatsEvent = { - kind: 'diagnostic', - id: this.id, - creationDate: currentTime, - dataSinceDate: this.dataSinceDate, - droppedEvents, - deduplicatedUsers, - eventsInLastBatch, - streamInits: this.streamInits, - }; - - this.streamInits = []; - this.dataSinceDate = currentTime; - return evt; - } -} diff --git a/packages/shared/sdk-server/src/events/EventSender.ts b/packages/shared/sdk-server/src/events/EventSender.ts deleted file mode 100644 index e405a6254..000000000 --- a/packages/shared/sdk-server/src/events/EventSender.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - ApplicationTags, - ClientContext, - Crypto, - Requests, - subsystem, -} from '@launchdarkly/js-sdk-common'; - -import defaultHeaders from '../data_sources/defaultHeaders'; -import httpErrorMessage from '../data_sources/httpErrorMessage'; -import { isHttpRecoverable, LDUnexpectedResponseError } from '../errors'; - -export interface EventSenderOptions { - tags: ApplicationTags; -} - -export default class EventSender implements subsystem.LDEventSender { - private defaultHeaders: { - [key: string]: string; - }; - - private eventsUri: string; - - private diagnosticEventsUri: string; - - private requests: Requests; - - private crypto: Crypto; - - constructor(config: EventSenderOptions, clientContext: ClientContext) { - this.defaultHeaders = { - ...defaultHeaders( - clientContext.basicConfiguration.sdkKey, - config, - clientContext.platform.info, - ), - }; - - this.eventsUri = `${clientContext.basicConfiguration.serviceEndpoints.events}/bulk`; - - this.diagnosticEventsUri = `${clientContext.basicConfiguration.serviceEndpoints.events}/diagnostic`; - - this.requests = clientContext.platform.requests; - - this.crypto = clientContext.platform.crypto; - } - - private async tryPostingEvents( - events: any, - uri: string, - payloadId: string | undefined, - canRetry: boolean, - ): Promise { - const tryRes: subsystem.LDEventSenderResult = { - status: subsystem.LDDeliveryStatus.Succeeded, - }; - - const headers: Record = { - ...this.defaultHeaders, - 'content-type': 'application/json', - }; - - if (payloadId) { - headers['x-launchdarkly-payload-id'] = payloadId; - headers['x-launchDarkly-event-schema'] = '4'; - } - let error; - try { - const res = await this.requests.fetch(uri, { - headers, - body: JSON.stringify(events), - method: 'POST', - }); - - const serverDate = Date.parse(res.headers.get('date') || ''); - if (serverDate) { - tryRes.serverTime = serverDate; - } - - if (res.status <= 204) { - return tryRes; - } - - error = new LDUnexpectedResponseError( - httpErrorMessage( - { status: res.status, message: 'some events were dropped' }, - 'event posting', - ), - ); - - if (!isHttpRecoverable(res.status)) { - tryRes.status = subsystem.LDDeliveryStatus.FailedAndMustShutDown; - tryRes.error = error; - return tryRes; - } - } catch (err) { - error = err; - } - - if (error && !canRetry) { - tryRes.status = subsystem.LDDeliveryStatus.Failed; - tryRes.error = error; - return tryRes; - } - - await new Promise((r) => { - setTimeout(r, 1000); - }); - return this.tryPostingEvents(events, this.eventsUri, payloadId, false); - } - - async sendEventData( - type: subsystem.LDEventType, - data: any, - ): Promise { - const payloadId = - type === subsystem.LDEventType.AnalyticsEvents ? this.crypto.randomUUID() : undefined; - const uri = - type === subsystem.LDEventType.AnalyticsEvents ? this.eventsUri : this.diagnosticEventsUri; - - return this.tryPostingEvents(data, uri, payloadId, true); - } -} diff --git a/packages/shared/sdk-server/src/events/NullEventProcessor.ts b/packages/shared/sdk-server/src/events/NullEventProcessor.ts deleted file mode 100644 index 2dd4ae313..000000000 --- a/packages/shared/sdk-server/src/events/NullEventProcessor.ts +++ /dev/null @@ -1,22 +0,0 @@ -// This is an empty implementation, so it doesn't use this, and it has empty methods, and it -// has unused variables. - -/* eslint-disable class-methods-use-this */ - -/* eslint-disable @typescript-eslint/no-empty-function */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { internal, subsystem } from '@launchdarkly/js-sdk-common'; - -/** - * Empty event processor implementation for when events are not desired. - * - * @internal - */ -export default class NullEventProcessor implements subsystem.LDEventProcessor { - close(): void {} - - async flush(): Promise {} - - sendEvent(inputEvent: internal.InputEvent): void {} -} diff --git a/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts b/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts index 896542c9b..885776ee2 100644 --- a/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts +++ b/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts @@ -1,8 +1,8 @@ -import { LDClientContext, LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDClientContext, LDLogger, subsystem, VoidFunction } from '@launchdarkly/js-sdk-common'; import { FileDataSourceOptions } from '../api/integrations'; -import { LDFeatureStore, LDStreamProcessor } from '../api/subsystems'; -import FileDataSource from '../data_sources/FileDataSource'; +import { LDFeatureStore } from '../api/subsystems'; +import FileDataSource, { FileDataSourceErrorHandler } from '../data_sources/FileDataSource'; /** * Components of the SDK runtime configuration which are required @@ -30,20 +30,34 @@ export default class FileDataSourceFactory { * * @internal */ - create(ldClientContext: LDClientContext, featureStore: LDFeatureStore) { + create( + ldClientContext: LDClientContext, + featureStore: LDFeatureStore, + initSuccessHandler?: VoidFunction, + errorHandler?: FileDataSourceErrorHandler, + ) { const updatedOptions: FileDataSourceOptions = { paths: this.options.paths, autoUpdate: this.options.autoUpdate, logger: this.options.logger || ldClientContext.basicConfiguration.logger, yamlParser: this.options.yamlParser, }; - return new FileDataSource(updatedOptions, ldClientContext.platform.fileSystem!, featureStore); + return new FileDataSource( + updatedOptions, + ldClientContext.platform.fileSystem!, + featureStore, + initSuccessHandler, + errorHandler, + ); } getFactory(): ( ldClientContext: LDClientContext, featureStore: LDFeatureStore, - ) => LDStreamProcessor { - return (ldClientContext, featureStore) => this.create(ldClientContext, featureStore); + initSuccessHandler?: VoidFunction, + errorHandler?: FileDataSourceErrorHandler, + ) => subsystem.LDStreamProcessor { + return (ldClientContext, featureStore, initSuccessHandler, errorHandler) => + this.create(ldClientContext, featureStore, initSuccessHandler, errorHandler); } } diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestData.ts b/packages/shared/sdk-server/src/integrations/test_data/TestData.ts index 1dc261488..6a33f0444 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestData.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestData.ts @@ -1,7 +1,7 @@ -import { LDClientContext } from '@launchdarkly/js-sdk-common'; +import { LDClientContext, subsystem, VoidFunction } from '@launchdarkly/js-sdk-common'; -import { LDStreamProcessor } from '../../api'; -import { LDFeatureStore } from '../../api/subsystems'; +import { LDFeatureStore } from '../../api'; +import { createStreamListeners } from '../../data_sources/createStreamListeners'; import { Flag } from '../../evaluation/data/Flag'; import { Segment } from '../../evaluation/data/Segment'; import AsyncStoreFacade from '../../store/AsyncStoreFacade'; @@ -58,22 +58,34 @@ export default class TestData { getFactory(): ( clientContext: LDClientContext, featureStore: LDFeatureStore, - ) => LDStreamProcessor { + initSuccessHandler: VoidFunction, + errorHandler?: (e: Error) => void, + ) => subsystem.LDStreamProcessor { // Provides an arrow function to prevent needed to bind the method to // maintain `this`. return ( /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ clientContext: LDClientContext, featureStore: LDFeatureStore, + initSuccessHandler: VoidFunction, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + errorHandler?: (e: Error) => void, ) => { + const listeners = createStreamListeners( + featureStore, + clientContext.basicConfiguration.logger, + { + put: initSuccessHandler, + }, + ); const newSource = new TestDataSource( new AsyncStoreFacade(featureStore), this.currentFlags, - this.currentSegments, (tds) => { this.dataSources.splice(this.dataSources.indexOf(tds)); }, + listeners, ); this.dataSources.push(newSource); diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts b/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts index 6f44bfba9..a4984ef07 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts @@ -1,28 +1,34 @@ -import { LDStreamProcessor } from '../../api'; -import { DataKind } from '../../api/interfaces'; -import { LDKeyedFeatureStoreItem } from '../../api/subsystems'; +import { EventName, ProcessStreamResponse, subsystem } from '@launchdarkly/js-sdk-common'; + +import { DataKind, LDKeyedFeatureStoreItem } from '../../api'; import { Flag } from '../../evaluation/data/Flag'; import { Segment } from '../../evaluation/data/Segment'; import AsyncStoreFacade from '../../store/AsyncStoreFacade'; -import VersionedDataKinds from '../../store/VersionedDataKinds'; /** * @internal */ -export default class TestDataSource implements LDStreamProcessor { +export default class TestDataSource implements subsystem.LDStreamProcessor { + private readonly flags: Record; + private readonly segments: Record; constructor( private readonly featureStore: AsyncStoreFacade, - private readonly flags: Record, - private readonly segments: Record, + initialFlags: Record, + initialSegments: Record, private readonly onStop: (tfs: TestDataSource) => void, - ) {} + private readonly listeners: Map, + ) { + // make copies of these objects to decouple them from the originals + // so updates made to the originals don't affect these internal data. + this.flags = { ...initialFlags }; + this.segments = { ...initialSegments }; + } - async start(fn?: ((err?: any) => void) | undefined) { - await this.featureStore.init({ - [VersionedDataKinds.Features.namespace]: { ...this.flags }, - [VersionedDataKinds.Segments.namespace]: { ...this.segments }, + async start() { + this.listeners.forEach(({ processJson }) => { + const dataJson = { data: { flags: this.flags, segments: this.segments } }; + processJson(dataJson); }); - fn?.(); } stop() { diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 35e4f0af0..494bb83c5 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -5,17 +5,13 @@ import { NumberWithMinimum, OptionMessages, ServiceEndpoints, + subsystem, TypeValidator, TypeValidators, + VoidFunction, } from '@launchdarkly/js-sdk-common'; -import { - LDBigSegmentsOptions, - LDOptions, - LDProxyOptions, - LDStreamProcessor, - LDTLSOptions, -} from '../api'; +import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; import { LDDataSourceUpdates, LDFeatureStore } from '../api/subsystems'; import InMemoryFeatureStore from '../store/InMemoryFeatureStore'; import { ValidatedOptions } from './ValidatedOptions'; @@ -65,7 +61,7 @@ const validations: Record = { export const defaultValues: ValidatedOptions = { baseUri: 'https://sdk.launchdarkly.com', streamUri: 'https://stream.launchdarkly.com', - eventsUri: 'https://events.launchdarkly.com', + eventsUri: ServiceEndpoints.DEFAULT_EVENTS, stream: true, streamInitialReconnectDelay: 1, sendEvents: true, @@ -205,7 +201,9 @@ export default class Configuration { public readonly updateProcessorFactory?: ( clientContext: LDClientContext, dataSourceUpdates: LDDataSourceUpdates, - ) => LDStreamProcessor; + initSuccessHandler: VoidFunction, + errorHandler?: (e: Error) => void, + ) => subsystem.LDStreamProcessor; public readonly bigSegments?: LDBigSegmentsOptions; diff --git a/packages/shared/sdk-server/src/options/ValidatedOptions.ts b/packages/shared/sdk-server/src/options/ValidatedOptions.ts index 894898b81..f50497711 100644 --- a/packages/shared/sdk-server/src/options/ValidatedOptions.ts +++ b/packages/shared/sdk-server/src/options/ValidatedOptions.ts @@ -1,12 +1,6 @@ -import { LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDLogger, subsystem } from '@launchdarkly/js-sdk-common'; -import { - LDBigSegmentsOptions, - LDOptions, - LDProxyOptions, - LDStreamProcessor, - LDTLSOptions, -} from '../api'; +import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; import { LDFeatureStore } from '../api/subsystems'; /** @@ -36,7 +30,7 @@ export interface ValidatedOptions { diagnosticRecordingInterval: number; featureStore: LDFeatureStore | ((options: LDOptions) => LDFeatureStore); tlsParams?: LDTLSOptions; - updateProcessor?: LDStreamProcessor; + updateProcessor?: subsystem.LDStreamProcessor; wrapperName?: string; wrapperVersion?: string; application?: { id?: string; version?: string }; diff --git a/packages/shared/sdk-server/src/store/VersionedDataKinds.ts b/packages/shared/sdk-server/src/store/VersionedDataKinds.ts index 6bfab16e7..6e3ebe67e 100644 --- a/packages/shared/sdk-server/src/store/VersionedDataKinds.ts +++ b/packages/shared/sdk-server/src/store/VersionedDataKinds.ts @@ -19,4 +19,10 @@ export default class VersionedDataKinds { streamApiPath: '/segments/', requestPath: '/sdk/latest-segments/', }; + + static getKeyFromPath(kind: VersionedDataKind, path: string): string | undefined { + return path.startsWith(kind.streamApiPath) + ? path.substring(kind.streamApiPath.length) + : undefined; + } } diff --git a/packages/shared/sdk-server/src/store/serialization.ts b/packages/shared/sdk-server/src/store/serialization.ts index 0807fb087..b46e6b8cd 100644 --- a/packages/shared/sdk-server/src/store/serialization.ts +++ b/packages/shared/sdk-server/src/store/serialization.ts @@ -23,12 +23,12 @@ export function reviver(this: any, key: string, value: any): any { return value; } -interface FlagsAndSegments { +export interface FlagsAndSegments { flags: { [name: string]: Flag }; segments: { [name: string]: Segment }; } -interface AllData { +export interface AllData { data: FlagsAndSegments; } @@ -54,7 +54,7 @@ export function replacer(this: any, key: string, value: any): any { return value; } -interface DeleteData extends Omit { +export interface DeleteData extends Omit { path: string; kind?: VersionedDataKind; } @@ -62,7 +62,7 @@ interface DeleteData extends Omit { type VersionedFlag = VersionedData & Flag; type VersionedSegment = VersionedData & Segment; -interface PatchData { +export interface PatchData { path: string; data: VersionedFlag | VersionedSegment; kind?: VersionedDataKind; diff --git a/packages/shared/sdk-server/tsconfig.json b/packages/shared/sdk-server/tsconfig.json index cd3f7af3c..1a394bfbf 100644 --- a/packages/shared/sdk-server/tsconfig.json +++ b/packages/shared/sdk-server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "es2017", + "target": "ES2017", "lib": ["es6"], "module": "commonjs", "strict": true, From 37b33fba2d92dd183eccc995e6706133476bcbf7 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 19 Sep 2023 12:14:45 -0700 Subject: [PATCH 38/57] chore: fixed conflicts --- packages/shared/common/src/errors.ts | 5 ++++- packages/shared/sdk-server/src/LDClientImpl.ts | 3 ++- .../shared/sdk-server/src/data_sources/PollingProcessor.ts | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/shared/common/src/errors.ts b/packages/shared/common/src/errors.ts index 4a805c875..eb7be712e 100644 --- a/packages/shared/common/src/errors.ts +++ b/packages/shared/common/src/errors.ts @@ -10,8 +10,11 @@ export class LDFileDataSourceError extends Error { } export class LDPollingError extends Error { - constructor(message: string) { + public readonly status?: number; + + constructor(message: string, status?: number) { super(message); + this.status = status; this.name = 'LaunchDarklyPollingError'; } } diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 0cf9b59b6..3af734be6 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -15,7 +15,7 @@ import { subsystem, } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDFlagsState, LDFlagsStateOptions, LDOptions } from './api'; +import { LDClient, LDFeatureStore, LDFlagsState, LDFlagsStateOptions, LDOptions } from './api'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -40,6 +40,7 @@ import Configuration from './options/Configuration'; import { AsyncStoreFacade } from './store'; import VersionedDataKinds from './store/VersionedDataKinds'; +const { NullEventProcessor } = internal; enum InitState { Initializing, Initialized, diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index fbf7502e6..a51beee7c 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -57,10 +57,11 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { this.logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); if (err) { - if (err.status && !isHttpRecoverable(err.status)) { + const { status } = err; + if (status && !isHttpRecoverable(status)) { const message = httpErrorMessage(err, 'polling request'); this.logger?.error(message); - this.errorHandler?.(new LDPollingError(message)); + this.errorHandler?.(new LDPollingError(message, status)); // It is not recoverable, return and do not trigger another // poll. return; From f97935e17269a84886c764b1250bd8fe86a955db Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 19 Sep 2023 12:25:38 -0700 Subject: [PATCH 39/57] chore: remove duplicate stream processor tests. --- .../data_sources/StreamingProcessor.test.ts | 213 ------------------ 1 file changed, 213 deletions(-) delete mode 100644 packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts deleted file mode 100644 index 4d66ee0cf..000000000 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -// FOR REVIEW ONLY: this has been moved to shared/common and is only here for PR -// review. Delete when review is finished. -import { EventName, ProcessStreamResponse } from '../../api'; -import { LDStreamProcessor } from '../../api/subsystem'; -import { LDStreamingError } from '../../errors'; -import StreamingProcessor from '../../src/data_sources/StreamingProcessor'; -import { defaultHeaders } from '../../utils'; -import { DiagnosticsManager } from '../diagnostics'; -import { basicPlatform, clientContext, logger } from '../mocks'; - -const dateNowString = '2023-08-10'; -const sdkKey = 'my-sdk-key'; -const { - basicConfiguration: { serviceEndpoints, tags }, - platform: { info }, -} = clientContext; -const event = { - data: { - flags: { - flagkey: { key: 'flagkey', version: 1 }, - }, - segments: { - segkey: { key: 'segkey', version: 2 }, - }, - }, -}; - -const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ - streamUri, - options, - onclose: jest.fn(), - addEventListener: jest.fn(), - close: jest.fn(), -}); - -describe('given a stream processor with mock event source', () => { - let streamingProcessor: LDStreamProcessor; - let diagnosticsManager: DiagnosticsManager; - let listeners: Map; - let mockEventSource: any; - let mockListener: ProcessStreamResponse; - let mockErrorHandler: jest.Mock; - let simulatePutEvent: (e?: any) => void; - let simulateError: (e: { status: number; message: string }) => boolean; - - beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(dateNowString)); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - beforeEach(() => { - mockErrorHandler = jest.fn(); - clientContext.basicConfiguration.logger = logger; - - basicPlatform.requests = { - createEventSource: jest.fn((streamUri: string, options: any) => { - mockEventSource = createMockEventSource(streamUri, options); - return mockEventSource; - }), - } as any; - simulatePutEvent = (e: any = event) => { - mockEventSource.addEventListener.mock.calls[0][1](e); - }; - simulateError = (e: { status: number; message: string }): boolean => - mockEventSource.options.errorFilter(e); - - listeners = new Map(); - mockListener = { - deserializeData: jest.fn((data) => data), - processJson: jest.fn(), - }; - listeners.set('put', mockListener); - listeners.set('patch', mockListener); - - diagnosticsManager = new DiagnosticsManager(sdkKey, basicPlatform, {}); - streamingProcessor = new StreamingProcessor( - sdkKey, - clientContext, - listeners, - diagnosticsManager, - mockErrorHandler, - ); - - jest.spyOn(streamingProcessor, 'stop'); - streamingProcessor.start(); - }); - - afterEach(() => { - streamingProcessor.close(); - jest.resetAllMocks(); - }); - - it('uses expected uri', () => { - expect(basicPlatform.requests.createEventSource).toBeCalledWith( - `${serviceEndpoints.streaming}/all`, - { - errorFilter: expect.any(Function), - headers: defaultHeaders(sdkKey, info, tags), - initialRetryDelayMillis: 1000, - readTimeoutMillis: 300000, - retryResetIntervalMillis: 60000, - }, - ); - }); - - it('adds listeners', () => { - expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( - 1, - 'put', - expect.any(Function), - ); - expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( - 2, - 'patch', - expect.any(Function), - ); - }); - - it('executes listeners', () => { - simulatePutEvent(); - const patchHandler = mockEventSource.addEventListener.mock.calls[1][1]; - patchHandler(event); - - expect(mockListener.deserializeData).toBeCalledTimes(2); - expect(mockListener.processJson).toBeCalledTimes(2); - }); - - it('passes error to callback if json data is malformed', async () => { - (mockListener.deserializeData as jest.Mock).mockReturnValue(false); - simulatePutEvent(); - - expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/)); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i)); - expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); - }); - - it('calls error handler if event.data prop is missing', async () => { - simulatePutEvent({ flags: {} }); - - expect(mockListener.deserializeData).not.toBeCalled(); - expect(mockListener.processJson).not.toBeCalled(); - expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i); - }); - - it.only('closes and stops', async () => { - streamingProcessor.close(); - - expect(streamingProcessor.stop).toBeCalled(); - expect(mockEventSource.close).toBeCalled(); - // @ts-ignore - expect(streamingProcessor.eventSource).toBeUndefined(); - }); - - it('creates a stream init event', async () => { - const startTime = Date.now(); - simulatePutEvent(); - - const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); - expect(diagnosticEvent.streamInits.length).toEqual(1); - const si = diagnosticEvent.streamInits[0]; - expect(si.timestamp).toEqual(startTime); - expect(si.failed).toBeFalsy(); - expect(si.durationMillis).toBeGreaterThanOrEqual(0); - }); - - describe.each([400, 408, 429, 500, 503])('given recoverable http errors', (status) => { - it(`continues retrying after error: ${status}`, () => { - const startTime = Date.now(); - const testError = { status, message: 'retry. recoverable.' }; - const willRetry = simulateError(testError); - - expect(willRetry).toBeTruthy(); - expect(mockErrorHandler).not.toBeCalled(); - expect(logger.warn).toBeCalledWith( - expect.stringMatching(new RegExp(`${status}.*will retry`)), - ); - - const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); - expect(diagnosticEvent.streamInits.length).toEqual(1); - const si = diagnosticEvent.streamInits[0]; - expect(si.timestamp).toEqual(startTime); - expect(si.failed).toBeTruthy(); - expect(si.durationMillis).toBeGreaterThanOrEqual(0); - }); - }); - - describe.each([401, 403])('given irrecoverable http errors', (status) => { - it(`stops retrying after error: ${status}`, () => { - const startTime = Date.now(); - const testError = { status, message: 'stopping. irrecoverable.' }; - const willRetry = simulateError(testError); - - expect(willRetry).toBeFalsy(); - expect(mockErrorHandler).toBeCalledWith( - new LDStreamingError(testError.message, testError.status), - ); - expect(logger.error).toBeCalledWith( - expect.stringMatching(new RegExp(`${status}.*permanently`)), - ); - - const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); - expect(diagnosticEvent.streamInits.length).toEqual(1); - const si = diagnosticEvent.streamInits[0]; - expect(si.timestamp).toEqual(startTime); - expect(si.failed).toBeTruthy(); - expect(si.durationMillis).toBeGreaterThanOrEqual(0); - }); - }); -}); From 71ee878b42d7ddb7e14374771e686f326594ee65 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 19 Sep 2023 12:29:48 -0700 Subject: [PATCH 40/57] chore: fixed missing mocks. prefix and add bash directive. --- .../__tests__/evaluation/Evaluator.segments.test.ts | 2 +- scripts/build-package.sh | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts index aa774db53..cd9730f93 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts @@ -126,7 +126,7 @@ describe('when evaluating user equivalent contexts for segments', () => { included: ['foo'], version: 1, }; - const evaluator = new Evaluator(basicPlatform, new TestQueries({ segments: [segment] })); + const evaluator = new Evaluator(mocks.basicPlatform, new TestQueries({ segments: [segment] })); const flag = makeFlagWithSegmentMatch(segment); flag.rules[0].clauses![0].negate = true; const user = { key: 'bar' }; diff --git a/scripts/build-package.sh b/scripts/build-package.sh index 93e38fe00..0969d60c8 100755 --- a/scripts/build-package.sh +++ b/scripts/build-package.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # Run this script like: # ./scripts/build-package.sh @@ -23,7 +25,7 @@ ESM_PACKAGE_JSON=$( jq -n \ --arg type "module" \ '{ name: $name, version: $version, type: $type }' ) -tsc --module commonjs --outDir dist/cjs/ +tsc --module commonjs --outDir dist/cjs/ echo "$CJS_PACKAGE_JSON" > dist/cjs/package.json tsc --module es2022 --outDir dist/esm/ From ca881def4febb1427ae42b6d1c3431765734a0dd Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 21 Sep 2023 12:29:23 -0700 Subject: [PATCH 41/57] chore: add bash directive to shell scripts --- scripts/doc-name.sh | 1 + scripts/package-name.sh | 1 + scripts/publish-doc.sh | 11 ++++++----- scripts/publish.sh | 1 + scripts/replace-version.sh | 1 + 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/doc-name.sh b/scripts/doc-name.sh index 150c0fb70..5e252b768 100755 --- a/scripts/doc-name.sh +++ b/scripts/doc-name.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # Given a path get the name for the documentation. # ./scripts/doc-name.sh packages/sdk/server-node # Produces something like: diff --git a/scripts/package-name.sh b/scripts/package-name.sh index e443b2c7d..1827284c7 100755 --- a/scripts/package-name.sh +++ b/scripts/package-name.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # Given a path get the name of the package. # ./scripts/package-name.sh packages/sdk/server-node # Produces something like: diff --git a/scripts/publish-doc.sh b/scripts/publish-doc.sh index 44d885c79..46e74467f 100755 --- a/scripts/publish-doc.sh +++ b/scripts/publish-doc.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # Run this script like: # ./scripts/publish-doc.sh packages/sdk/node @@ -55,10 +56,10 @@ set +e while true do - + git pull origin gh-pages --no-edit # should accept the default message after_pull_sha=$(git rev-parse HEAD) - + # The first time this runs the head_sha will be empty and they will not match. # If the push fails, then we pull again, and if the SHA does not change, then # the push will not succeed. @@ -66,13 +67,13 @@ do echo "Failed to get changes. Could not publish docs." exit 1 fi - + head_sha=$after_pull_sha - + if git push; then break fi - + echo "Push failed, trying again." done diff --git a/scripts/publish.sh b/scripts/publish.sh index 597e964da..8619a6a12 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash if $LD_RELEASE_IS_DRYRUN ; then # Dry run just pack the workspace. echo "Doing a dry run of publishing." diff --git a/scripts/replace-version.sh b/scripts/replace-version.sh index 87d2309af..1e3fd218e 100755 --- a/scripts/replace-version.sh +++ b/scripts/replace-version.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # Run this script like: # ./scripts/replace-version.sh packages/sdk/node From 703f56d3aee947363a99e9a80c0757f8b23a44df Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 21 Sep 2023 14:05:07 -0700 Subject: [PATCH 42/57] Update package.json --- packages/shared/sdk-client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index 952bc4652..707f622dd 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -30,7 +30,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "1.0.2", + "@launchdarkly/js-sdk-common": "^1.1.0", "semver": "7.5.4" }, "devDependencies": { From c120948d219ad4cc35b5ea6b58bf65d7ad58ee77 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 22 Sep 2023 09:25:33 -0700 Subject: [PATCH 43/57] feat: Do not generate an event if the measurements are inconsistent. (#282) --- .../__tests__/MigrationOpTracker.test.ts | 105 ++++++++++++++++++ .../sdk-server/src/MigrationOpTracker.ts | 20 ++-- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 1cb0dd280..4c045717e 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -265,3 +265,108 @@ it('can handle exceptions thrown in the consistency check method', () => { }, ]); }); + +it.each([ + [false, true, true, false], + [true, false, false, true], + [false, true, true, true], + [true, false, true, true], +])( + 'does not generate an event if latency measurement without correct invoked measurement' + + ' invoke old: %p invoke new: %p measure old: %p measure new: %p', + (invoke_old, invoke_new, measure_old, measure_new) => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + tracker.op('write'); + if (invoke_old) { + tracker.invoked('old'); + } + if (invoke_new) { + tracker.invoked('new'); + } + if (measure_old) { + tracker.latency('old', 100); + } + if (measure_new) { + tracker.latency('new', 100); + } + + expect(tracker.createEvent()).toBeUndefined(); + }, +); + +it.each([ + [false, true, true, false], + [true, false, false, true], + [false, true, true, true], + [true, false, true, true], +])( + 'does not generate an event error measurement without correct invoked measurement' + + ' invoke old: %p invoke new: %p measure old: %p measure new: %p', + (invoke_old, invoke_new, measure_old, measure_new) => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + tracker.op('write'); + if (invoke_old) { + tracker.invoked('old'); + } + if (invoke_new) { + tracker.invoked('new'); + } + if (measure_old) { + tracker.error('old'); + } + if (measure_new) { + tracker.error('new'); + } + + expect(tracker.createEvent()).toBeUndefined(); + }, +); + +it.each([ + [true, false, true], + [false, true, true], + [true, false, false], + [false, true, false], +])( + 'does not generate an event if there is a consistency measurement but both origins were not invoked' + + ' invoke old: %p invoke new: %p consistent: %p', + (invoke_old, invoke_new, consistent) => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + tracker.op('write'); + if (invoke_old) { + tracker.invoked('old'); + } + if (invoke_new) { + tracker.invoked('new'); + } + tracker.consistency(() => consistent); + expect(tracker.createEvent()).toBeUndefined(); + }, +); diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index 795572c00..eb30be50b 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -26,7 +26,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { new: false, }; - private consistencyCheck?: LDConsistencyCheck; + private consistencyCheck: LDConsistencyCheck = LDConsistencyCheck.NotChecked; private latencyMeasurement = { old: NaN, @@ -101,13 +101,16 @@ export default class MigrationOpTracker implements LDMigrationTracker { return undefined; } + if (!this.measurementConsistencyCheck()) { + return undefined; + } + const measurements: LDMigrationMeasurement[] = []; this.populateInvoked(measurements); this.populateConsistency(measurements); this.populateLatency(measurements); this.populateErrors(measurements); - this.measurementConsistencyCheck(); return { kind: 'migration_op', @@ -145,32 +148,35 @@ export default class MigrationOpTracker implements LDMigrationTracker { ); } - private checkOriginEventConsistency(origin: LDMigrationOrigin) { + private checkOriginEventConsistency(origin: LDMigrationOrigin): boolean { if (this.wasInvoked[origin]) { - return; + return true; } // If the specific origin was not invoked, but it contains measurements, then // that is a problem. Check each measurement and log a message if it is present. if (!Number.isNaN(this.latencyMeasurement[origin])) { this.logger?.error(`${this.logTag()} ${this.latencyConsistencyMessage(origin)}`); + return false; } if (this.errors[origin]) { this.logger?.error(`${this.logTag()} ${this.errorConsistencyMessage(origin)}`); + return false; } if (this.consistencyCheck !== LDConsistencyCheck.NotChecked) { this.logger?.error(`${this.logTag()} ${this.consistencyCheckConsistencyMessage(origin)}`); + return false; } + return true; } /** * Check that the latency, error, consistency and invoked measurements are self-consistent. */ - private measurementConsistencyCheck() { - this.checkOriginEventConsistency('old'); - this.checkOriginEventConsistency('new'); + private measurementConsistencyCheck(): boolean { + return this.checkOriginEventConsistency('old') && this.checkOriginEventConsistency('new'); } private populateInvoked(measurements: LDMigrationMeasurement[]) { From 9c5f402a677e3a37c366ac94ef676a5805d852b9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 22 Sep 2023 09:25:44 -0700 Subject: [PATCH 44/57] feat: Add flag version to migration op event. (#281) --- .../__tests__/MigrationOpTracker.test.ts | 78 +++++++++++++++++++ .../shared/sdk-server/src/LDClientImpl.ts | 2 + .../src/MigrationOpEventConversion.ts | 4 + .../sdk-server/src/MigrationOpTracker.ts | 2 + .../src/api/data/LDMigrationOpEvent.ts | 1 + 5 files changed, 87 insertions(+) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 4c045717e..8346aa2b5 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -58,6 +58,83 @@ it('generates an event if the minimal requirements are met.', () => { }); }); +it('can include the variation in the event', () => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + undefined, + 1, + ); + + tracker.op('write'); + tracker.invoked('old'); + + expect(tracker.createEvent()).toMatchObject({ + contextKeys: { user: 'bob' }, + evaluation: { + default: 'off', + key: 'flag', + reason: { kind: 'FALLTHROUGH' }, + value: 'off', + variation: 1, + }, + kind: 'migration_op', + measurements: [ + { + key: 'invoked', + values: { + old: true, + }, + }, + ], + operation: 'write', + }); +}); + +it('can include the version in the event', () => { + const tracker = new MigrationOpTracker( + 'flag', + { user: 'bob' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + undefined, + undefined, + 2, + ); + + tracker.op('write'); + tracker.invoked('old'); + + expect(tracker.createEvent()).toMatchObject({ + contextKeys: { user: 'bob' }, + evaluation: { + default: 'off', + key: 'flag', + reason: { kind: 'FALLTHROUGH' }, + value: 'off', + version: 2, + }, + kind: 'migration_op', + measurements: [ + { + key: 'invoked', + values: { + old: true, + }, + }, + ], + operation: 'write', + }); +}); + it('includes errors if at least one is set', () => { const tracker = new MigrationOpTracker( 'flag', @@ -250,6 +327,7 @@ it('can handle exceptions thrown in the consistency check method', () => { undefined, undefined, undefined, + undefined, logger, ); tracker.op('read'); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 3b8590ad7..80c94f904 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -336,6 +336,7 @@ export default class LDClientImpl implements LDClient { reason, checkRatio, undefined, + undefined, samplingRatio, ), }); @@ -352,6 +353,7 @@ export default class LDClientImpl implements LDClient { checkRatio, // Can be null for compatibility reasons. detail.variationIndex === null ? undefined : detail.variationIndex, + flag?.version, samplingRatio, ), }); diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index f18348c30..75b6a1530 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -196,6 +196,10 @@ function validateEvaluation(evaluation: LDMigrationEvaluation): LDMigrationEvalu validated.variation = evaluation.variation; } + if (evaluation.version !== undefined && TypeValidators.Number.is(evaluation.version)) { + validated.version = evaluation.version; + } + return validated; } diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index eb30be50b..9001e42fd 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -43,6 +43,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { private readonly reason: LDEvaluationReason, private readonly checkRatio?: number, private readonly variation?: number, + private readonly version?: number, private readonly samplingRatio?: number, private readonly logger?: LDLogger, ) {} @@ -123,6 +124,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { default: this.defaultStage, reason: this.reason, variation: this.variation, + version: this.version, }, measurements, samplingRatio: this.samplingRatio ?? 1, diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index c3e69d702..544a7b34c 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -13,6 +13,7 @@ export interface LDMigrationEvaluation { value: LDMigrationStage; default: LDMigrationStage; variation?: number; + version?: number; reason: LDEvaluationReason; } From f3481a4b767647151c67d1d4d94a59b116ceb938 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 25 Sep 2023 10:58:06 -0700 Subject: [PATCH 45/57] feat: Use a factory method to create migrations. (#283) --- contract-tests/sdkClientEntity.js | 5 ++--- .../sdk-server/__tests__/Migration.test.ts | 18 +++++++++--------- .../__tests__/MigrationOpEvent.test.ts | 19 ++++++++++--------- packages/shared/sdk-server/src/Migration.ts | 19 ++++++++++++++++++- .../shared/sdk-server/src/api/LDMigration.ts | 4 ++-- packages/shared/sdk-server/src/index.ts | 5 ++--- 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index 3830730d4..bd9ad2d4f 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -1,12 +1,11 @@ import got from 'got'; import ld, { LDConcurrentExecution, - LDExecution, LDExecutionOrdering, LDMigrationError, LDMigrationSuccess, LDSerialExecution, - Migration, + createMigration, } from 'node-server-sdk'; import BigSegmentTestStore from './BigSegmentTestStore.js'; @@ -172,7 +171,7 @@ export async function newSdkClientEntity(options) { const migrationOperation = params.migrationOperation; const readExecutionOrder = migrationOperation.readExecutionOrder; - const migration = new Migration(client, { + const migration = createMigration(client, { execution: getExecution(readExecutionOrder), latencyTracking: migrationOperation.trackLatency, errorTracking: migrationOperation.trackErrors, diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts index 79207f0f1..2a23fb3ed 100644 --- a/packages/shared/sdk-server/__tests__/Migration.test.ts +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -7,7 +7,7 @@ import { } from '../src'; import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; -import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import { createMigration, LDMigrationError, LDMigrationSuccess } from '../src/Migration'; import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; @@ -172,7 +172,7 @@ describe('given an LDClient with test data', () => { ], ])('given each migration step: %p, read: %p, write: %j.', (stage, readValue, writeMatch) => { it('uses the correct authoritative source', async () => { - const migration = new Migration(client, { + const migration = createMigration(client, { execution, latencyTracking: false, errorTracking: false, @@ -204,7 +204,7 @@ describe('given an LDClient with test data', () => { it('correctly forwards the payload for read and write operations', async () => { let receivedReadPayload: string | undefined; let receivedWritePayload: string | undefined; - const migration = new Migration(client, { + const migration = createMigration(client, { execution, latencyTracking: false, errorTracking: false, @@ -249,7 +249,7 @@ describe('given an LDClient with test data', () => { [LDMigrationStage.RampDown, 'new'], [LDMigrationStage.Complete, 'new'], ])('handles read errors for stage: %p', async (stage, authority) => { - const migration = new Migration(client, { + const migration = createMigration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), latencyTracking: false, errorTracking: false, @@ -282,7 +282,7 @@ describe('given an LDClient with test data', () => { [LDMigrationStage.RampDown, 'new'], [LDMigrationStage.Complete, 'new'], ])('handles exceptions for stage: %p', async (stage, authority) => { - const migration = new Migration(client, { + const migration = createMigration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), latencyTracking: false, errorTracking: false, @@ -378,7 +378,7 @@ describe('given an LDClient with test data', () => { let oldWriteCalled = false; let newWriteCalled = false; - const migration = new Migration(client, { + const migration = createMigration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), latencyTracking: false, errorTracking: false, @@ -420,7 +420,7 @@ describe('given an LDClient with test data', () => { let oldWriteCalled = false; let newWriteCalled = false; - const migration = new Migration(client, { + const migration = createMigration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), latencyTracking: false, errorTracking: false, @@ -453,7 +453,7 @@ describe('given an LDClient with test data', () => { }); it('handles the case where the authoritative write succeeds, but the non-authoritative fails', async () => { - const migrationA = new Migration(client, { + const migrationA = createMigration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), latencyTracking: false, errorTracking: false, @@ -500,7 +500,7 @@ describe('given an LDClient with test data', () => { }, }); - const migrationB = new Migration(client, { + const migrationB = createMigration(client, { execution: new LDSerialExecution(LDExecutionOrdering.Fixed), latencyTracking: false, errorTracking: false, diff --git a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts index c7dc7a4b7..6e1e214d3 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts @@ -10,9 +10,10 @@ import { LDMigrationStage, LDSerialExecution, } from '../src'; +import { LDMigration } from '../src/api/LDMigration'; import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; -import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import { createMigration, LDMigrationError, LDMigrationSuccess } from '../src/Migration'; import MigrationOpEventConversion from '../src/MigrationOpEventConversion'; import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; @@ -64,9 +65,9 @@ describe('given an LDClient with test data', () => { [new LDConcurrentExecution(), 'concurrent'], ])('given different execution methods: %p %p', (execution) => { describe('given a migration which checks consistency and produces consistent results', () => { - let migration: Migration; + let migration: LDMigration; beforeEach(() => { - migration = new Migration(client, { + migration = createMigration(client, { execution, latencyTracking: false, errorTracking: false, @@ -137,9 +138,9 @@ describe('given an LDClient with test data', () => { }); describe('given a migration which checks consistency and produces inconsistent results', () => { - let migration: Migration; + let migration: LDMigration; beforeEach(() => { - migration = new Migration(client, { + migration = createMigration(client, { execution, latencyTracking: false, errorTracking: false, @@ -171,7 +172,7 @@ describe('given an LDClient with test data', () => { }); describe('given a migration which takes time to execute and tracks latency', () => { - let migration: Migration; + let migration: LDMigration; function timeoutPromise(val: TReturn): Promise { return new Promise((a) => { @@ -180,7 +181,7 @@ describe('given an LDClient with test data', () => { } beforeEach(() => { - migration = new Migration(client, { + migration = createMigration(client, { execution, latencyTracking: true, errorTracking: false, @@ -351,9 +352,9 @@ describe('given an LDClient with test data', () => { }); describe('given a migration which produces errors for every step', () => { - let migration: Migration; + let migration: LDMigration; beforeEach(() => { - migration = new Migration(client, { + migration = createMigration(client, { execution, latencyTracking: false, errorTracking: true, diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index e6552ee51..ebcebf195 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -91,7 +91,7 @@ interface MigrationContext { /** * Class which allows performing technology migrations. */ -export default class Migration< +class Migration< TMigrationRead, TMigrationWrite, TMigrationReadInput = any, @@ -372,3 +372,20 @@ export default class Migration< return result; } } + +export function createMigration< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput = any, + TMigrationWriteInput = any, +>( + client: LDClient, + config: LDMigrationOptions< + TMigrationRead, + TMigrationWrite, + TMigrationReadInput, + TMigrationWriteInput + >, +): LDMigration { + return new Migration(client, config); +} diff --git a/packages/shared/sdk-server/src/api/LDMigration.ts b/packages/shared/sdk-server/src/api/LDMigration.ts index ff8bc3db5..707678645 100644 --- a/packages/shared/sdk-server/src/api/LDMigration.ts +++ b/packages/shared/sdk-server/src/api/LDMigration.ts @@ -56,8 +56,8 @@ export type LDMigrationWriteResult = { export interface LDMigration< TMigrationRead, TMigrationWrite, - TMigrationReadInput, - TMigrationWriteInput, + TMigrationReadInput = any, + TMigrationWriteInput = any, > { /** * Perform a read using the migration. diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index f00a34adf..1c99f0097 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -1,7 +1,6 @@ import BigSegmentStoreStatusProviderImpl from './BigSegmentStatusProviderImpl'; import LDClientImpl from './LDClientImpl'; -// TODO: Maybe we have a factory? -import Migration, { LDMigrationError, LDMigrationSuccess } from './Migration'; +import { createMigration, LDMigrationError, LDMigrationSuccess } from './Migration'; export * as integrations from './integrations'; export * as platform from '@launchdarkly/js-sdk-common'; @@ -16,5 +15,5 @@ export { BigSegmentStoreStatusProviderImpl, LDMigrationError, LDMigrationSuccess, - Migration, + createMigration, }; From dda6b37b91431ef8cc15fa7f43a2fc87557ee70a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:07:04 -0700 Subject: [PATCH 46/57] feat: No migration op event for empty flag key. (#287) --- .../__tests__/MigrationOpTracker.test.ts | 23 ++++++++++++++++++- .../sdk-server/src/MigrationOpTracker.ts | 12 +++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 8346aa2b5..4c44f80bf 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -14,6 +14,8 @@ it('does not generate an event if an op is not set', () => { }, ); + tracker.invoked('old'); + expect(tracker.createEvent()).toBeUndefined(); }); @@ -22,8 +24,27 @@ it('does not generate an event with missing context keys', () => { kind: 'FALLTHROUGH', }); - // Set the op otherwise that would prevent an event as well. + // Set the op otherwise/invoked that would prevent an event as well. tracker.op('write'); + tracker.invoked('old'); + + expect(tracker.createEvent()).toBeUndefined(); +}); + +it('does not generate an event with empty flag key', () => { + const tracker = new MigrationOpTracker( + '', + { key: 'user-key' }, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); + + // Set the op/invoked otherwise that would prevent an event as well. + tracker.op('write'); + tracker.invoked('old'); expect(tracker.createEvent()).toBeUndefined(); }); diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index 9001e42fd..fae4884bf 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -1,4 +1,9 @@ -import { internal, LDEvaluationReason, LDLogger } from '@launchdarkly/js-sdk-common'; +import { + internal, + LDEvaluationReason, + LDLogger, + TypeValidators, +} from '@launchdarkly/js-sdk-common'; import { LDMigrationStage, LDMigrationTracker } from './api'; import { @@ -82,6 +87,11 @@ export default class MigrationOpTracker implements LDMigrationTracker { } createEvent(): LDMigrationOpEvent | undefined { + if (!TypeValidators.String.is(this.flagKey) || this.flagKey === '') { + this.logger?.error('The flag key for a migration operation must be a non-empty string.'); + return undefined; + } + if (!this.operation) { this.logger?.error('The operation must be set using "op" before an event can be created.'); return undefined; From 8e96a5200e690dc364a0cdb012c7193304f0e812 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:36:12 -0700 Subject: [PATCH 47/57] feat: Add typed variation methods. (#288) Co-authored-by: Yusinto Ngadiman resolves #285 --- contract-tests/index.js | 3 +- contract-tests/sdkClientEntity.js | 28 ++- .../common/src/api/data/LDEvaluationDetail.ts | 19 ++ .../__tests__/LDClient.evaluation.test.ts | 93 +++++++++ .../__tests__/LDClient.migrations.test.ts | 6 +- .../shared/sdk-server/src/LDClientImpl.ts | 164 ++++++++++++++-- packages/shared/sdk-server/src/Migration.ts | 4 +- .../shared/sdk-server/src/api/LDClient.ts | 184 +++++++++++++++++- .../src/api/data/LDMigrationVariation.ts | 2 +- .../sdk-server/src/evaluation/ErrorKinds.ts | 1 + 10 files changed, 471 insertions(+), 33 deletions(-) diff --git a/contract-tests/index.js b/contract-tests/index.js index 6a5b72809..1588b4654 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -31,7 +31,8 @@ app.get('/', (req, res) => { 'migrations', 'event-sampling', 'config-override-kind', - 'metric-kind' + 'metric-kind', + 'strongly-typed', ], }); }); diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index bd9ad2d4f..080c97813 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -124,10 +124,30 @@ export async function newSdkClientEntity(options) { case 'evaluate': { const pe = params.evaluate; if (pe.detail) { - return await client.variationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + switch(pe.valueType) { + case "bool": + return await client.boolVariationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + case "int": // Intentional fallthrough. + case "double": + return await client.numberVariationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + case "string": + return await client.stringVariationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + default: + return await client.variationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + } + } else { - const value = await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue); - return { value }; + switch(pe.valueType) { + case "bool": + return {value: await client.boolVariation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; + case "int": // Intentional fallthrough. + case "double": + return {value: await client.numberVariation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; + case "string": + return {value: await client.stringVariation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; + default: + return {value: await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; + } } } @@ -160,7 +180,7 @@ export async function newSdkClientEntity(options) { case 'migrationVariation': const migrationVariation = params.migrationVariation; - const res = await client.variationMigration( + const res = await client.migrationVariation( migrationVariation.key, migrationVariation.context, migrationVariation.defaultStage, diff --git a/packages/shared/common/src/api/data/LDEvaluationDetail.ts b/packages/shared/common/src/api/data/LDEvaluationDetail.ts index 37c959ce1..0a4f11dd7 100644 --- a/packages/shared/common/src/api/data/LDEvaluationDetail.ts +++ b/packages/shared/common/src/api/data/LDEvaluationDetail.ts @@ -27,3 +27,22 @@ export interface LDEvaluationDetail { */ reason: LDEvaluationReason; } + +export interface LDEvaluationDetailTyped { + /** + * The result of the flag evaluation. This will be either one of the flag's variations or + * the default value that was passed to `LDClient.variationDetail`. + */ + value: TFlag; + + /** + * The index of the returned value within the flag's list of variations, e.g. 0 for the + * first variation-- or `null` if the default value was returned. + */ + variationIndex?: number | null; + + /** + * An object describing the main factor that influenced the flag evaluation value. + */ + reason: LDEvaluationReason; +} diff --git a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts index a5665de72..301f2c2d2 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts @@ -160,6 +160,99 @@ describe('given an LDClient with test data', () => { const valueB = await client.variation('my-feature-flag-1', userContextObject, 'default'); expect(valueB).toEqual(true); }); + + it('evaluates with jsonVariation', async () => { + td.update(td.flag('flagkey').booleanFlag().on(true)); + const boolRes: boolean = (await client.jsonVariation('flagkey', defaultUser, false)) as boolean; + expect(boolRes).toBe(true); + + td.update(td.flag('flagkey').valueForAll(62)); + const numericRes: number = (await client.jsonVariation( + 'flagkey', + defaultUser, + false, + )) as number; + expect(numericRes).toBe(62); + + td.update(td.flag('flagkey').valueForAll('potato')); + const stringRes: string = (await client.jsonVariation('flagkey', defaultUser, false)) as string; + expect(stringRes).toBe('potato'); + }); + + it('evaluates an existing boolean flag', async () => { + td.update(td.flag('flagkey').booleanFlag().on(true)); + expect(await client.boolVariation('flagkey', defaultUser, false)).toEqual(true); + }); + + it('it uses the default value when a boolean variation is for a flag of the wrong type', async () => { + td.update(td.flag('flagkey').valueForAll('potato')); + expect(await client.boolVariation('flagkey', defaultUser, false)).toEqual(false); + }); + + it('evaluates an existing numeric flag', async () => { + td.update(td.flag('flagkey').booleanFlag().valueForAll(18)); + expect(await client.numberVariation('flagkey', defaultUser, 36)).toEqual(18); + }); + + it('it uses the default value when a numeric variation is for a flag of the wrong type', async () => { + td.update(td.flag('flagkey').valueForAll('potato')); + expect(await client.numberVariation('flagkey', defaultUser, 36)).toEqual(36); + }); + + it('evaluates an existing string flag', async () => { + td.update(td.flag('flagkey').booleanFlag().valueForAll('potato')); + expect(await client.stringVariation('flagkey', defaultUser, 'default')).toEqual('potato'); + }); + + it('it uses the default value when a string variation is for a flag of the wrong type', async () => { + td.update(td.flag('flagkey').valueForAll(8)); + expect(await client.stringVariation('flagkey', defaultUser, 'default')).toEqual('default'); + }); + + it('evaluates an existing boolean flag with detail', async () => { + td.update(td.flag('flagkey').booleanFlag().on(true)); + const res = await client.boolVariationDetail('flagkey', defaultUser, false); + expect(res.value).toEqual(true); + expect(res.reason.kind).toBe('FALLTHROUGH'); + }); + + it('it uses the default value when a boolean variation is for a flag of the wrong type with detail', async () => { + td.update(td.flag('flagkey').valueForAll('potato')); + const res = await client.boolVariationDetail('flagkey', defaultUser, false); + expect(res.value).toEqual(false); + expect(res.reason.kind).toEqual('ERROR'); + expect(res.reason.errorKind).toEqual('WRONG_TYPE'); + }); + + it('evaluates an existing numeric flag with detail', async () => { + td.update(td.flag('flagkey').booleanFlag().valueForAll(18)); + const res = await client.numberVariationDetail('flagkey', defaultUser, 36); + expect(res.value).toEqual(18); + expect(res.reason.kind).toBe('FALLTHROUGH'); + }); + + it('it uses the default value when a numeric variation is for a flag of the wrong type with detail', async () => { + td.update(td.flag('flagkey').valueForAll('potato')); + const res = await client.numberVariationDetail('flagkey', defaultUser, 36); + expect(res.value).toEqual(36); + expect(res.reason.kind).toEqual('ERROR'); + expect(res.reason.errorKind).toEqual('WRONG_TYPE'); + }); + + it('evaluates an existing string flag with detail', async () => { + td.update(td.flag('flagkey').booleanFlag().valueForAll('potato')); + const res = await client.stringVariationDetail('flagkey', defaultUser, 'default'); + expect(res.value).toEqual('potato'); + expect(res.reason.kind).toBe('FALLTHROUGH'); + }); + + it('it uses the default value when a string variation is for a flag of the wrong type with detail', async () => { + td.update(td.flag('flagkey').valueForAll(8)); + const res = await client.stringVariationDetail('flagkey', defaultUser, 'default'); + expect(res.value).toEqual('default'); + expect(res.reason.kind).toEqual('ERROR'); + expect(res.reason.errorKind).toEqual('WRONG_TYPE'); + }); }); describe('given an offline client', () => { diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index 460c3c333..b3ee43612 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -57,7 +57,7 @@ describe('given an LDClient with test data', () => { const defaultValue = Object.values(LDMigrationStage).find((item) => item !== value); // Verify the pre-condition that the default value is not the value under test. expect(defaultValue).not.toEqual(value); - const res = await client.variationMigration( + const res = await client.migrationVariation( flagKey, { key: 'test-key' }, defaultValue as LDMigrationStage, @@ -74,7 +74,7 @@ describe('given an LDClient with test data', () => { LDMigrationStage.RampDown, LDMigrationStage.Complete, ])('returns the default value if the flag does not exist: default = %p', async (stage) => { - const res = await client.variationMigration('no-flag', { key: 'test-key' }, stage); + const res = await client.migrationVariation('no-flag', { key: 'test-key' }, stage); expect(res.value).toEqual(stage); }); @@ -82,7 +82,7 @@ describe('given an LDClient with test data', () => { it('produces an error event for a migration flag with an incorrect value', async () => { const flagKey = 'bad-migration'; td.update(td.flag(flagKey).valueForAll('potato')); - const res = await client.variationMigration(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + const res = await client.migrationVariation(flagKey, { key: 'test-key' }, LDMigrationStage.Off); expect(res.value).toEqual(LDMigrationStage.Off); expect(errors.length).toEqual(1); expect(errors[0].message).toEqual( diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 80c94f904..98fc32fed 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -7,9 +7,11 @@ import { internal, LDContext, LDEvaluationDetail, + LDEvaluationDetailTyped, LDLogger, Platform, subsystem, + TypeValidators, } from '@launchdarkly/js-sdk-common'; import { @@ -301,7 +303,105 @@ export default class LDClientImpl implements LDClient { }); } - async variationMigration( + private typedEval( + key: string, + context: LDContext, + defaultValue: TResult, + eventFactory: EventFactory, + typeChecker: (value: unknown) => [boolean, string], + ): Promise { + return new Promise>((resolve) => { + this.evaluateIfPossible( + key, + context, + defaultValue, + eventFactory, + (res) => { + const typedRes: LDEvaluationDetailTyped = { + value: res.detail.value as TResult, + reason: res.detail.reason, + variationIndex: res.detail.variationIndex, + }; + resolve(typedRes); + }, + typeChecker, + ); + }); + } + + async boolVariation(key: string, context: LDContext, defaultValue: boolean): Promise { + return ( + await this.typedEval(key, context, defaultValue, this.eventFactoryDefault, (value) => [ + TypeValidators.Boolean.is(value), + TypeValidators.Boolean.getType(), + ]) + ).value; + } + + async numberVariation(key: string, context: LDContext, defaultValue: number): Promise { + return ( + await this.typedEval(key, context, defaultValue, this.eventFactoryDefault, (value) => [ + TypeValidators.Number.is(value), + TypeValidators.Number.getType(), + ]) + ).value; + } + + async stringVariation(key: string, context: LDContext, defaultValue: string): Promise { + return ( + await this.typedEval(key, context, defaultValue, this.eventFactoryDefault, (value) => [ + TypeValidators.String.is(value), + TypeValidators.String.getType(), + ]) + ).value; + } + + jsonVariation(key: string, context: LDContext, defaultValue: unknown): Promise { + return this.variation(key, context, defaultValue); + } + + boolVariationDetail( + key: string, + context: LDContext, + defaultValue: boolean, + ): Promise> { + return this.typedEval(key, context, defaultValue, this.eventFactoryWithReasons, (value) => [ + TypeValidators.Boolean.is(value), + TypeValidators.Boolean.getType(), + ]); + } + + numberVariationDetail( + key: string, + context: LDContext, + defaultValue: number, + ): Promise> { + return this.typedEval(key, context, defaultValue, this.eventFactoryWithReasons, (value) => [ + TypeValidators.Number.is(value), + TypeValidators.Number.getType(), + ]); + } + + stringVariationDetail( + key: string, + context: LDContext, + defaultValue: string, + ): Promise> { + return this.typedEval(key, context, defaultValue, this.eventFactoryWithReasons, (value) => [ + TypeValidators.String.is(value), + TypeValidators.String.getType(), + ]); + } + + jsonVariationDetail( + key: string, + context: LDContext, + defaultValue: unknown, + ): Promise> { + return this.variationDetail(key, context, defaultValue); + } + + async migrationVariation( key: string, context: LDContext, defaultValue: LDMigrationStage, @@ -523,6 +623,7 @@ export default class LDClientImpl implements LDClient { defaultValue: any, eventFactory: EventFactory, cb: (res: EvalResult, flag?: Flag) => void, + typeChecker?: (value: any) => [boolean, string], ): void { if (this.config.offline) { this.logger?.info('Variation called in offline mode. Returning default value.'); @@ -574,24 +675,23 @@ export default class LDClientImpl implements LDClient { evalRes.setDefault(defaultValue); } - // Immediately invoked function expression to get the event out of the callback - // path and allow access to async methods. - (async () => { - const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); - evalRes.events?.forEach((event) => { - this.eventProcessor.sendEvent({ ...event, indexSamplingRatio }); - }); - this.eventProcessor.sendEvent( - eventFactory.evalEvent( - flag, - evalContext, - evalRes.detail, + if (typeChecker) { + const [matched, type] = typeChecker(evalRes.detail.value); + if (!matched) { + const errorRes = EvalResult.forError( + ErrorKinds.WrongType, + `Did not receive expected type (${type}) evaluating feature flag "${flagKey}"`, defaultValue, - undefined, - indexSamplingRatio, - ), - ); - })(); + ); + // Method intentionally not awaited, puts event processing outside hot path. + this.sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); + cb(errorRes, flag); + return; + } + } + + // Method intentionally not awaited, puts event processing outside hot path. + this.sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); cb(evalRes, flag); }, eventFactory, @@ -599,12 +699,36 @@ export default class LDClientImpl implements LDClient { }); } + private async sendEvalEvent( + evalRes: EvalResult, + eventFactory: EventFactory, + flag: Flag, + evalContext: Context, + defaultValue: any, + ) { + const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); + evalRes.events?.forEach((event) => { + this.eventProcessor.sendEvent({ ...event, indexSamplingRatio }); + }); + this.eventProcessor.sendEvent( + eventFactory.evalEvent( + flag, + evalContext, + evalRes.detail, + defaultValue, + undefined, + indexSamplingRatio, + ), + ); + } + private evaluateIfPossible( flagKey: string, context: LDContext, defaultValue: any, eventFactory: EventFactory, cb: (res: EvalResult, flag?: Flag) => void, + typeChecker?: (value: any) => [boolean, string], ): void { if (!this.initialized()) { this.featureStore.initialized((storeInitialized) => { @@ -613,7 +737,7 @@ export default class LDClientImpl implements LDClient { 'Variation called before LaunchDarkly client initialization completed' + " (did you wait for the 'ready' event?) - using last known values from feature store", ); - this.variationInternal(flagKey, context, defaultValue, eventFactory, cb); + this.variationInternal(flagKey, context, defaultValue, eventFactory, cb, typeChecker); return; } this.logger?.warn( @@ -624,6 +748,6 @@ export default class LDClientImpl implements LDClient { }); return; } - this.variationInternal(flagKey, context, defaultValue, eventFactory, cb); + this.variationInternal(flagKey, context, defaultValue, eventFactory, cb, typeChecker); } } diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index ebcebf195..3eebf76d9 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -232,7 +232,7 @@ class Migration< defaultStage: LDMigrationStage, payload?: TMigrationReadInput, ): Promise> { - const stage = await this.client.variationMigration(key, context, defaultStage); + const stage = await this.client.migrationVariation(key, context, defaultStage); const res = await this.readTable[stage.value]({ payload, tracker: stage.tracker, @@ -248,7 +248,7 @@ class Migration< defaultStage: LDMigrationStage, payload?: TMigrationWriteInput, ): Promise> { - const stage = await this.client.variationMigration(key, context, defaultStage); + const stage = await this.client.migrationVariation(key, context, defaultStage); const res = await this.writeTable[stage.value]({ payload, tracker: stage.tracker, diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index cb4534947..177727c44 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -1,4 +1,9 @@ -import { LDContext, LDEvaluationDetail, LDFlagValue } from '@launchdarkly/js-sdk-common'; +import { + LDContext, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDFlagValue, +} from '@launchdarkly/js-sdk-common'; import { LDMigrationOpEvent, LDMigrationVariation } from './data'; import { LDFlagsState } from './data/LDFlagsState'; @@ -137,12 +142,187 @@ export interface LDClient { * @returns * A Promise which will be resolved with the result (as an{@link LDMigrationVariation}). */ - variationMigration( + migrationVariation( key: string, context: LDContext, defaultValue: LDMigrationStage, ): Promise; + /** + * Determines the boolean variation of a feature flag for a context. + * + * If the flag variation does not have a boolean value, defaultValue is returned. + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result value. + */ + boolVariation(key: string, context: LDContext, defaultValue: boolean): Promise; + + /** + * Determines the numeric variation of a feature flag for a context. + * + * If the flag variation does not have a numeric value, defaultValue is returned. + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result value. + */ + numberVariation(key: string, context: LDContext, defaultValue: number): Promise; + + /** + * Determines the string variation of a feature flag for a context. + * + * If the flag variation does not have a string value, defaultValue is returned. + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result value. + */ + stringVariation(key: string, context: LDContext, defaultValue: string): Promise; + + /** + * Determines the variation of a feature flag for a context. + * + * This version may be favored in TypeScript versus `variation` because it returns + * an `unknown` type instead of `any`. `unknown` will require a cast before usage. + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result value. + */ + jsonVariation(key: string, context: LDContext, defaultValue: unknown): Promise; + + /** + * Determines the boolean variation of a feature flag for a context, along with information about + * how it was calculated. + * + * The `reason` property of the result will also be included in analytics events, if you are + * capturing detailed event data for this flag. + * + * If the flag variation does not have a boolean value, defaultValue is returned. The reason will + * indicate an error of the type `WRONG_KIND` in this case. + * + * For more information, see the [SDK reference + * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result + * (as an {@link LDEvaluationDetailTyped}). + */ + boolVariationDetail( + key: string, + context: LDContext, + defaultValue: boolean, + ): Promise>; + + /** + * Determines the numeric variation of a feature flag for a context, along with information about + * how it was calculated. + * + * The `reason` property of the result will also be included in analytics events, if you are + * capturing detailed event data for this flag. + * + * If the flag variation does not have a numeric value, defaultValue is returned. The reason will + * indicate an error of the type `WRONG_KIND` in this case. + * + * For more information, see the [SDK reference + * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result + * (as an {@link LDEvaluationDetailTyped}). + */ + numberVariationDetail( + key: string, + context: LDContext, + defaultValue: number, + ): Promise>; + + /** + * Determines the string variation of a feature flag for a context, along with information about + * how it was calculated. + * + * The `reason` property of the result will also be included in analytics events, if you are + * capturing detailed event data for this flag. + * + * If the flag variation does not have a string value, defaultValue is returned. The reason will + * indicate an error of the type `WRONG_KIND` in this case. + * + * For more information, see the [SDK reference + * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @returns + * A Promise which will be resolved with the result + * (as an {@link LDEvaluationDetailTyped}). + */ + stringVariationDetail( + key: string, + context: LDContext, + defaultValue: string, + ): Promise>; + + /** + * Determines the variation of a feature flag for a context, along with information about how it + * was calculated. + * + * The `reason` property of the result will also be included in analytics events, if you are + * capturing detailed event data for this flag. + * + * This version may be favored in TypeScript versus `variation` because it returns + * an `unknown` type instead of `any`. `unknown` will require a cast before usage. + * + * For more information, see the [SDK reference + * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). + * + * @param key The unique key of the feature flag. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default value of the flag, to be used if the value is not available + * from LaunchDarkly. + * @param callback A Node-style callback to receive the result (as an {@link LDEvaluationDetail}). + * If omitted, you will receive a Promise instead. + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which will be resolved with + * the result (as an{@link LDEvaluationDetailTyped}). + */ + jsonVariationDetail( + key: string, + context: LDContext, + defaultValue: unknown, + ): Promise>; + /** * Builds an object that encapsulates the state of all feature flags for a given context. * This includes the flag values and also metadata that can be used on the front end. This diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts b/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts index 396c0012b..fe4f81efb 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationVariation.ts @@ -80,7 +80,7 @@ export interface LDMigrationTracker { export interface LDMigrationVariation { /** * The result of the flag evaluation. This will be either one of the flag's variations or - * the default value that was passed to `LDClient.variationMigration`. + * the default value that was passed to `LDClient.migrationVariation`. */ value: LDMigrationStage; diff --git a/packages/shared/sdk-server/src/evaluation/ErrorKinds.ts b/packages/shared/sdk-server/src/evaluation/ErrorKinds.ts index e2bb87af2..33ba8667a 100644 --- a/packages/shared/sdk-server/src/evaluation/ErrorKinds.ts +++ b/packages/shared/sdk-server/src/evaluation/ErrorKinds.ts @@ -8,6 +8,7 @@ enum ErrorKinds { UserNotSpecified = 'USER_NOT_SPECIFIED', FlagNotFound = 'FLAG_NOT_FOUND', ClientNotReady = 'CLIENT_NOT_READY', + WrongType = 'WRONG_TYPE', } /** From 2ada20d3ec03a83f76214e6c22932bbd80cd8a01 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 2 Oct 2023 12:06:53 -0700 Subject: [PATCH 48/57] fix: refactor mocks to its own project (#284) Large pr but mostly trivial changes to import paths because mocks have been moved to its own private project `@launchdarkly/private-js-mocks`: * Refactored mocks to its own project `@launchdarkly/private-js-mocks`. * Better eslint rule for `no-unused-vars` to avoid disabling this rule sporadically. --- .eslintrc.js | 10 ++++ .github/workflows/mocks.yml | 24 +++++++++ package.json | 1 + packages/sdk/akamai-base/example/ldClient.ts | 3 +- .../__tests__/LDClientNode.test.ts | 7 ++- .../__tests__/LDClientNode.tls.test.ts | 14 ++---- packages/sdk/server-node/package.json | 1 + .../src/platform/requests.ts | 3 +- .../src/utils/createCallbacks.ts | 7 ++- .../internal/events/EventProcessor.test.ts | 8 +-- packages/shared/common/package.json | 1 + .../diagnostics/DiagnosticsManager.test.ts | 3 +- .../src/internal/events/EventSender.test.ts | 4 +- packages/shared/common/src/internal/index.ts | 1 - .../src/internal/mocks/clientContext.ts | 14 ------ .../stream/StreamingProcessor.test.ts | 3 +- packages/shared/common/tsconfig.json | 3 +- packages/shared/mocks/CHANGELOG.md | 3 ++ packages/shared/mocks/LICENSE | 13 +++++ packages/shared/mocks/README.md | 28 +++++++++++ packages/shared/mocks/jest.config.js | 7 +++ packages/shared/mocks/package.json | 50 +++++++++++++++++++ packages/shared/mocks/src/clientContext.ts | 13 +++++ .../src}/contextDeduplicator.ts | 5 +- .../internal/mocks => mocks/src}/hasher.ts | 2 +- .../src/internal/mocks => mocks/src}/index.ts | 2 + .../internal/mocks => mocks/src}/logger.ts | 0 .../internal/mocks => mocks/src}/platform.ts | 24 ++------- .../mocks => mocks/src}/streamingProcessor.ts | 28 +++++++---- packages/shared/mocks/tsconfig.eslint.json | 5 ++ packages/shared/mocks/tsconfig.json | 26 ++++++++++ packages/shared/mocks/tsconfig.ref.json | 7 +++ .../__tests__/BigSegmentsManager.test.ts | 2 +- .../__tests__/LDClient.allFlags.test.ts | 4 +- .../__tests__/LDClient.evaluation.test.ts | 15 +++--- .../__tests__/LDClient.events.test.ts | 3 +- .../LDClientImpl.bigSegments.test.ts | 7 ++- .../__tests__/LDClientImpl.listeners.test.ts | 6 +-- .../sdk-server/__tests__/LDClientImpl.test.ts | 12 ++--- .../data_sources/FileDataSource.test.ts | 10 +--- .../data_sources/PollingProcessor.test.ts | 5 +- .../__tests__/data_sources/Requestor.test.ts | 7 +-- .../__tests__/evaluation/Bucketer.test.ts | 5 +- .../evaluation/Evaluator.bucketing.test.ts | 5 +- .../evaluation/Evaluator.clause.test.ts | 5 +- .../evaluation/Evaluator.rules.test.ts | 5 +- .../evaluation/Evaluator.segments.test.ts | 8 +-- .../__tests__/evaluation/Evaluator.test.ts | 5 +- .../__tests__/events/EventProcessor.test.ts | 12 ++--- .../integrations/test_data/TestData.test.ts | 5 +- packages/shared/sdk-server/package.json | 1 + .../shared/sdk-server/src/LDClientImpl.ts | 12 ++--- .../createStreamListeners.test.ts | 6 +-- .../createDiagnosticsInitConfig.test.ts | 6 +-- .../sdk-server/src/evaluation/Evaluator.ts | 3 -- .../src/integrations/test_data/TestData.ts | 4 +- .../store/node-server-sdk-dynamodb/README.md | 4 +- .../src/DynamoDBBigSegmentStore.ts | 1 - .../src/RedisBigSegmentStore.ts | 1 - tsconfig.json | 3 ++ 60 files changed, 296 insertions(+), 186 deletions(-) create mode 100644 .github/workflows/mocks.yml delete mode 100644 packages/shared/common/src/internal/mocks/clientContext.ts create mode 100644 packages/shared/mocks/CHANGELOG.md create mode 100644 packages/shared/mocks/LICENSE create mode 100644 packages/shared/mocks/README.md create mode 100644 packages/shared/mocks/jest.config.js create mode 100644 packages/shared/mocks/package.json create mode 100644 packages/shared/mocks/src/clientContext.ts rename packages/shared/{common/src/internal/mocks => mocks/src}/contextDeduplicator.ts (61%) rename packages/shared/{common/src/internal/mocks => mocks/src}/hasher.ts (91%) rename packages/shared/{common/src/internal/mocks => mocks/src}/index.ts (82%) rename packages/shared/{common/src/internal/mocks => mocks/src}/logger.ts (100%) rename packages/shared/{common/src/internal/mocks => mocks/src}/platform.ts (53%) rename packages/shared/{common/src/internal/mocks => mocks/src}/streamingProcessor.ts (53%) create mode 100644 packages/shared/mocks/tsconfig.eslint.json create mode 100644 packages/shared/mocks/tsconfig.json create mode 100644 packages/shared/mocks/tsconfig.ref.json diff --git a/.eslintrc.js b/.eslintrc.js index 66ebcd337..98be7e8ec 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,6 +11,10 @@ module.exports = { ignorePatterns: ['**/dist/**', '**/vercel/examples/**'], rules: { '@typescript-eslint/lines-between-class-members': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^__' }, + ], 'prettier/prettier': ['error'], 'class-methods-use-this': 'off', 'import/no-extraneous-dependencies': [ @@ -19,5 +23,11 @@ module.exports = { devDependencies: ['**/jest*.ts', '**/*.test.ts', '**/rollup.config.ts'], }, ], + 'import/default': 'error', + 'import/export': 'error', + 'import/no-self-import': 'error', + 'import/no-cycle': 'error', + 'import/no-useless-path-segments': 'error', + 'import/no-duplicates': 'error', }, }; diff --git a/.github/workflows/mocks.yml b/.github/workflows/mocks.yml new file mode 100644 index 000000000..0a2c39269 --- /dev/null +++ b/.github/workflows/mocks.yml @@ -0,0 +1,24 @@ +name: sdk/cloudflare + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-mocks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/private-js-mocks' + workspace_path: packages/shared/mocks diff --git a/package.json b/package.json index ac9088ac2..c569cb13d 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@launchdarkly/js-core", "workspaces": [ "packages/shared/common", + "packages/shared/mocks", "packages/shared/sdk-client", "packages/shared/sdk-server", "packages/shared/sdk-server-edge", diff --git a/packages/sdk/akamai-base/example/ldClient.ts b/packages/sdk/akamai-base/example/ldClient.ts index 8beb003d9..36f92d209 100644 --- a/packages/sdk/akamai-base/example/ldClient.ts +++ b/packages/sdk/akamai-base/example/ldClient.ts @@ -39,8 +39,7 @@ const flagData = ` class MyCustomStoreProvider implements EdgeProvider { // root key is formatted as LD-Env-{Launchdarkly environment client ID} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async get(rootKey: string): Promise { + async get(_rootKey: string): Promise { // you should provide an implementation to retrieve your flags from launchdarkly's https://sdk.launchdarkly.com/sdk/latest-all endpoint. // see https://docs.launchdarkly.com/sdk/features/flags-from-files for more information. return flagData; diff --git a/packages/sdk/server-node/__tests__/LDClientNode.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.test.ts index 0d4d44ae7..1bfa8f6cd 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.test.ts @@ -1,9 +1,8 @@ -import { internal, LDContext } from '@launchdarkly/js-server-sdk-common'; +import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; import { init } from '../src'; -const { mocks } = internal; - it('fires ready event in offline mode', (done) => { const client = init('sdk_key', { offline: true }); client.on('ready', () => { @@ -18,7 +17,7 @@ it('fires the failed event if initialization fails', async () => { const failedHandler = jest.fn().mockName('failedHandler'); const client = init('sdk_key', { sendEvents: false, - logger: mocks.logger, + logger, updateProcessor: (clientContext, dataSourceUpdates, initSuccessHandler, errorHandler) => ({ start: () => { setTimeout(() => errorHandler?.(new Error('Something unexpected happened')), 0); diff --git a/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts index eae972033..8c6baba96 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts @@ -6,22 +6,14 @@ import { TestHttpServer, } from 'launchdarkly-js-test-helpers'; -import { internal } from '@launchdarkly/js-server-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; -import { basicLogger, LDClient, LDLogger } from '../src'; +import { LDClient } from '../src'; import LDClientNode from '../src/LDClientNode'; -const { mocks } = internal; describe('When using a TLS connection', () => { let client: LDClient; let server: TestHttpServer; - let logger: LDLogger; - - beforeEach(() => { - logger = basicLogger({ - destination: () => {}, - }); - }); it('can connect via HTTPS to a server with a self-signed certificate, if CA is specified', async () => { server = await TestHttpServer.startSecure(); @@ -90,7 +82,7 @@ describe('When using a TLS connection', () => { stream: false, tlsParams: { ca: server.certificate }, diagnosticOptOut: true, - logger: mocks.logger, + logger, }); await client.waitForInitialization(); diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index ad1d283eb..50181027a 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -50,6 +50,7 @@ "launchdarkly-eventsource": "2.0.1" }, "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.1.0", diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts index cc7926dab..5a6b728b0 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts @@ -34,8 +34,7 @@ class NoopResponse implements Response { } export default class EdgeRequests implements Requests { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - fetch(url: string, options: Options = {}): Promise { + fetch(url: string, _options: Options = {}): Promise { return Promise.resolve(new NoopResponse()); } diff --git a/packages/shared/akamai-edgeworker-sdk/src/utils/createCallbacks.ts b/packages/shared/akamai-edgeworker-sdk/src/utils/createCallbacks.ts index 8002344b4..b6907ddac 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/utils/createCallbacks.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/utils/createCallbacks.ts @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ // eslint-disable-next-line import/prefer-default-export export const createCallbacks = () => ({ - onError: (err: Error) => {}, - onFailed: (err: Error) => {}, + onError: (_err: Error) => {}, + onFailed: (_err: Error) => {}, onReady: () => {}, - onUpdate: (key: string) => {}, + onUpdate: (_key: string) => {}, hasEventListeners: () => false, }); diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index a6641378b..37ee296e3 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -1,9 +1,9 @@ +import { clientContext, ContextDeduplicator } from '@launchdarkly/private-js-mocks'; + import { Context } from '../../../src'; -import { LDDeliveryStatus, LDEventType } from '../../../src/api/subsystem'; +import { LDContextDeduplicator, LDDeliveryStatus, LDEventType } from '../../../src/api/subsystem'; import { EventProcessor, InputIdentifyEvent } from '../../../src/internal'; import { EventProcessorOptions } from '../../../src/internal/events/EventProcessor'; -import { clientContext } from '../../../src/internal/mocks'; -import ContextDeduplicator from '../../../src/internal/mocks/contextDeduplicator'; import BasicLogger from '../../../src/logging/BasicLogger'; import format from '../../../src/logging/format'; @@ -84,7 +84,7 @@ function makeFeatureEvent( } describe('given an event processor', () => { - let contextDeduplicator: ContextDeduplicator; + let contextDeduplicator: LDContextDeduplicator; let eventProcessor: EventProcessor; const eventProcessorConfig: EventProcessorOptions = { diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 4f146fabe..ade47c22c 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -27,6 +27,7 @@ }, "license": "Apache-2.0", "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.1.0", diff --git a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts index c0130433e..2816dde10 100644 --- a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts +++ b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts @@ -1,4 +1,5 @@ -import { basicPlatform } from '../mocks'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + import DiagnosticsManager from './DiagnosticsManager'; describe('given a diagnostics manager', () => { diff --git a/packages/shared/common/src/internal/events/EventSender.test.ts b/packages/shared/common/src/internal/events/EventSender.test.ts index d8b196d01..d90d4daba 100644 --- a/packages/shared/common/src/internal/events/EventSender.test.ts +++ b/packages/shared/common/src/internal/events/EventSender.test.ts @@ -1,8 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + import { Info, PlatformData, SdkData } from '../../api'; import { LDDeliveryStatus, LDEventSenderResult, LDEventType } from '../../api/subsystem'; import { ApplicationTags, ClientContext } from '../../options'; -import { basicPlatform } from '../mocks'; import EventSender from './EventSender'; jest.mock('../../utils', () => { diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index 0eec6634d..241ab9c87 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -1,4 +1,3 @@ export * from './diagnostics'; export * from './events'; export * from './stream'; -export * as mocks from './mocks'; diff --git a/packages/shared/common/src/internal/mocks/clientContext.ts b/packages/shared/common/src/internal/mocks/clientContext.ts deleted file mode 100644 index 070f7df2c..000000000 --- a/packages/shared/common/src/internal/mocks/clientContext.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createSafeLogger } from '../../logging'; -import { ClientContext } from '../../options'; -import basicPlatform from './platform'; - -const clientContext = new ClientContext( - 'testSdkKey', - { - serviceEndpoints: { streaming: 'https://mockstream.ld.com', polling: '', events: '' }, - logger: createSafeLogger(), - }, - basicPlatform, -); - -export default clientContext; diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts index d8ad4a5d4..e6c845351 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts @@ -1,9 +1,10 @@ +import { basicPlatform, clientContext, logger } from '@launchdarkly/private-js-mocks'; + import { EventName, ProcessStreamResponse } from '../../api'; import { LDStreamProcessor } from '../../api/subsystem'; import { LDStreamingError } from '../../errors'; import { defaultHeaders } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; -import { basicPlatform, clientContext, logger } from '../mocks'; import StreamingProcessor from './StreamingProcessor'; const dateNowString = '2023-08-10'; diff --git a/packages/shared/common/tsconfig.json b/packages/shared/common/tsconfig.json index 1a394bfbf..e2ed2b0f3 100644 --- a/packages/shared/common/tsconfig.json +++ b/packages/shared/common/tsconfig.json @@ -12,7 +12,8 @@ "sourceMap": true, "declaration": true, "declarationMap": true, // enables importers to jump to source - "stripInternal": true + "stripInternal": true, + "composite": true }, "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] } diff --git a/packages/shared/mocks/CHANGELOG.md b/packages/shared/mocks/CHANGELOG.md new file mode 100644 index 000000000..cc1a5afb9 --- /dev/null +++ b/packages/shared/mocks/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `@launchdarkly/private-js-mocks` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). diff --git a/packages/shared/mocks/LICENSE b/packages/shared/mocks/LICENSE new file mode 100644 index 000000000..ab8bd335b --- /dev/null +++ b/packages/shared/mocks/LICENSE @@ -0,0 +1,13 @@ +Copyright 2023 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/shared/mocks/README.md b/packages/shared/mocks/README.md new file mode 100644 index 000000000..730bb4fee --- /dev/null +++ b/packages/shared/mocks/README.md @@ -0,0 +1,28 @@ +# LaunchDarkly SDK JavaScript Mocks + +[![Actions Status][mocks-ci-badge]][mocks-ci] + +**Internal use only.** + +This project contains JavaScript mocks that are consumed in unit tests in client-side and server-side JavaScript SDKs. + +## Contributing + +See [Contributing](../shared/CONTRIBUTING.md). + +## About LaunchDarkly + +- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + - Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +- Explore LaunchDarkly + - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information + - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides + - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation + - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates + +[mocks-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/mocks.yml/badge.svg +[mocks-ci]: https://github.com/launchdarkly/js-core/actions/workflows/mocks.yml diff --git a/packages/shared/mocks/jest.config.js b/packages/shared/mocks/jest.config.js new file mode 100644 index 000000000..6753062cc --- /dev/null +++ b/packages/shared/mocks/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/*.test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/shared/mocks/package.json b/packages/shared/mocks/package.json new file mode 100644 index 000000000..47ce02e41 --- /dev/null +++ b/packages/shared/mocks/package.json @@ -0,0 +1,50 @@ +{ + "name": "@launchdarkly/private-js-mocks", + "private": true, + "version": "0.0.1", + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/common", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "description": "LaunchDarkly SDK for JavaScript - mocks", + "files": [ + "dist" + ], + "keywords": [ + "mocks", + "unit", + "tests", + "launchdarkly", + "js", + "client" + ], + "scripts": { + "test": "", + "build": "npx tsc", + "clean": "npx tsc --build --clean", + "lint": "npx eslint --ext .ts", + "lint:fix": "yarn run lint -- --fix" + }, + "license": "Apache-2.0", + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^4.2.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.7.3", + "@typescript-eslint/parser": "^6.7.3", + "eslint": "^8.50.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.7.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^3.0.3", + "ts-jest": "^29.0.5", + "typescript": "^5.2.2" + } +} diff --git a/packages/shared/mocks/src/clientContext.ts b/packages/shared/mocks/src/clientContext.ts new file mode 100644 index 000000000..b59f6cc43 --- /dev/null +++ b/packages/shared/mocks/src/clientContext.ts @@ -0,0 +1,13 @@ +import type { ClientContext } from '@common'; + +import platform from './platform'; + +const clientContext: ClientContext = { + basicConfiguration: { + sdkKey: 'testSdkKey', + serviceEndpoints: { events: '', polling: '', streaming: 'https://mockstream.ld.com' }, + }, + platform, +}; + +export default clientContext; diff --git a/packages/shared/common/src/internal/mocks/contextDeduplicator.ts b/packages/shared/mocks/src/contextDeduplicator.ts similarity index 61% rename from packages/shared/common/src/internal/mocks/contextDeduplicator.ts rename to packages/shared/mocks/src/contextDeduplicator.ts index 5d2b9ebd5..b04d0d277 100644 --- a/packages/shared/common/src/internal/mocks/contextDeduplicator.ts +++ b/packages/shared/mocks/src/contextDeduplicator.ts @@ -1,7 +1,6 @@ -import { LDContextDeduplicator } from '../../api/subsystem'; -import { Context } from '../../index'; +import type { Context, subsystem } from '@common'; -export default class ContextDeduplicator implements LDContextDeduplicator { +export default class ContextDeduplicator implements subsystem.LDContextDeduplicator { flushInterval?: number | undefined = 0.1; seen: string[] = []; diff --git a/packages/shared/common/src/internal/mocks/hasher.ts b/packages/shared/mocks/src/hasher.ts similarity index 91% rename from packages/shared/common/src/internal/mocks/hasher.ts rename to packages/shared/mocks/src/hasher.ts index 1bfd0335c..0d3b363de 100644 --- a/packages/shared/common/src/internal/mocks/hasher.ts +++ b/packages/shared/mocks/src/hasher.ts @@ -1,4 +1,4 @@ -import { Crypto, Hasher, Hmac } from '../../api'; +import type { Crypto, Hasher, Hmac } from '@common'; export const hasher: Hasher = { update: jest.fn(), diff --git a/packages/shared/common/src/internal/mocks/index.ts b/packages/shared/mocks/src/index.ts similarity index 82% rename from packages/shared/common/src/internal/mocks/index.ts rename to packages/shared/mocks/src/index.ts index 5b0dd0097..26896b6d4 100644 --- a/packages/shared/common/src/internal/mocks/index.ts +++ b/packages/shared/mocks/src/index.ts @@ -1,4 +1,5 @@ import clientContext from './clientContext'; +import ContextDeduplicator from './contextDeduplicator'; import { crypto, hasher } from './hasher'; import logger from './logger'; import basicPlatform from './platform'; @@ -10,6 +11,7 @@ export { crypto, logger, hasher, + ContextDeduplicator, MockStreamingProcessor, setupMockStreamingProcessor, }; diff --git a/packages/shared/common/src/internal/mocks/logger.ts b/packages/shared/mocks/src/logger.ts similarity index 100% rename from packages/shared/common/src/internal/mocks/logger.ts rename to packages/shared/mocks/src/logger.ts diff --git a/packages/shared/common/src/internal/mocks/platform.ts b/packages/shared/mocks/src/platform.ts similarity index 53% rename from packages/shared/common/src/internal/mocks/platform.ts rename to packages/shared/mocks/src/platform.ts index f64693304..d817ceaea 100644 --- a/packages/shared/common/src/internal/mocks/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -1,14 +1,5 @@ -import { - EventSource, - EventSourceInitDict, - Info, - Options, - Platform, - PlatformData, - Requests, - Response, - SdkData, -} from '../../api'; +import type { Info, Platform, PlatformData, Requests, SdkData } from '@common'; + import { crypto } from './hasher'; const info: Info = { @@ -37,15 +28,8 @@ const info: Info = { }; const requests: Requests = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - fetch(url: string, options?: Options): Promise { - throw new Error('Function not implemented.'); - }, - - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - throw new Error('Function not implemented.'); - }, + fetch: jest.fn(), + createEventSource: jest.fn(), }; const basicPlatform: Platform = { diff --git a/packages/shared/common/src/internal/mocks/streamingProcessor.ts b/packages/shared/mocks/src/streamingProcessor.ts similarity index 53% rename from packages/shared/common/src/internal/mocks/streamingProcessor.ts rename to packages/shared/mocks/src/streamingProcessor.ts index 36cfd5e26..5b890fe3c 100644 --- a/packages/shared/common/src/internal/mocks/streamingProcessor.ts +++ b/packages/shared/mocks/src/streamingProcessor.ts @@ -1,8 +1,10 @@ -import { EventName, ProcessStreamResponse } from '../../api'; -import { LDStreamingError } from '../../errors'; -import { ClientContext } from '../../options'; -import { DiagnosticsManager } from '../diagnostics'; -import { type StreamingErrorHandler } from '../stream'; +import type { + ClientContext, + EventName, + internal, + LDStreamingError, + ProcessStreamResponse, +} from '@common'; export const MockStreamingProcessor = jest.fn(); @@ -12,14 +14,20 @@ export const setupMockStreamingProcessor = (shouldError: boolean = false) => { sdkKey: string, clientContext: ClientContext, listeners: Map, - diagnosticsManager: DiagnosticsManager, - errorHandler: StreamingErrorHandler, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - streamInitialReconnectDelay, + diagnosticsManager: internal.DiagnosticsManager, + errorHandler: internal.StreamingErrorHandler, + _streamInitialReconnectDelay: number, ) => ({ start: jest.fn(async () => { if (shouldError) { - process.nextTick(() => errorHandler(new LDStreamingError('test-error', 401))); + process.nextTick(() => { + const unauthorized: LDStreamingError = { + code: 401, + name: 'LaunchDarklyStreamingError', + message: 'test-error', + }; + errorHandler(unauthorized); + }); } else { // execute put which will resolve the init promise process.nextTick( diff --git a/packages/shared/mocks/tsconfig.eslint.json b/packages/shared/mocks/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/shared/mocks/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/shared/mocks/tsconfig.json b/packages/shared/mocks/tsconfig.json new file mode 100644 index 000000000..93b5f38e5 --- /dev/null +++ b/packages/shared/mocks/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "ES2017", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "stripInternal": true, + "paths": { + "@common": ["../common"] + } + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"], + "references": [ + { + "path": "../common" + } + ] +} diff --git a/packages/shared/mocks/tsconfig.ref.json b/packages/shared/mocks/tsconfig.ref.json new file mode 100644 index 000000000..0c86b2c55 --- /dev/null +++ b/packages/shared/mocks/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts b/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts index 12fd303b7..1bda8c9dd 100644 --- a/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts +++ b/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts @@ -1,4 +1,4 @@ -import { Crypto, Hasher, Hmac } from '@launchdarkly/js-sdk-common'; +import type { Crypto, Hasher, Hmac } from '@launchdarkly/js-sdk-common'; import { BigSegmentStore, diff --git a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts index 8c216f3cd..681d7c9a3 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts @@ -1,12 +1,10 @@ -import { internal } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { LDClientImpl } from '../src'; import TestData from '../src/integrations/test_data/TestData'; import TestLogger, { LogLevel } from './Logger'; import makeCallbacks from './makeCallbacks'; -const { mocks } = internal; - const defaultUser = { key: 'user' }; describe('given an LDClient with test data', () => { diff --git a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts index 08e03fdc4..32e3a93a3 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts @@ -1,4 +1,9 @@ -import { internal, subsystem } from '@launchdarkly/js-sdk-common'; +import { subsystem } from '@launchdarkly/js-sdk-common'; +import { + basicPlatform, + MockStreamingProcessor, + setupMockStreamingProcessor, +} from '@launchdarkly/private-js-mocks'; import { LDClientImpl, LDFeatureStore } from '../src'; import TestData from '../src/integrations/test_data/TestData'; @@ -15,14 +20,11 @@ jest.mock('@launchdarkly/js-sdk-common', () => { ...{ internal: { ...actual.internal, - StreamingProcessor: actual.internal.mocks.MockStreamingProcessor, + StreamingProcessor: MockStreamingProcessor, }, }, }; }); -const { - mocks: { basicPlatform, setupMockStreamingProcessor }, -} = internal; const defaultUser = { key: 'user' }; @@ -224,8 +226,7 @@ describe('given an offline client', () => { }); class InertUpdateProcessor implements subsystem.LDStreamProcessor { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - start(fn?: ((err?: any) => void) | undefined) { + start(_fn?: ((err?: any) => void) | undefined) { // Never initialize. } diff --git a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts index 1a9eb6ab3..98cc1ea13 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts @@ -1,11 +1,10 @@ import { Context, internal } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { LDClientImpl } from '../src'; import TestData from '../src/integrations/test_data/TestData'; import makeCallbacks from './makeCallbacks'; -const { mocks } = internal; - const defaultUser = { key: 'user' }; const anonymousUser = { key: 'anon-user', anonymous: true }; diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts index 0fcaada06..71af891e1 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts @@ -1,15 +1,14 @@ -import { Crypto, Hasher, Hmac, internal } from '@launchdarkly/js-sdk-common'; +import { Crypto, Hasher, Hmac } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; +import { LDBigSegmentsOptions } from '../src'; import { BigSegmentStore } from '../src/api/interfaces'; -import { LDBigSegmentsOptions } from '../src/api/options/LDBigSegmentsOptions'; import makeBigSegmentRef from '../src/evaluation/makeBigSegmentRef'; import TestData from '../src/integrations/test_data/TestData'; import LDClientImpl from '../src/LDClientImpl'; import { makeSegmentMatchClause } from './evaluation/flags'; import makeCallbacks from './makeCallbacks'; -const { mocks } = internal; - const user = { key: 'userkey' }; const bigSegment = { key: 'segmentkey', diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts index 4766bdf4e..f559b8a92 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts @@ -1,6 +1,6 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; -import { internal } from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; import { AttributeReference, LDClientImpl } from '../src'; import { Op } from '../src/evaluation/data/Clause'; @@ -9,10 +9,6 @@ import { makeFlagWithSegmentMatch } from './evaluation/flags'; import TestLogger from './Logger'; import makeCallbacks from './makeCallbacks'; -const { - mocks: { basicPlatform }, -} = internal; - describe('given an LDClient with test data', () => { let client: LDClientImpl; let td: TestData; diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts index f10be75cd..ad58d5e2f 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts @@ -1,5 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { internal } from '@launchdarkly/js-sdk-common'; +import { + basicPlatform, + MockStreamingProcessor, + setupMockStreamingProcessor, +} from '@launchdarkly/private-js-mocks'; import { LDClientImpl, LDOptions } from '../src'; @@ -10,14 +13,11 @@ jest.mock('@launchdarkly/js-sdk-common', () => { ...{ internal: { ...actual.internal, - StreamingProcessor: actual.internal.mocks.MockStreamingProcessor, + StreamingProcessor: MockStreamingProcessor, }, }, }; }); -const { - mocks: { basicPlatform, setupMockStreamingProcessor }, -} = internal; describe('LDClientImpl', () => { let client: LDClientImpl; diff --git a/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts b/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts index 4cfceffe2..8e8da6f03 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts @@ -1,10 +1,5 @@ -import { - ClientContext, - Context, - Filesystem, - internal, - WatchHandle, -} from '@launchdarkly/js-sdk-common'; +import { ClientContext, Context, Filesystem, WatchHandle } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { Flag } from '../../src/evaluation/data/Flag'; import { Segment } from '../../src/evaluation/data/Segment'; @@ -16,7 +11,6 @@ import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; import TestLogger from '../Logger'; -const { mocks } = internal; const flag1Key = 'flag1'; const flag2Key = 'flag2'; const flag2Value = 'value2'; diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index f4d1320cf..cb8548dfe 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -1,4 +1,5 @@ -import { ClientContext, internal } from '@launchdarkly/js-sdk-common'; +import { ClientContext } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { LDFeatureStore } from '../../src'; import PollingProcessor from '../../src/data_sources/PollingProcessor'; @@ -9,8 +10,6 @@ import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; import TestLogger, { LogLevel } from '../Logger'; -const { mocks } = internal; - describe('given an event processor', () => { const requestor = { requestAllData: jest.fn(), diff --git a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts index fd47069a0..4e18f372e 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts @@ -1,20 +1,17 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { EventSource, EventSourceInitDict, Headers, - internal, Options, Requests, Response, } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import promisify from '../../src/async/promisify'; import Requestor from '../../src/data_sources/Requestor'; import Configuration from '../../src/options/Configuration'; -const { mocks } = internal; - describe('given a requestor', () => { let requestor: Requestor; @@ -37,7 +34,6 @@ describe('given a requestor', () => { resetRequestState(); const requests: Requests = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ async fetch(url: string, options?: Options): Promise { return new Promise((a, r) => { if (throwThis) { @@ -76,7 +72,6 @@ describe('given a requestor', () => { }); }, - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, diff --git a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts index 48c8b2055..c2bc82276 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts @@ -2,12 +2,11 @@ // We cannot fully validate bucketing in the common tests. Platform implementations // should contain a consistency test. // Testing here can only validate we are providing correct inputs to the hashing algorithm. -import { AttributeReference, Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import Bucketer from '../../src/evaluation/Bucketer'; -const { mocks } = internal; - describe.each< [ context: LDContext, diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts index a7619b1a3..51acff48d 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts @@ -1,12 +1,11 @@ -import { Context, internal } from '@launchdarkly/js-sdk-common'; +import { Context } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { Flag } from '../../src/evaluation/data/Flag'; import { Rollout } from '../../src/evaluation/data/Rollout'; import Evaluator from '../../src/evaluation/Evaluator'; import noQueries from './mocks/noQueries'; -const { mocks } = internal; - const evaluator = new Evaluator(mocks.basicPlatform, noQueries); describe('given a flag with a rollout', () => { diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts index 82a5ef13e..9a2e0c9aa 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts @@ -1,4 +1,5 @@ -import { AttributeReference, Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { Clause } from '../../src/evaluation/data/Clause'; import { Flag } from '../../src/evaluation/data/Flag'; @@ -11,8 +12,6 @@ import { } from './flags'; import noQueries from './mocks/noQueries'; -const { mocks } = internal; - const evaluator = new Evaluator(mocks.basicPlatform, noQueries); // Either a legacy user, or context with equivalent user. diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts index a96c09a5a..60de3b117 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts @@ -1,6 +1,7 @@ // Tests of flag evaluation at the rule level. Clause-level behavior is covered // in detail in Evaluator.clause.tests and (TODO: File for segments). -import { AttributeReference, Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { Clause } from '../../src/evaluation/data/Clause'; import { Flag } from '../../src/evaluation/data/Flag'; @@ -13,8 +14,6 @@ import { } from './flags'; import noQueries from './mocks/noQueries'; -const { mocks } = internal; - const basicUser: LDContext = { key: 'userkey' }; const basicSingleKindUser: LDContext = { kind: 'user', key: 'userkey' }; const basicMultiKindUser: LDContext = { kind: 'multi', user: { key: 'userkey' } }; diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts index cd9730f93..3e71c8690 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts @@ -1,15 +1,13 @@ /* eslint-disable class-methods-use-this */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ import { AttributeReference, Context, Crypto, Hasher, Hmac, - internal, LDContext, } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { BigSegmentStoreMembership } from '../../src/api/interfaces'; import { Flag } from '../../src/evaluation/data/Flag'; @@ -22,8 +20,6 @@ import { makeFlagWithSegmentMatch, } from './flags'; -const { mocks } = internal; - const basicUser: LDContext = { key: 'userkey' }; const basicSingleKindUser: LDContext = { kind: 'user', key: 'userkey' }; const basicMultiKindUser: LDContext = { kind: 'multi', user: { key: 'userkey' } }; @@ -47,7 +43,7 @@ class TestQueries implements Queries { } getBigSegmentsMembership( - userKey: string, + _userKey: string, ): Promise<[BigSegmentStoreMembership | null, string] | undefined> { throw new Error('Method not implemented.'); } diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index 7a5628b1c..a51cd35e2 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -1,4 +1,5 @@ -import { Context, internal, LDContext } from '@launchdarkly/js-sdk-common'; +import { Context, LDContext } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { Flag } from '../../src/evaluation/data/Flag'; import EvalResult from '../../src/evaluation/EvalResult'; @@ -6,8 +7,6 @@ import Evaluator from '../../src/evaluation/Evaluator'; import Reasons from '../../src/evaluation/Reasons'; import noQueries from './mocks/noQueries'; -const { mocks } = internal; - const offBaseFlag = { key: 'feature0', version: 1, diff --git a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts index f6f4d7466..0028c8b04 100644 --- a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { ClientContext, Context, @@ -15,13 +14,12 @@ import { Response, SdkData, } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import ContextDeduplicator from '../../src/events/ContextDeduplicator'; import Configuration from '../../src/options/Configuration'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; -const { mocks } = internal; - const SDK_KEY = 'sdk-key'; interface RequestState { @@ -67,9 +65,8 @@ function makePlatform(requestState: RequestState) { }); const requests: Requests = { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ fetch(url: string, options?: Options): Promise { - return new Promise((a, r) => { + return new Promise((a) => { const headers: Headers = { get(name: string): string | null { return requestState.testHeaders[name] || null; @@ -83,7 +80,7 @@ function makePlatform(requestState: RequestState) { entries(): Iterable<[string, string]> { throw new Error('Function not implemented.'); }, - has(name: string): boolean { + has(_name: string): boolean { throw new Error('Function not implemented.'); }, }; @@ -105,8 +102,7 @@ function makePlatform(requestState: RequestState) { }); }, - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { + createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, }; diff --git a/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts b/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts index f95be91a5..e5d7119b0 100644 --- a/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts +++ b/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts @@ -1,4 +1,5 @@ -import { AttributeReference, ClientContext, internal } from '@launchdarkly/js-sdk-common'; +import { AttributeReference, ClientContext } from '@launchdarkly/js-sdk-common'; +import * as mocks from '@launchdarkly/private-js-mocks'; import { Flag } from '../../../src/evaluation/data/Flag'; import { FlagRule } from '../../../src/evaluation/data/FlagRule'; @@ -8,8 +9,6 @@ import AsyncStoreFacade from '../../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../../src/store/VersionedDataKinds'; -const { mocks } = internal; - const basicBooleanFlag: Flag = { fallthrough: { variation: 0, diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index 9a3c4fbb9..e18b488de 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -31,6 +31,7 @@ "semver": "7.5.4" }, "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@types/semver": "^7.3.13", diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 3af734be6..e464bf441 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - /* eslint-disable class-methods-use-this */ import { ClientContext, @@ -9,8 +7,6 @@ import { LDContext, LDEvaluationDetail, LDLogger, - LDPollingError, - LDStreamingError, Platform, subsystem, } from '@launchdarkly/js-sdk-common'; @@ -25,7 +21,7 @@ import DataSourceUpdates from './data_sources/DataSourceUpdates'; import PollingProcessor from './data_sources/PollingProcessor'; import Requestor from './data_sources/Requestor'; import createDiagnosticsInitConfig from './diagnostics/createDiagnosticsInitConfig'; -import { allAsync, allSeriesAsync } from './evaluation/collection'; +import { allAsync } from './evaluation/collection'; import { Flag } from './evaluation/data/Flag'; import { Segment } from './evaluation/data/Segment'; import ErrorKinds from './evaluation/ErrorKinds'; @@ -485,11 +481,9 @@ export default class LDClientImpl implements LDClient { this.variationInternal(flagKey, context, defaultValue, eventFactory, cb); } - private dataSourceErrorHandler(e: LDStreamingError | LDPollingError) { + private dataSourceErrorHandler(e: any) { const error = - e instanceof LDStreamingError && e.code === 401 - ? new Error('Authentication failed. Double check your SDK key.') - : e; + e.code === 401 ? new Error('Authentication failed. Double check your SDK key.') : e; this.onError(error); this.onFailed(error); diff --git a/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts b/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts index 7f67f6fe4..002f43336 100644 --- a/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts +++ b/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts @@ -1,4 +1,4 @@ -import { internal } from '@launchdarkly/js-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; import { LDDataSourceUpdates } from '../api/subsystems'; import { deserializeAll, deserializeDelete, deserializePatch } from '../store/serialization'; @@ -7,10 +7,6 @@ import { createStreamListeners } from './createStreamListeners'; jest.mock('../store/serialization'); -const { - mocks: { logger }, -} = internal; - const allData = { data: { flags: { diff --git a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts index a62abb837..a6a566bf6 100644 --- a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts +++ b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts @@ -1,13 +1,9 @@ -import { internal } from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; import { LDOptions } from '../api'; import Configuration from '../options/Configuration'; import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; -const { - mocks: { basicPlatform }, -} = internal; - const mockFeatureStore = { getDescription: jest.fn(() => 'Mock Feature Store'), }; diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 2abe12a9f..5ff2f4f8d 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -175,7 +175,6 @@ export default class Evaluator { private evaluateInternal( flag: Flag, context: Context, - // eslint-disable-next-line @typescript-eslint/no-unused-vars state: EvalState, visitedFlags: string[], cb: (res: EvalResult) => void, @@ -601,9 +600,7 @@ export default class Evaluator { segmentMatchContext( segment: Segment, context: Context, - // eslint-disable-next-line @typescript-eslint/no-unused-vars state: EvalState, - // eslint-disable-next-line @typescript-eslint/no-unused-vars segmentsVisited: string[], cb: (res: MatchOrError) => void, ): void { diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestData.ts b/packages/shared/sdk-server/src/integrations/test_data/TestData.ts index 6a33f0444..cd4b41417 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestData.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestData.ts @@ -64,12 +64,10 @@ export default class TestData { // Provides an arrow function to prevent needed to bind the method to // maintain `this`. return ( - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ clientContext: LDClientContext, featureStore: LDFeatureStore, initSuccessHandler: VoidFunction, - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - errorHandler?: (e: Error) => void, + _errorHandler?: (e: Error) => void, ) => { const listeners = createStreamListeners( featureStore, diff --git a/packages/store/node-server-sdk-dynamodb/README.md b/packages/store/node-server-sdk-dynamodb/README.md index 59f9c6917..07aef68e0 100644 --- a/packages/store/node-server-sdk-dynamodb/README.md +++ b/packages/store/node-server-sdk-dynamodb/README.md @@ -52,7 +52,9 @@ const client = LaunchDarkly.init('YOUR SDK KEY', config); By default, the DynamoDB client will try to get your AWS credentials and region name from environment variables and/or local configuration files, as described in the AWS SDK documentation. You can also specify any valid [DynamoDB client options](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) like this: ```typescript -const dynamoDBOptions = { credentials: { accessKeyId: 'YOUR KEY', secretAccessKey: 'YOUR SECRET' }}; +const dynamoDBOptions = { + credentials: { accessKeyId: 'YOUR KEY', secretAccessKey: 'YOUR SECRET' }, +}; const store = DynamoDBFeatureStore('YOUR TABLE NAME', { clientOptions: dynamoDBOptions }); ``` diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts index bac777d65..e7ed6614b 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts @@ -39,7 +39,6 @@ export default class DynamoDBBigSegmentStore implements interfaces.BigSegmentSto // Logger is not currently used, but is included to reduce the chance of a // compatibility break to add a log. - // eslint-disable-next-line @typescript-eslint/no-unused-vars constructor( private readonly tableName: string, options?: LDDynamoDBOptions, diff --git a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts index 7e51fd701..8934bd2ba 100644 --- a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts @@ -23,7 +23,6 @@ export default class RedisBigSegmentStore implements interfaces.BigSegmentStore // Logger is not currently used, but is included to reduce the chance of a // compatibility break to add a log. - // eslint-disable-next-line @typescript-eslint/no-unused-vars constructor( options?: LDRedisOptions, private readonly logger?: LDLogger, diff --git a/tsconfig.json b/tsconfig.json index 93c61e83e..0c40df097 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,9 @@ { "path": "./packages/shared/common/tsconfig.ref.json" }, + { + "path": "./packages/shared/mocks/tsconfig.ref.json" + }, { "path": "./packages/shared/sdk-server/tsconfig.ref.json" }, From 837c4e1f4fa5d63a6de9b28429a2f548669c23ef Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:08:36 -0700 Subject: [PATCH 49/57] fix: Include flag version for WRONG_TYPE migrations. (#290) --- packages/shared/sdk-server/src/LDClientImpl.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 98fc32fed..bb75b180b 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -417,6 +417,7 @@ export default class LDClientImpl implements LDClient { const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; const checkRatio = flag?.migration?.checkRatio; const samplingRatio = flag?.samplingRatio; + if (!IsMigrationStage(detail.value)) { const error = new Error( `Unrecognized MigrationState for "${key}"; returning default value.`, @@ -424,7 +425,7 @@ export default class LDClientImpl implements LDClient { this.onError(error); const reason = { kind: 'ERROR', - errorKind: 'WRONG_TYPE', + errorKind: ErrorKinds.WrongType, }; resolve({ value: defaultValue, @@ -436,8 +437,9 @@ export default class LDClientImpl implements LDClient { reason, checkRatio, undefined, - undefined, + flag?.version, samplingRatio, + this.logger, ), }); return; @@ -455,6 +457,7 @@ export default class LDClientImpl implements LDClient { detail.variationIndex === null ? undefined : detail.variationIndex, flag?.version, samplingRatio, + this.logger, ), }); }, From c3f2d88af39786ad9e8fb644e8a16e3e6e690bda Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:09:18 -0700 Subject: [PATCH 50/57] feat: Remove index and custom event sampling. (#289) --- .../src/featureStore/index.ts | 12 --- .../internal/events/EventProcessor.test.ts | 101 ------------------ .../src/internal/events/EventProcessor.ts | 4 +- .../src/internal/events/InputCustomEvent.ts | 3 +- .../src/internal/events/InputEvalEvent.ts | 1 - .../src/internal/events/LDEventOverrides.ts | 29 ----- .../common/src/internal/events/index.ts | 2 - .../src/api/EdgeFeatureStore.ts | 12 --- .../data_sources/PollingProcessor.test.ts | 6 -- .../data_sources/StreamingProcessor.test.ts | 72 +++++-------- .../__tests__/events/EventProcessor.test.ts | 2 - .../__tests__/store/serialization.test.ts | 86 ++------------- .../shared/sdk-server/src/LDClientImpl.ts | 72 ++----------- .../src/data_sources/FileDataSource.ts | 6 -- .../src/data_sources/PollingProcessor.ts | 3 - .../src/data_sources/StreamingProcessor.ts | 3 - .../sdk-server/src/events/EventFactory.ts | 12 +-- .../shared/sdk-server/src/store/Metric.ts | 5 - .../shared/sdk-server/src/store/Override.ts | 5 - .../src/store/VersionedDataKinds.ts | 10 -- .../sdk-server/src/store/serialization.ts | 16 +-- .../__tests__/RedisCore.test.ts | 52 --------- 22 files changed, 52 insertions(+), 462 deletions(-) delete mode 100644 packages/shared/common/src/internal/events/LDEventOverrides.ts delete mode 100644 packages/shared/sdk-server/src/store/Metric.ts delete mode 100644 packages/shared/sdk-server/src/store/Override.ts diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index 68b02ddef..812ae1dfa 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -59,12 +59,6 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments[dataKey]); break; - case 'configurationOverrides': - callback(item.configurationOverrides?.[dataKey] ?? null); - break; - case 'metrics': - callback(item.metrics?.[dataKey] ?? null); - break; default: callback(null); } @@ -96,12 +90,6 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments); break; - case 'configurationOverrides': - callback(item.configurationOverrides || {}); - break; - case 'metrics': - callback(item.metrics || {}); - break; default: throw new Error(`Unsupported DataKind: ${namespace}`); } diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index 5bc17c794..860b31811 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -295,7 +295,6 @@ describe('given an event processor', () => { trackEvents: true, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -326,7 +325,6 @@ describe('given an event processor', () => { trackEvents: true, default: 'default', samplingRatio: 2, - indexSamplingRatio: 1, // Disable the index events. withReasons: true, }); @@ -360,7 +358,6 @@ describe('given an event processor', () => { trackEvents: true, default: 'default', samplingRatio: 2, - indexSamplingRatio: 1, // Disable the index events. withReasons: true, }); @@ -379,36 +376,6 @@ describe('given an event processor', () => { ]); }); - it('excludes index events that are not sampled', async () => { - // @ts-ignore - shouldSample.mockImplementation((ratio) => ratio === 2); - Date.now = jest.fn(() => 1000); - eventProcessor.sendEvent({ - kind: 'feature', - creationDate: 1000, - context: Context.fromLDContext(user), - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: true, - default: 'default', - samplingRatio: 2, - indexSamplingRatio: 1, // Disable the index events. - withReasons: true, - }); - - await eventProcessor.flush(); - const request = await eventSender.queue.take(); - expect(shouldSample).toHaveBeenCalledWith(2); - expect(shouldSample).toHaveBeenCalledWith(1); - - expect(request.data).toEqual([ - { ...makeFeatureEvent(1000, 11), samplingRatio: 2 }, - makeSummary(1000, 1000, 1, 11), - ]); - }); - it('handles the version being 0', async () => { Date.now = jest.fn(() => 1000); eventProcessor.sendEvent({ @@ -422,7 +389,6 @@ describe('given an event processor', () => { trackEvents: true, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -455,7 +421,6 @@ describe('given an event processor', () => { debugEventsUntilDate: 2000, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -487,7 +452,6 @@ describe('given an event processor', () => { debugEventsUntilDate: 2000, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -526,7 +490,6 @@ describe('given an event processor', () => { debugEventsUntilDate: 1500, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -558,7 +521,6 @@ describe('given an event processor', () => { trackEvents: true, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); eventProcessor.sendEvent({ @@ -572,7 +534,6 @@ describe('given an event processor', () => { trackEvents: true, default: 'potato', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -637,7 +598,6 @@ describe('given an event processor', () => { trackEvents: false, default: 'default', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); eventProcessor.sendEvent({ @@ -651,7 +611,6 @@ describe('given an event processor', () => { trackEvents: false, default: 'potato', samplingRatio: 1, - indexSamplingRatio: 1, withReasons: true, }); @@ -709,42 +668,6 @@ describe('given an event processor', () => { key: 'eventkey', data: { thing: 'stuff' }, samplingRatio: 1, - indexSamplingRatio: 1, - }); - - await eventProcessor.flush(); - const request = await eventSender.queue.take(); - - expect(request.data).toEqual([ - { - kind: 'index', - creationDate: 1000, - context: { ...user, kind: 'user' }, - }, - { - kind: 'custom', - key: 'eventkey', - data: { thing: 'stuff' }, - creationDate: 1000, - contextKeys: { - user: 'userKey', - }, - }, - ]); - }); - - it('does not queue a custom event that is not sampled', async () => { - // @ts-ignore - shouldSample.mockImplementation((ratio) => ratio !== 2); - Date.now = jest.fn(() => 1000); - eventProcessor.sendEvent({ - kind: 'custom', - creationDate: 1000, - context: Context.fromLDContext(user), - key: 'eventkey', - data: { thing: 'stuff' }, - samplingRatio: 2, - indexSamplingRatio: 1, }); await eventProcessor.flush(); @@ -756,27 +679,6 @@ describe('given an event processor', () => { creationDate: 1000, context: { ...user, kind: 'user' }, }, - ]); - }); - - it('does not queue a index event that is not sampled with a custom event', async () => { - // @ts-ignore - shouldSample.mockImplementation((ratio) => ratio === 2); - Date.now = jest.fn(() => 1000); - eventProcessor.sendEvent({ - kind: 'custom', - creationDate: 1000, - context: Context.fromLDContext(user), - key: 'eventkey', - data: { thing: 'stuff' }, - samplingRatio: 2, - indexSamplingRatio: 1, - }); - - await eventProcessor.flush(); - const request = await eventSender.queue.take(); - - expect(request.data).toEqual([ { kind: 'custom', key: 'eventkey', @@ -785,7 +687,6 @@ describe('given an event processor', () => { contextKeys: { user: 'userKey', }, - samplingRatio: 2, }, ]); }); @@ -798,7 +699,6 @@ describe('given an event processor', () => { key: 'eventkey', data: { thing: 'stuff' }, samplingRatio: 1, - indexSamplingRatio: 1, }); await eventProcessor.flush(); @@ -832,7 +732,6 @@ describe('given an event processor', () => { data: { thing: 'stuff' }, metricValue: 1.5, samplingRatio: 1, - indexSamplingRatio: 1, }); await eventProcessor.flush(); diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 4b847c18a..8ed346764 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -251,14 +251,14 @@ export default class EventProcessor implements LDEventProcessor { const addIndexEvent = shouldNotDeduplicate && !isIdentifyEvent; - if (addIndexEvent && shouldSample(inputEvent.indexSamplingRatio)) { + if (addIndexEvent) { this.enqueue( this.makeOutputEvent( { kind: 'index', creationDate: inputEvent.creationDate, context: inputEvent.context, - samplingRatio: inputEvent.indexSamplingRatio, + samplingRatio: 1, }, false, ), diff --git a/packages/shared/common/src/internal/events/InputCustomEvent.ts b/packages/shared/common/src/internal/events/InputCustomEvent.ts index 5e6fa2ae1..1c0c4a2b3 100644 --- a/packages/shared/common/src/internal/events/InputCustomEvent.ts +++ b/packages/shared/common/src/internal/events/InputCustomEvent.ts @@ -10,8 +10,9 @@ export default class InputCustomEvent { public readonly key: string, public readonly data?: any, public readonly metricValue?: number, + // Currently custom events are not sampled, but this is here to make the handling + // code more uniform. public readonly samplingRatio: number = 1, - public readonly indexSamplingRatio: number = 1, ) { this.creationDate = Date.now(); this.context = context; diff --git a/packages/shared/common/src/internal/events/InputEvalEvent.ts b/packages/shared/common/src/internal/events/InputEvalEvent.ts index 201f47beb..ce7e24727 100644 --- a/packages/shared/common/src/internal/events/InputEvalEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvalEvent.ts @@ -38,7 +38,6 @@ export default class InputEvalEvent { debugEventsUntilDate?: number, excludeFromSummaries?: boolean, public readonly samplingRatio: number = 1, - public readonly indexSamplingRatio: number = 1, ) { this.creationDate = Date.now(); this.default = defValue; diff --git a/packages/shared/common/src/internal/events/LDEventOverrides.ts b/packages/shared/common/src/internal/events/LDEventOverrides.ts deleted file mode 100644 index 459e0d6fa..000000000 --- a/packages/shared/common/src/internal/events/LDEventOverrides.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Represents an override for a specific custom event. - * - * This does not use the data store type, because storage would not be shared between - * client and server implementations. - */ -export interface LDMetricOverride { - samplingRatio?: number; -} - -/** - * Interfaces for accessing dynamic event configuration data from LaunchDarkly. - * - * LaunchDarkly may adjust the rate of sampling of specific event types, or - * specific custom events. - */ -export interface LDEventOverrides { - /** - * Get the sampling ratio for a custom event. - * - * @param key A key of a custom event. - */ - samplingRatio(key: string): Promise; - - /** - * Get the sampling ratio for index events. - */ - indexEventSamplingRatio(): Promise; -} diff --git a/packages/shared/common/src/internal/events/index.ts b/packages/shared/common/src/internal/events/index.ts index a124929c8..80a44f9e1 100644 --- a/packages/shared/common/src/internal/events/index.ts +++ b/packages/shared/common/src/internal/events/index.ts @@ -4,7 +4,6 @@ import InputEvalEvent from './InputEvalEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; -import { LDEventOverrides } from './LDEventOverrides'; import shouldSample from './sampling'; export { @@ -14,6 +13,5 @@ export { InputIdentifyEvent, InputMigrationEvent, EventProcessor, - LDEventOverrides, shouldSample, }; diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts index 8cdd897db..933943d20 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts +++ b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts @@ -52,12 +52,6 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments[dataKey]); break; - case 'configurationOverrides': - callback(item.configurationOverrides?.[dataKey] ?? null); - break; - case 'metrics': - callback(item.metrics?.[dataKey] ?? null); - break; default: callback(null); } @@ -89,12 +83,6 @@ export class EdgeFeatureStore implements LDFeatureStore { case 'segments': callback(item.segments); break; - case 'configurationOverrides': - callback(item.configurationOverrides || {}); - break; - case 'metrics': - callback(item.metrics || {}); - break; default: callback({}); } diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index 7822c5e31..75b0bec17 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -19,8 +19,6 @@ describe('given an event processor', () => { const allData = { flags: { flag: { version: 1 } }, segments: { segment: { version: 1 } }, - configurationOverrides: { override: { version: 1 } }, - metrics: { metric: { version: 1 } }, }; const jsonData = JSON.stringify(allData); @@ -74,10 +72,6 @@ describe('given an event processor', () => { expect(flags).toEqual(allData.flags); const segments = await storeFacade.all(VersionedDataKinds.Segments); expect(segments).toEqual(allData.segments); - const configurationOverrides = await storeFacade.all(VersionedDataKinds.ConfigurationOverrides); - expect(configurationOverrides).toEqual(allData.configurationOverrides); - const metrics = await storeFacade.all(VersionedDataKinds.Metrics); - expect(metrics).toEqual(allData.metrics); }); }); diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts index fababa968..23692184c 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts @@ -118,12 +118,6 @@ describe('given a stream processor with mock event source', () => { segments: { segkey: { key: 'segkey', version: 2 }, }, - configurationOverrides: { - configKey: { key: 'configKey', version: 3 }, - }, - metrics: { - metricKey: { key: 'metricKey', version: 4 }, - }, }, }; @@ -137,10 +131,6 @@ describe('given a stream processor with mock event source', () => { expect(f?.version).toEqual(1); const s = await asyncStore.get(VersionedDataKinds.Segments, 'segkey'); expect(s?.version).toEqual(2); - const override = await asyncStore.get(VersionedDataKinds.ConfigurationOverrides, 'configKey'); - expect(override?.version).toEqual(3); - const metric = await asyncStore.get(VersionedDataKinds.Metrics, 'metricKey'); - expect(metric?.version).toEqual(4); }); it('calls initialization callback', async () => { @@ -174,23 +164,21 @@ describe('given a stream processor with mock event source', () => { }); describe('when patching a message', () => { - it.each([ - VersionedDataKinds.Features, - VersionedDataKinds.Segments, - VersionedDataKinds.ConfigurationOverrides, - VersionedDataKinds.Metrics, - ])('patches a item of each kind: %j', async (kind) => { - streamProcessor.start(); - const patchData = { - path: `${kind.streamApiPath}itemKey`, - data: { key: 'itemKey', version: 1 }, - }; - - es.handlers.patch({ data: JSON.stringify(patchData) }); - - const f = await asyncStore.get(kind, 'itemKey'); - expect(f!.version).toEqual(1); - }); + it.each([VersionedDataKinds.Features, VersionedDataKinds.Segments])( + 'patches a item of each kind: %j', + async (kind) => { + streamProcessor.start(); + const patchData = { + path: `${kind.streamApiPath}itemKey`, + data: { key: 'itemKey', version: 1 }, + }; + + es.handlers.patch({ data: JSON.stringify(patchData) }); + + const f = await asyncStore.get(kind, 'itemKey'); + expect(f!.version).toEqual(1); + }, + ); it('passes error to callback if data is invalid', async () => { streamProcessor.start(); @@ -203,25 +191,23 @@ describe('given a stream processor with mock event source', () => { }); describe('when deleting a message', () => { - it.each([ - VersionedDataKinds.Features, - VersionedDataKinds.Segments, - VersionedDataKinds.ConfigurationOverrides, - VersionedDataKinds.Metrics, - ])('deletes each data kind: %j', async (kind) => { - streamProcessor.start(); - const item = { key: 'itemKey', version: 1 }; - await asyncStore.upsert(kind, item); - const stored = await asyncStore.get(kind, 'itemKey'); - expect(stored!.version).toEqual(1); + it.each([VersionedDataKinds.Features, VersionedDataKinds.Segments])( + 'deletes each data kind: %j', + async (kind) => { + streamProcessor.start(); + const item = { key: 'itemKey', version: 1 }; + await asyncStore.upsert(kind, item); + const stored = await asyncStore.get(kind, 'itemKey'); + expect(stored!.version).toEqual(1); - const deleteData = { path: `${kind.streamApiPath}${item.key}`, version: 2 }; + const deleteData = { path: `${kind.streamApiPath}${item.key}`, version: 2 }; - es.handlers.delete({ data: JSON.stringify(deleteData) }); + es.handlers.delete({ data: JSON.stringify(deleteData) }); - const stored2 = await asyncStore.get(VersionedDataKinds.Features, 'itemKey'); - expect(stored2).toBe(null); - }); + const stored2 = await asyncStore.get(VersionedDataKinds.Features, 'itemKey'); + expect(stored2).toBe(null); + }, + ); it('passes error to callback if data is invalid', async () => { streamProcessor.start(); diff --git a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts index 5a114ae90..7cf4a9cf7 100644 --- a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts @@ -345,7 +345,6 @@ describe('given an event processor with diagnostics manager', () => { creationDate: 1000, context, samplingRatio: 1, - indexSamplingRatio: 1, }); eventProcessor.sendEvent({ kind: 'custom', @@ -353,7 +352,6 @@ describe('given an event processor with diagnostics manager', () => { creationDate: 1001, context, samplingRatio: 1, - indexSamplingRatio: 1, }); await eventProcessor.flush(); diff --git a/packages/shared/sdk-server/__tests__/store/serialization.test.ts b/packages/shared/sdk-server/__tests__/store/serialization.test.ts index 9e4264a3f..a18ac959d 100644 --- a/packages/shared/sdk-server/__tests__/store/serialization.test.ts +++ b/packages/shared/sdk-server/__tests__/store/serialization.test.ts @@ -152,13 +152,11 @@ const segmentWithBucketBy = { deleted: false, }; -function makeAllData(flag?: any, segment?: any, override?: any, metric?: any): any { +function makeAllData(flag?: any, segment?: any): any { const allData: any = { data: { flags: {}, segments: {}, - configurationOverrides: {}, - metrics: {}, }, }; @@ -168,38 +166,26 @@ function makeAllData(flag?: any, segment?: any, override?: any, metric?: any): a if (segment) { allData.data.segments.segmentName = segment; } - if (override) { - allData.data.configurationOverrides.overrideName = override; - } - if (metric) { - allData.data.metrics.metricName = metric; - } return allData; } -function makeSerializedAllData(flag?: any, segment?: any, override?: any, metric?: any): string { - return JSON.stringify(makeAllData(flag, segment, override, metric)); +function makeSerializedAllData(flag?: any, segment?: any): string { + return JSON.stringify(makeAllData(flag, segment)); } -function makePatchData(flag?: any, segment?: any, override?: any, metric?: any): any { +function makePatchData(flag?: any, segment?: any): any { let path = '/flags/flagName'; if (segment) { path = '/segments/segmentName'; } - if (override) { - path = '/configurationOverrides/overrideName'; - } - if (metric) { - path = '/metrics/metricName'; - } return { path, - data: flag ?? segment ?? override ?? metric, + data: flag ?? segment, }; } -function makeSerializedPatchData(flag?: any, segment?: any, override?: any, metric?: any): string { - return JSON.stringify(makePatchData(flag, segment, override, metric)); +function makeSerializedPatchData(flag?: any, segment?: any): string { + return JSON.stringify(makePatchData(flag, segment)); } describe('when deserializing all data', () => { @@ -253,28 +239,6 @@ describe('when deserializing all data', () => { const ref = parsed?.data.flags.flagName.rules?.[0].rollout?.bucketByAttributeReference; expect(ref?.isValid).toBeTruthy(); }); - - it('handles a config override', () => { - const override = { - key: 'overrideName', - value: 'potato', - version: 1, - }; - const jsonString = makeSerializedAllData(undefined, undefined, override, undefined); - const parsed = deserializeAll(jsonString); - expect(parsed).toMatchObject({ data: { configurationOverrides: { overrideName: override } } }); - }); - - it('handles a metric', () => { - const metric = { - key: 'metricName', - samplingRatio: 42, - version: 1, - }; - const jsonString = makeSerializedAllData(undefined, undefined, undefined, metric); - const parsed = deserializeAll(jsonString); - expect(parsed).toMatchObject({ data: { metrics: { metricName: metric } } }); - }); }); describe('when deserializing patch data', () => { @@ -326,42 +290,6 @@ describe('when deserializing patch data', () => { const ref = (parsed?.data as Flag).rules?.[0].rollout?.bucketByAttributeReference; expect(ref?.isValid).toBeTruthy(); }); - - it('handles a config override', () => { - const override = { - key: 'overrideName', - value: 'potato', - version: 1, - }; - const jsonString = makeSerializedPatchData(undefined, undefined, override, undefined); - const parsed = deserializePatch(jsonString); - expect(parsed).toEqual({ - data: override, - path: '/configurationOverrides/overrideName', - kind: { - namespace: 'configurationOverrides', - streamApiPath: '/configurationOverrides/', - }, - }); - }); - - it('handles a metric', () => { - const metric = { - key: 'metricName', - samplingRatio: 42, - version: 1, - }; - const jsonString = makeSerializedPatchData(undefined, undefined, undefined, metric); - const parsed = deserializePatch(jsonString); - expect(parsed).toEqual({ - data: metric, - path: '/metrics/metricName', - kind: { - namespace: 'metrics', - streamApiPath: '/metrics/', - }, - }); - }); }); it('removes null elements', () => { diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index bb75b180b..c9347ab80 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -54,8 +54,6 @@ import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; import Configuration from './options/Configuration'; import AsyncStoreFacade from './store/AsyncStoreFacade'; -import { Metric } from './store/Metric'; -import { Override } from './store/Override'; import VersionedDataKinds from './store/VersionedDataKinds'; enum InitState { @@ -115,8 +113,6 @@ export default class LDClientImpl implements LDClient { private diagnosticsManager?: DiagnosticsManager; - private eventConfig: internal.LDEventOverrides; - /** * Intended for use by platform specific client implementations. * @@ -236,26 +232,6 @@ export default class LDClientImpl implements LDClient { this.onReady(); } }); - - this.eventConfig = { - samplingRatio: async (key: string) => { - const ratioItem = await this.asyncFeatureStore.get(VersionedDataKinds.Metrics, key); - if (ratioItem && !ratioItem.deleted) { - return (ratioItem as Metric).samplingRatio ?? 1; - } - return 1; - }, - indexEventSamplingRatio: async () => { - const indexSampling = await this.asyncFeatureStore.get( - VersionedDataKinds.ConfigurationOverrides, - 'indexSamplingRatio', - ); - if (indexSampling && !indexSampling.deleted) { - return (indexSampling as Override).value ?? 1; - } - return 1; - }, - }; } initialized(): boolean { @@ -577,20 +553,10 @@ export default class LDClientImpl implements LDClient { this.logger?.warn(ClientMessages.missingContextKeyNoEvent); return; } - // Async immediately invoking function expression to get the flag from the store - // without requiring track to be async. - (async () => { - this.eventProcessor.sendEvent( - this.eventFactoryDefault.customEvent( - key, - checkedContext!, - data, - metricValue, - await this.eventConfig.samplingRatio(key), - await this.eventConfig.indexEventSamplingRatio(), - ), - ); - })(); + + this.eventProcessor.sendEvent( + this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue), + ); } trackMigration(event: LDMigrationOpEvent): void { @@ -652,17 +618,9 @@ export default class LDClientImpl implements LDClient { ); this.onError(error); const result = EvalResult.forError(ErrorKinds.FlagNotFound, undefined, defaultValue); - (async () => { - const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); - this.eventProcessor.sendEvent( - this.eventFactoryDefault.unknownFlagEvent( - flagKey, - evalContext, - result.detail, - indexSamplingRatio, - ), - ); - })(); + this.eventProcessor.sendEvent( + this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), + ); cb(result); return; } @@ -686,14 +644,12 @@ export default class LDClientImpl implements LDClient { `Did not receive expected type (${type}) evaluating feature flag "${flagKey}"`, defaultValue, ); - // Method intentionally not awaited, puts event processing outside hot path. this.sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); cb(errorRes, flag); return; } } - // Method intentionally not awaited, puts event processing outside hot path. this.sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); cb(evalRes, flag); }, @@ -702,26 +658,18 @@ export default class LDClientImpl implements LDClient { }); } - private async sendEvalEvent( + private sendEvalEvent( evalRes: EvalResult, eventFactory: EventFactory, flag: Flag, evalContext: Context, defaultValue: any, ) { - const indexSamplingRatio = await this.eventConfig.indexEventSamplingRatio(); evalRes.events?.forEach((event) => { - this.eventProcessor.sendEvent({ ...event, indexSamplingRatio }); + this.eventProcessor.sendEvent({ ...event }); }); this.eventProcessor.sendEvent( - eventFactory.evalEvent( - flag, - evalContext, - evalRes.detail, - defaultValue, - undefined, - indexSamplingRatio, - ), + eventFactory.evalEvent(flag, evalContext, evalRes.detail, defaultValue, undefined), ); } diff --git a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts index fccf40f53..c40209e0b 100644 --- a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts +++ b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts @@ -137,11 +137,5 @@ export default class FileDataSource implements LDStreamProcessor { processSegment(parsed.segments[key]); this.addItem(VersionedDataKinds.Segments, parsed.segments[key]); }); - Object.keys(parsed.configurationOverrides || {}).forEach((key) => { - this.addItem(VersionedDataKinds.ConfigurationOverrides, parsed.configurationOverrides[key]); - }); - Object.keys(parsed.metrics || {}).forEach((key) => { - this.addItem(VersionedDataKinds.Metrics, parsed.metrics[key]); - }); } } diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index 307691efe..a7af081a4 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -69,9 +69,6 @@ export default class PollingProcessor implements LDStreamProcessor { const initData = { [VersionedDataKinds.Features.namespace]: parsed.flags, [VersionedDataKinds.Segments.namespace]: parsed.segments, - [VersionedDataKinds.ConfigurationOverrides.namespace]: - parsed.configurationOverrides || {}, - [VersionedDataKinds.Metrics.namespace]: parsed.metrics || {}, }; this.featureStore.init(initData, () => { fn?.(); diff --git a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts index 698121749..411657aff 100644 --- a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts @@ -131,9 +131,6 @@ export default class StreamingProcessor implements LDStreamProcessor { const initData = { [VersionedDataKinds.Features.namespace]: parsed.data.flags, [VersionedDataKinds.Segments.namespace]: parsed.data.segments, - [VersionedDataKinds.ConfigurationOverrides.namespace]: - parsed.data.configurationOverrides || {}, - [VersionedDataKinds.Metrics.namespace]: parsed.data.metrics || {}, }; this.featureStore.init(initData, () => fn?.()); diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index a996c1b5e..b9067971c 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -15,7 +15,6 @@ export default class EventFactory { detail: LDEvaluationDetail, defaultVal: any, prereqOfFlag?: Flag, - indexEventSamplingRatio?: number, ): internal.InputEvalEvent { const addExperimentData = isExperiment(flag, detail.reason); return new internal.InputEvalEvent( @@ -33,16 +32,10 @@ export default class EventFactory { flag.debugEventsUntilDate, flag.excludeFromSummaries, flag.samplingRatio, - indexEventSamplingRatio ?? 1, ); } - unknownFlagEvent( - key: string, - context: Context, - detail: LDEvaluationDetail, - indexEventSamplingRatio?: number, - ) { + unknownFlagEvent(key: string, context: Context, detail: LDEvaluationDetail) { return new internal.InputEvalEvent( this.withReasons, context, @@ -59,7 +52,6 @@ export default class EventFactory { undefined, // debugEventsUntilDate undefined, // exclude from summaries undefined, // sampling ratio - indexEventSamplingRatio, ); } @@ -76,7 +68,6 @@ export default class EventFactory { data?: any, metricValue?: number, samplingRatio: number = 1, - indexSamplingRatio: number = 1, ) { return new internal.InputCustomEvent( context, @@ -84,7 +75,6 @@ export default class EventFactory { data ?? undefined, metricValue ?? undefined, samplingRatio, - indexSamplingRatio, ); } } diff --git a/packages/shared/sdk-server/src/store/Metric.ts b/packages/shared/sdk-server/src/store/Metric.ts deleted file mode 100644 index ec42a4e28..000000000 --- a/packages/shared/sdk-server/src/store/Metric.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Versioned } from '../evaluation/data/Versioned'; - -export interface Metric extends Versioned { - samplingRatio?: number; -} diff --git a/packages/shared/sdk-server/src/store/Override.ts b/packages/shared/sdk-server/src/store/Override.ts deleted file mode 100644 index 37bc07524..000000000 --- a/packages/shared/sdk-server/src/store/Override.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Versioned } from '../evaluation/data/Versioned'; - -export interface Override extends Versioned { - value: any; -} diff --git a/packages/shared/sdk-server/src/store/VersionedDataKinds.ts b/packages/shared/sdk-server/src/store/VersionedDataKinds.ts index 2ac0af241..0052e18ae 100644 --- a/packages/shared/sdk-server/src/store/VersionedDataKinds.ts +++ b/packages/shared/sdk-server/src/store/VersionedDataKinds.ts @@ -16,14 +16,4 @@ export default class VersionedDataKinds { namespace: 'segments', streamApiPath: '/segments/', }; - - static readonly ConfigurationOverrides: VersionedDataKind = { - namespace: 'configurationOverrides', - streamApiPath: '/configurationOverrides/', - }; - - static readonly Metrics: VersionedDataKind = { - namespace: 'metrics', - streamApiPath: '/metrics/', - }; } diff --git a/packages/shared/sdk-server/src/store/serialization.ts b/packages/shared/sdk-server/src/store/serialization.ts index 9c3a51243..8c9f545af 100644 --- a/packages/shared/sdk-server/src/store/serialization.ts +++ b/packages/shared/sdk-server/src/store/serialization.ts @@ -8,8 +8,6 @@ import { VersionedData } from '../api/interfaces'; import { Flag } from '../evaluation/data/Flag'; import { Rollout } from '../evaluation/data/Rollout'; import { Segment } from '../evaluation/data/Segment'; -import { Metric } from './Metric'; -import { Override } from './Override'; import VersionedDataKinds, { VersionedDataKind } from './VersionedDataKinds'; // The max size where we use an array instead of a set. @@ -31,8 +29,6 @@ export function reviver(this: any, key: string, value: any): any { interface AllData { flags: { [name: string]: Flag }; segments: { [name: string]: Segment }; - configurationOverrides?: { [name: string]: Override }; - metrics?: { [name: string]: Metric }; } interface AllDataStream { @@ -92,12 +88,10 @@ interface DeleteData extends Omit { type VersionedFlag = VersionedData & Flag; type VersionedSegment = VersionedData & Segment; -type VersionedOverride = VersionedData & Override; -type VersionedMetric = VersionedData & Metric; interface PatchData { path: string; - data: VersionedFlag | VersionedSegment | VersionedOverride | VersionedMetric; + data: VersionedFlag | VersionedSegment; kind?: VersionedDataKind; } @@ -262,10 +256,6 @@ export function deserializePatch(data: string): PatchData | undefined { } else if (parsed.path.startsWith(VersionedDataKinds.Segments.streamApiPath)) { processSegment(parsed.data as VersionedSegment); parsed.kind = VersionedDataKinds.Segments; - } else if (parsed.path.startsWith(VersionedDataKinds.ConfigurationOverrides.streamApiPath)) { - parsed.kind = VersionedDataKinds.ConfigurationOverrides; - } else if (parsed.path.startsWith(VersionedDataKinds.Metrics.streamApiPath)) { - parsed.kind = VersionedDataKinds.Metrics; } return parsed; @@ -283,10 +273,6 @@ export function deserializeDelete(data: string): DeleteData | undefined { parsed.kind = VersionedDataKinds.Features; } else if (parsed.path.startsWith(VersionedDataKinds.Segments.streamApiPath)) { parsed.kind = VersionedDataKinds.Segments; - } else if (parsed.path.startsWith(VersionedDataKinds.ConfigurationOverrides.streamApiPath)) { - parsed.kind = VersionedDataKinds.ConfigurationOverrides; - } else if (parsed.path.startsWith(VersionedDataKinds.Metrics.streamApiPath)) { - parsed.kind = VersionedDataKinds.Metrics; } return parsed; } diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index de7d2d4f8..fffb1151d 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -6,17 +6,10 @@ import clearPrefix from './clearPrefix'; const featuresKind = { namespace: 'features', deserialize: (data: string) => JSON.parse(data) }; const segmentsKind = { namespace: 'segments', deserialize: (data: string) => JSON.parse(data) }; -const configurationOverridesKind = { - namespace: 'configurationOverrides', - deserialize: (data: string) => JSON.parse(data), -}; -const metricsKind = { namespace: 'metrics', deserialize: (data: string) => JSON.parse(data) }; const dataKind = { features: featuresKind, segments: segmentsKind, - configurationOverrides: configurationOverridesKind, - metrics: metricsKind, }; function promisify(method: (callback: (val: T) => void) => void): Promise { @@ -105,24 +98,14 @@ describe('given an empty store', () => { const segments = [ { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, ]; - const configurationOverrides = [ - { key: 'first', item: { version: 1, serializedItem: `{"version":3}`, deleted: false } }, - ]; - const metrics = [ - { key: 'first', item: { version: 1, serializedItem: `{"version":4}`, deleted: false } }, - ]; await facade.init([ { key: dataKind.features, item: flags }, { key: dataKind.segments, item: segments }, - { key: dataKind.configurationOverrides, item: configurationOverrides }, - { key: dataKind.metrics, item: metrics }, ]); const items1 = await facade.getAll(dataKind.features); const items2 = await facade.getAll(dataKind.segments); - const overrides1 = await facade.getAll(dataKind.configurationOverrides); - const metrics1 = await facade.getAll(dataKind.metrics); // Reading from the store will not maintain the version. expect(items1).toEqual([ @@ -142,43 +125,20 @@ describe('given an empty store', () => { }, ]); - expect(overrides1).toEqual([ - { - key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":3}' }, - }, - ]); - expect(metrics1).toEqual([ - { - key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":4}' }, - }, - ]); - const newFlags = [ { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, ]; const newSegments = [ { key: 'first', item: { version: 3, serializedItem: `{"version":3}`, deleted: false } }, ]; - const newOverrides = [ - { key: 'first', item: { version: 2, serializedItem: `{"version":5}`, deleted: false } }, - ]; - const newMetrics = [ - { key: 'first', item: { version: 3, serializedItem: `{"version":6}`, deleted: false } }, - ]; await facade.init([ { key: dataKind.features, item: newFlags }, { key: dataKind.segments, item: newSegments }, - { key: dataKind.configurationOverrides, item: newOverrides }, - { key: dataKind.metrics, item: newMetrics }, ]); const items3 = await facade.getAll(dataKind.features); const items4 = await facade.getAll(dataKind.segments); - const overrides2 = await facade.getAll(dataKind.configurationOverrides); - const metrics2 = await facade.getAll(dataKind.metrics); expect(items3).toEqual([ { @@ -192,18 +152,6 @@ describe('given an empty store', () => { item: { version: 0, deleted: false, serializedItem: '{"version":3}' }, }, ]); - expect(overrides2).toEqual([ - { - key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":5}' }, - }, - ]); - expect(metrics2).toEqual([ - { - key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":6}' }, - }, - ]); }); }); From b22eef486c65fc42d9828150f7a428916bc86e94 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 2 Oct 2023 15:51:15 -0700 Subject: [PATCH 51/57] fix: yarn topological dev build (#291) CI failed because `yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/xxx' run build`. This is because `topological-dev` instructs yarn to build all deps (prod & dev) prior to running build in the current workspace. Since `mocks` need types from `common` this fails. This pr adds a `build-types` command to common which gets run prior to mocks `build` command to ensure common types exist for mocks to be built successfully. --- .github/workflows/mocks.yml | 3 ++- actions/ci/action.yml | 5 ++++- packages/shared/common/package.json | 1 + packages/shared/mocks/package.json | 3 ++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mocks.yml b/.github/workflows/mocks.yml index 0a2c39269..674aa7a70 100644 --- a/.github/workflows/mocks.yml +++ b/.github/workflows/mocks.yml @@ -1,4 +1,4 @@ -name: sdk/cloudflare +name: shared/mocks on: push: @@ -22,3 +22,4 @@ jobs: with: workspace_name: '@launchdarkly/private-js-mocks' workspace_path: packages/shared/mocks + should_build_docs: false diff --git a/actions/ci/action.yml b/actions/ci/action.yml index 439e4a3d5..6751d0cbd 100644 --- a/actions/ci/action.yml +++ b/actions/ci/action.yml @@ -11,7 +11,9 @@ inputs: workspace_path: description: 'Path to the package to release.' required: true - + should_build_docs: + description: 'Whether docs should be built. It will be by default.' + default: true runs: using: composite steps: @@ -40,4 +42,5 @@ runs: - name: Build Docs shell: bash + if: ${{inputs.should_build_docs == 'true'}} run: yarn build:doc -- ${{ inputs.workspace_path }} diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index ade47c22c..dce77af8a 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -20,6 +20,7 @@ ], "scripts": { "test": "npx jest --ci", + "build-types": "npx tsc --declaration true --emitDeclarationOnly true --declarationDir dist", "build": "npx tsc", "clean": "npx tsc --build --clean", "lint": "npx eslint . --ext .ts", diff --git a/packages/shared/mocks/package.json b/packages/shared/mocks/package.json index 47ce02e41..aa8ceb63f 100644 --- a/packages/shared/mocks/package.json +++ b/packages/shared/mocks/package.json @@ -24,7 +24,8 @@ ], "scripts": { "test": "", - "build": "npx tsc", + "build-types": "yarn workspace @launchdarkly/js-sdk-common build-types", + "build": "yarn build-types && npx tsc", "clean": "npx tsc --build --clean", "lint": "npx eslint --ext .ts", "lint:fix": "yarn run lint -- --fix" From a3f64af5c4cd12181ebf5744fd6862f1fa223db7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Oct 2023 10:05:36 -0700 Subject: [PATCH 52/57] fix unit tests post merge --- .../internal/events/EventProcessor.test.ts | 13 ++++++++----- .../__tests__/LDClient.migrations.test.ts | 3 ++- .../shared/sdk-server/__tests__/Migration.test.ts | 3 ++- .../sdk-server/__tests__/MigrationOpEvent.test.ts | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index b88d61576..be3cf3ec6 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -4,9 +4,15 @@ import { Context } from '../../../src'; import { LDContextDeduplicator, LDDeliveryStatus, LDEventType } from '../../../src/api/subsystem'; import { EventProcessor, InputIdentifyEvent } from '../../../src/internal'; import { EventProcessorOptions } from '../../../src/internal/events/EventProcessor'; +import shouldSample from '../../../src/internal/events/sampling'; import BasicLogger from '../../../src/logging/BasicLogger'; import format from '../../../src/logging/format'; +jest.mock('../../../src/internal/events/sampling', () => ({ + __esModule: true, + default: jest.fn(() => true), +})); + const mockSendEventData = jest.fn(); jest.useFakeTimers(); @@ -224,10 +230,9 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); expect(shouldSample).toHaveBeenCalledWith(2); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, @@ -257,11 +262,9 @@ describe('given an event processor', () => { }); await eventProcessor.flush(); - const request = await eventSender.queue.take(); expect(shouldSample).toHaveBeenCalledWith(2); - expect(shouldSample).toHaveBeenCalledWith(1); - expect(request.data).toEqual([ + expect(mockSendEventData).toBeCalledWith(LDEventType.AnalyticsEvents, [ { kind: 'index', creationDate: 1000, diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index b3ee43612..5775c7169 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -1,7 +1,8 @@ +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + import { LDClientImpl, LDMigrationStage } from '../src'; import TestData from '../src/integrations/test_data/TestData'; import { LDClientCallbacks } from '../src/LDClientImpl'; -import basicPlatform from './evaluation/mocks/platform'; /** * Basic callback handler that records errors for tests. diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts index 2a23fb3ed..ca1ed8284 100644 --- a/packages/shared/sdk-server/__tests__/Migration.test.ts +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -1,3 +1,5 @@ +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + import { LDClientImpl, LDConcurrentExecution, @@ -8,7 +10,6 @@ import { import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; import { createMigration, LDMigrationError, LDMigrationSuccess } from '../src/Migration'; -import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; describe('given an LDClient with test data', () => { diff --git a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts index 6e1e214d3..f1eb9d88f 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts @@ -1,6 +1,7 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; import { internal } from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; import { LDClientImpl, @@ -15,7 +16,6 @@ import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; import { createMigration, LDMigrationError, LDMigrationSuccess } from '../src/Migration'; import MigrationOpEventConversion from '../src/MigrationOpEventConversion'; -import basicPlatform from './evaluation/mocks/platform'; import makeCallbacks from './makeCallbacks'; jest.mock('@launchdarkly/js-sdk-common', () => ({ From a0dac0d7f843cc4c8de32ef28642b03d1c20fdc2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Oct 2023 10:35:39 -0700 Subject: [PATCH 53/57] Remove config and metric kind from contract tests. --- contract-tests/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/contract-tests/index.js b/contract-tests/index.js index 1588b4654..06430d0a5 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -30,8 +30,6 @@ app.get('/', (req, res) => { 'user-type', 'migrations', 'event-sampling', - 'config-override-kind', - 'metric-kind', 'strongly-typed', ], }); From 8f34dfacb0ad1c3b21060b67b817e4d2dcc19208 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 10 Oct 2023 11:23:29 -0700 Subject: [PATCH 54/57] chore: implement initial flag fetch (#294) First attempt to implement an initial flag fetch followed by emitting events. I also added comments like this: ```tsx Dom api usage: xxx ``` There are three right now: fetch, btoa and EventTarget. I left comments in the code for react native how to deal with these. --------- Co-authored-by: LaunchDarklyReleaseBot Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> --- contract-tests/sdkClientEntity.js | 77 +++++++++++----- .../common/src/api/platform/Encoding.ts | 3 + .../common/src/api/platform/Platform.ts | 6 ++ .../shared/common/src/api/platform/index.ts | 1 + packages/shared/mocks/src/clientContext.ts | 4 +- packages/shared/mocks/src/index.ts | 2 + packages/shared/mocks/src/mockFetch.ts | 32 +++++++ packages/shared/mocks/src/platform.ts | 7 +- packages/shared/sdk-client/package.json | 1 + .../{LDClientDomImpl.ts => LDClientImpl.ts} | 63 +++++++++++-- .../src/api/{LDClientDom.ts => LDClient.ts} | 6 +- .../sdk-client/src/api/LDEmitter.test.ts | 58 ++++++++++++ .../shared/sdk-client/src/api/LDEmitter.ts | 52 +++++++++-- .../shared/sdk-client/src/api/LDOptions.ts | 8 +- .../src/configuration/Configuration.test.ts | 4 +- .../src/configuration/Configuration.ts | 7 +- .../src/configuration/validators.ts | 2 +- .../createDiagnosticsInitConfig.ts | 4 +- .../src/evaluation/fetchFlags.test.ts | 92 +++++++++++++++++++ .../sdk-client/src/evaluation/fetchFlags.ts | 31 +++++++ .../src/evaluation/fetchUtils.test.ts | 4 + .../sdk-client/src/evaluation/fetchUtils.ts | 86 +++++++++++++++++ .../src/evaluation/mockResponse.json | 58 ++++++++++++ .../evaluation/mockResponseWithReasons.json | 66 +++++++++++++ .../src/events/createEventProcessor.ts | 5 +- .../sdk-client/src/platform/PlatformDom.ts | 13 --- packages/shared/sdk-client/tsconfig.json | 4 +- 27 files changed, 623 insertions(+), 73 deletions(-) create mode 100644 packages/shared/common/src/api/platform/Encoding.ts create mode 100644 packages/shared/mocks/src/mockFetch.ts rename packages/shared/sdk-client/src/{LDClientDomImpl.ts => LDClientImpl.ts} (53%) rename packages/shared/sdk-client/src/api/{LDClientDom.ts => LDClient.ts} (99%) create mode 100644 packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts create mode 100644 packages/shared/sdk-client/src/evaluation/fetchFlags.ts create mode 100644 packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts create mode 100644 packages/shared/sdk-client/src/evaluation/fetchUtils.ts create mode 100644 packages/shared/sdk-client/src/evaluation/mockResponse.json create mode 100644 packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json delete mode 100644 packages/shared/sdk-client/src/platform/PlatformDom.ts diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index 2cb4153e7..c9a1556f3 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -1,11 +1,11 @@ import got from 'got'; import ld, { + createMigration, LDConcurrentExecution, LDExecutionOrdering, LDMigrationError, LDMigrationSuccess, LDSerialExecution, - createMigration, } from 'node-server-sdk'; import BigSegmentTestStore from './BigSegmentTestStore.js'; @@ -17,7 +17,7 @@ export { badCommandError }; export function makeSdkConfig(options, tag) { const cf = { logger: sdkLogger(tag), - diagnosticOptOut: true + diagnosticOptOut: true, }; const maybeTime = (seconds) => seconds === undefined || seconds === null ? undefined : seconds / 1000; @@ -125,29 +125,64 @@ export async function newSdkClientEntity(options) { case 'evaluate': { const pe = params.evaluate; if (pe.detail) { - switch(pe.valueType) { - case "bool": - return await client.boolVariationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); - case "int": // Intentional fallthrough. - case "double": - return await client.numberVariationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); - case "string": - return await client.stringVariationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + switch (pe.valueType) { + case 'bool': + return await client.boolVariationDetail( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ); + case 'int': // Intentional fallthrough. + case 'double': + return await client.numberVariationDetail( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ); + case 'string': + return await client.stringVariationDetail( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ); default: - return await client.variationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue); + return await client.variationDetail( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ); } - } else { - switch(pe.valueType) { - case "bool": - return {value: await client.boolVariation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; - case "int": // Intentional fallthrough. - case "double": - return {value: await client.numberVariation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; - case "string": - return {value: await client.stringVariation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; + switch (pe.valueType) { + case 'bool': + return { + value: await client.boolVariation( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ), + }; + case 'int': // Intentional fallthrough. + case 'double': + return { + value: await client.numberVariation( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ), + }; + case 'string': + return { + value: await client.stringVariation( + pe.flagKey, + pe.context || pe.user, + pe.defaultValue, + ), + }; default: - return {value: await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue)}; + return { + value: await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue), + }; } } } diff --git a/packages/shared/common/src/api/platform/Encoding.ts b/packages/shared/common/src/api/platform/Encoding.ts new file mode 100644 index 000000000..c431ada02 --- /dev/null +++ b/packages/shared/common/src/api/platform/Encoding.ts @@ -0,0 +1,3 @@ +export interface Encoding { + btoa(data: string): string; +} diff --git a/packages/shared/common/src/api/platform/Platform.ts b/packages/shared/common/src/api/platform/Platform.ts index b73cfb520..6c667dcaf 100644 --- a/packages/shared/common/src/api/platform/Platform.ts +++ b/packages/shared/common/src/api/platform/Platform.ts @@ -1,9 +1,15 @@ import { Crypto } from './Crypto'; +import { Encoding } from './Encoding'; import { Filesystem } from './Filesystem'; import { Info } from './Info'; import { Requests } from './Requests'; export interface Platform { + /** + * The interface for performing encoding operations. + */ + encoding?: Encoding; + /** * The interface for getting information about the platform and the execution * environment. diff --git a/packages/shared/common/src/api/platform/index.ts b/packages/shared/common/src/api/platform/index.ts index fc31a909a..0e488004e 100644 --- a/packages/shared/common/src/api/platform/index.ts +++ b/packages/shared/common/src/api/platform/index.ts @@ -1,3 +1,4 @@ +export * from './Encoding'; export * from './Crypto'; export * from './Filesystem'; export * from './Info'; diff --git a/packages/shared/mocks/src/clientContext.ts b/packages/shared/mocks/src/clientContext.ts index b59f6cc43..aa14729f5 100644 --- a/packages/shared/mocks/src/clientContext.ts +++ b/packages/shared/mocks/src/clientContext.ts @@ -1,13 +1,13 @@ import type { ClientContext } from '@common'; -import platform from './platform'; +import basicPlatform from './platform'; const clientContext: ClientContext = { basicConfiguration: { sdkKey: 'testSdkKey', serviceEndpoints: { events: '', polling: '', streaming: 'https://mockstream.ld.com' }, }, - platform, + platform: basicPlatform, }; export default clientContext; diff --git a/packages/shared/mocks/src/index.ts b/packages/shared/mocks/src/index.ts index 26896b6d4..beceb6f47 100644 --- a/packages/shared/mocks/src/index.ts +++ b/packages/shared/mocks/src/index.ts @@ -2,12 +2,14 @@ import clientContext from './clientContext'; import ContextDeduplicator from './contextDeduplicator'; import { crypto, hasher } from './hasher'; import logger from './logger'; +import mockFetch from './mockFetch'; import basicPlatform from './platform'; import { MockStreamingProcessor, setupMockStreamingProcessor } from './streamingProcessor'; export { basicPlatform, clientContext, + mockFetch, crypto, logger, hasher, diff --git a/packages/shared/mocks/src/mockFetch.ts b/packages/shared/mocks/src/mockFetch.ts new file mode 100644 index 000000000..0ae07f804 --- /dev/null +++ b/packages/shared/mocks/src/mockFetch.ts @@ -0,0 +1,32 @@ +import { Response } from '@common'; + +import basicPlatform from './platform'; + +const createMockResponse = (remoteJson: any, statusCode: number) => { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: jest.fn(), + json: () => Promise.resolve(remoteJson), + }; + return Promise.resolve(response); +}; + +/** + * Mocks basicPlatform fetch. Returns the fetch jest.Mock object. + * @param remoteJson + * @param statusCode + */ +const mockFetch = (remoteJson: any, statusCode: number = 200): jest.Mock => { + const f = basicPlatform.requests.fetch as jest.Mock; + f.mockResolvedValue(createMockResponse(remoteJson, statusCode)); + return f; +}; + +export default mockFetch; diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/mocks/src/platform.ts index d817ceaea..7b11b5c71 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -1,7 +1,11 @@ -import type { Info, Platform, PlatformData, Requests, SdkData } from '@common'; +import type { Encoding, Info, Platform, PlatformData, Requests, SdkData } from '@common'; import { crypto } from './hasher'; +const encoding: Encoding = { + btoa: (s: string) => Buffer.from(s).toString('base64'), +}; + const info: Info = { platformData(): PlatformData { return { @@ -33,6 +37,7 @@ const requests: Requests = { }; const basicPlatform: Platform = { + encoding, info, crypto, requests, diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index 707f622dd..d87dfef8e 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -34,6 +34,7 @@ "semver": "7.5.4" }, "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^5.16.5", "@types/jest": "^29.5.3", diff --git a/packages/shared/sdk-client/src/LDClientDomImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts similarity index 53% rename from packages/shared/sdk-client/src/LDClientDomImpl.ts rename to packages/shared/sdk-client/src/LDClientImpl.ts index f8bab88ba..e003f182f 100644 --- a/packages/shared/sdk-client/src/LDClientDomImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -2,37 +2,76 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { + Context, internal, LDContext, LDEvaluationDetail, LDFlagSet, LDFlagValue, + LDLogger, + Platform, subsystem, } from '@launchdarkly/js-sdk-common'; -import { LDClientDom } from './api/LDClientDom'; +import { LDClient } from './api/LDClient'; +import LDEmitter, { EventName } from './api/LDEmitter'; import LDOptions from './api/LDOptions'; import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; +import fetchFlags, { Flags } from './evaluation/fetchFlags'; import createEventProcessor from './events/createEventProcessor'; -import { PlatformDom, Storage } from './platform/PlatformDom'; -export default class LDClientDomImpl implements LDClientDom { +export default class LDClientImpl implements LDClient { config: Configuration; diagnosticsManager?: internal.DiagnosticsManager; eventProcessor: subsystem.LDEventProcessor; - storage: Storage; + private emitter: LDEmitter; + private flags: Flags = {}; + private logger: LDLogger; + + /** + * Creates the client object synchronously. No async, no network calls. + */ + constructor( + public readonly sdkKey: string, + public readonly context: LDContext, + public readonly platform: Platform, + options: LDOptions, + ) { + if (!sdkKey) { + throw new Error('You must configure the client with a client-side SDK key'); + } + + const checkedContext = Context.fromLDContext(context); + if (!checkedContext.valid) { + throw new Error('Context was unspecified or had no key'); + } + + if (!platform.encoding) { + throw new Error('Platform must implement Encoding because btoa is required.'); + } - constructor(clientSideID: string, context: LDContext, options: LDOptions, platform: PlatformDom) { this.config = new Configuration(options); - this.storage = platform.storage; - this.diagnosticsManager = createDiagnosticsManager(clientSideID, this.config, platform); + this.logger = this.config.logger; + this.diagnosticsManager = createDiagnosticsManager(sdkKey, this.config, platform); this.eventProcessor = createEventProcessor( - clientSideID, + sdkKey, this.config, platform, this.diagnosticsManager, ); + this.emitter = new LDEmitter(); + } + + async start() { + try { + this.flags = await fetchFlags(this.sdkKey, this.context, this.config, this.platform); + this.emitter.emit('ready'); + } catch (error: any) { + this.logger.error(error); + this.emitter.emit('error', error); + this.emitter.emit('failed', error); + } } allFlags(): LDFlagSet { @@ -59,9 +98,13 @@ export default class LDClientDomImpl implements LDClientDom { return Promise.resolve({}); } - off(key: string, callback: (...args: any[]) => void, context?: any): void {} + off(eventName: EventName, listener?: Function): void { + this.emitter.off(eventName, listener); + } - on(key: string, callback: (...args: any[]) => void, context?: any): void {} + on(eventName: EventName, listener: Function): void { + this.emitter.on(eventName, listener); + } setStreaming(value?: boolean): void {} diff --git a/packages/shared/sdk-client/src/api/LDClientDom.ts b/packages/shared/sdk-client/src/api/LDClient.ts similarity index 99% rename from packages/shared/sdk-client/src/api/LDClientDom.ts rename to packages/shared/sdk-client/src/api/LDClient.ts index b09dc3dff..854cd5876 100644 --- a/packages/shared/sdk-client/src/api/LDClientDom.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -7,7 +7,7 @@ import { LDContext, LDEvaluationDetail, LDFlagSet, LDFlagValue } from '@launchda * * @ignore (don't need to show this separately in TypeDoc output; all methods will be shown in LDClient) */ -export interface LDClientDom { +export interface LDClient { /** * Returns a Promise that tracks the client's initialization state. * @@ -172,7 +172,7 @@ export interface LDClientDom { * * If this is true, the client will always attempt to maintain a streaming connection; if false, * it never will. If you leave the value undefined (the default), the client will open a streaming - * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClientDom.on}). + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). * * This can also be set as the `streaming` property of {@link LDOptions}. */ @@ -206,7 +206,7 @@ export interface LDClientDom { * The `"change"` and `"change:FLAG-KEY"` events have special behavior: by default, the * client will open a streaming connection to receive live changes if and only if * you are listening for one of these events. This behavior can be overridden by - * setting `streaming` in {@link LDOptions} or calling {@link LDClientDom.setStreaming}. + * setting `streaming` in {@link LDOptions} or calling {@link LDClient.setStreaming}. * * @param key * The name of the event for which to listen. diff --git a/packages/shared/sdk-client/src/api/LDEmitter.test.ts b/packages/shared/sdk-client/src/api/LDEmitter.test.ts index cba0d4bcd..9783b55da 100644 --- a/packages/shared/sdk-client/src/api/LDEmitter.test.ts +++ b/packages/shared/sdk-client/src/api/LDEmitter.test.ts @@ -1,3 +1,5 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; + import LDEmitter from './LDEmitter'; describe('LDEmitter', () => { @@ -74,4 +76,60 @@ describe('LDEmitter', () => { expect(emitter.listenerCount('error')).toEqual(2); expect(emitter.listenerCount('change')).toEqual(1); }); + + test('on listener with arguments', () => { + const context = { kind: 'user', key: 'test-user-1' }; + const onListener = jest.fn((c: LDContext) => c); + + emitter.on('change', onListener); + emitter.emit('change', context); + + expect(onListener).toBeCalledWith(context); + }); + + test('unsubscribe one of many listeners', () => { + const errorHandler1 = jest.fn(); + const errorHandler2 = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('error', errorHandler2); + emitter.off('error', errorHandler2); + emitter.emit('error'); + + expect(emitter.listenerCount('error')).toEqual(1); + expect(errorHandler2).not.toBeCalled(); + }); + + test('unsubscribe all listeners manually', () => { + const errorHandler1 = jest.fn(); + const errorHandler2 = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('error', errorHandler2); + + // intentional duplicate calls to ensure no errors are thrown if the + // same handler gets removed multiple times + emitter.off('error', errorHandler1); + emitter.off('error', errorHandler1); + emitter.off('error', errorHandler2); + emitter.emit('error'); + + expect(emitter.listenerCount('error')).toEqual(0); + expect(errorHandler1).not.toBeCalled(); + expect(errorHandler2).not.toBeCalled(); + }); + + test('unsubscribe all listeners by event name', () => { + const errorHandler1 = jest.fn(); + const errorHandler2 = jest.fn(); + + emitter.on('error', errorHandler1); + emitter.on('error', errorHandler2); + emitter.off('error'); + emitter.emit('error'); + + expect(emitter.listenerCount('error')).toEqual(0); + expect(errorHandler1).not.toBeCalled(); + expect(errorHandler2).not.toBeCalled(); + }); }); diff --git a/packages/shared/sdk-client/src/api/LDEmitter.ts b/packages/shared/sdk-client/src/api/LDEmitter.ts index d0da6112a..53ad6be38 100644 --- a/packages/shared/sdk-client/src/api/LDEmitter.ts +++ b/packages/shared/sdk-client/src/api/LDEmitter.ts @@ -1,6 +1,12 @@ -export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | 'failed' | 'error'; +export type EventName = 'change' | 'ready' | 'failed' | 'error'; +type CustomEventListeners = { + original: Function; + custom: Function; +}; /** + * Native api usage: EventTarget. + * * This is an event emitter using the standard built-in EventTarget web api. * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget * @@ -11,15 +17,19 @@ export type EventName = 'change' | 'internal-change' | 'ready' | 'initialized' | export default class LDEmitter { private et: EventTarget = new EventTarget(); - private listeners: Map = new Map(); + private listeners: Map = new Map(); /** * Cache all listeners in a Map so we can remove them later * @param name string event name - * @param listener function to handle the event + * @param originalListener pointer to the original function as specified by + * the consumer + * @param customListener pointer to the custom function based on original + * listener. This is needed to allow for CustomEvents. * @private */ - private saveListener(name: EventName, listener: EventListener) { + private saveListener(name: EventName, originalListener: Function, customListener: Function) { + const listener = { original: originalListener, custom: customListener }; if (!this.listeners.has(name)) { this.listeners.set(name, [listener]); } else { @@ -34,13 +44,39 @@ export default class LDEmitter { // invoke listener with args from CustomEvent listener(...detail); }; - this.saveListener(name, customListener); + this.saveListener(name, listener, customListener); this.et.addEventListener(name, customListener); } - off(name: EventName) { - this.listeners.get(name)?.forEach((l) => { - this.et.removeEventListener(name, l); + /** + * Unsubscribe one or all events. + * + * @param name + * @param listener Optional. If unspecified, all listeners for the event will be removed. + */ + off(name: EventName, listener?: Function) { + const existingListeners = this.listeners.get(name); + if (!existingListeners) { + return; + } + + if (listener) { + const toBeRemoved = existingListeners.find((c) => c.original === listener); + this.et.removeEventListener(name, toBeRemoved?.custom as any); + + // remove from internal cache + const updated = existingListeners.filter((l) => l.original !== listener); + if (updated.length === 0) { + this.listeners.delete(name); + } else { + this.listeners.set(name, updated); + } + return; + } + + // remove all listeners + existingListeners.forEach((l) => { + this.et.removeEventListener(name, l.custom as any); }); this.listeners.delete(name); } diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 2fc7a4dbe..34d5118a5 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -15,7 +15,7 @@ export default interface LDOptions { * * 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 LDClientDom.variation} + * 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). * @@ -49,7 +49,7 @@ export default interface LDOptions { * * If this is true, the client will always attempt to maintain a streaming connection; if false, * it never will. If you leave the value undefined (the default), the client will open a streaming - * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClientDom.on}). + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). * * This is equivalent to calling `client.setStreaming()` with the same value. */ @@ -95,10 +95,10 @@ export default interface LDOptions { * calculated. * * The additional information will then be available through the client's - * {@link LDClientDom.variationDetail} method. Since this increases the size of network requests, + * {@link LDClient.variationDetail} method. Since this increases the size of network requests, * such information is not sent unless you set this option to true. */ - evaluationReasons?: boolean; + withReasons?: boolean; /** * Whether to send analytics events back to LaunchDarkly. By default, this is true. diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts index 4bca3af62..e501b3d84 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -12,11 +12,11 @@ describe('Configuration', () => { expect(config).toMatchObject({ allAttributesPrivate: false, - baseUri: 'https://clientsdk.launchdarkly.com', + baseUri: 'https://sdk.launchdarkly.com', capacity: 100, diagnosticOptOut: false, diagnosticRecordingInterval: 900, - evaluationReasons: false, + withReasons: false, eventsUri: 'https://events.launchdarkly.com', flushInterval: 2, inspectors: [], diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 0c21ca0e4..75640cec7 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -1,4 +1,5 @@ import { + ApplicationTags, createSafeLogger, LDFlagSet, NumberWithMinimum, @@ -12,7 +13,7 @@ import type LDOptions from '../api/LDOptions'; import validators from './validators'; export default class Configuration { - public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; + public static DEFAULT_POLLING = 'https://sdk.launchdarkly.com'; public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; public readonly logger = createSafeLogger(); @@ -28,7 +29,7 @@ export default class Configuration { public readonly allAttributesPrivate = false; public readonly diagnosticOptOut = false; - public readonly evaluationReasons = false; + public readonly withReasons = false; public readonly sendEvents = true; public readonly sendLDHeaders = true; public readonly useReport = false; @@ -36,6 +37,7 @@ export default class Configuration { public readonly inspectors: LDInspection[] = []; public readonly privateAttributes: string[] = []; + public readonly tags: ApplicationTags; public readonly application?: { id?: string; version?: string }; public readonly bootstrap?: 'localStorage' | LDFlagSet; public readonly requestHeaderTransform?: (headers: Map) => Map; @@ -54,6 +56,7 @@ export default class Configuration { errors.forEach((e: string) => this.logger.warn(e)); this.serviceEndpoints = new ServiceEndpoints(this.streamUri, this.baseUri, this.eventsUri); + this.tags = new ApplicationTags({ application: this.application, logger: this.logger }); } validateTypesAndNames(pristineOptions: LDOptions): string[] { diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index af4a9670e..b8c7a09a6 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -27,7 +27,7 @@ const validators: Record = { allAttributesPrivate: TypeValidators.Boolean, diagnosticOptOut: TypeValidators.Boolean, - evaluationReasons: TypeValidators.Boolean, + withReasons: TypeValidators.Boolean, sendEvents: TypeValidators.Boolean, sendLDHeaders: TypeValidators.Boolean, useReport: TypeValidators.Boolean, diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts index d854d1d69..6bf267381 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts @@ -3,7 +3,7 @@ import { secondsToMillis, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; import Configuration from '../configuration'; export type DiagnosticsInitConfig = { - // dom & server common properties + // client & server common properties customBaseURI: boolean; customStreamURI: boolean; customEventsURI: boolean; @@ -14,7 +14,7 @@ export type DiagnosticsInitConfig = { streamingDisabled: boolean; allAttributesPrivate: boolean; - // dom specific properties + // client specific properties usingSecureMode: boolean; bootstrapMode: boolean; }; diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts new file mode 100644 index 000000000..4a5975af9 --- /dev/null +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts @@ -0,0 +1,92 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; +import { basicPlatform, mockFetch } from '@launchdarkly/private-js-mocks'; + +import Configuration from '../configuration'; +import fetchFlags from './fetchFlags'; +import * as mockResponse from './mockResponse.json'; +import * as mockResponseWithReasons from './mockResponseWithReasons.json'; + +describe('fetchFeatures', () => { + const sdkKey = 'testSdkKey1'; + const context: LDContext = { kind: 'user', key: 'test-user-key-1' }; + const getHeaders = { + authorization: 'testSdkKey1', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }; + const reportHeaders = { + authorization: 'testSdkKey1', + 'content-type': 'application/json', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }; + + let config: Configuration; + const platformFetch = basicPlatform.requests.fetch as jest.Mock; + + beforeEach(() => { + mockFetch(mockResponse); + config = new Configuration(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('get', async () => { + const json = await fetchFlags(sdkKey, context, config, basicPlatform); + + expect(platformFetch).toBeCalledWith( + 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9', + { + method: 'GET', + headers: getHeaders, + }, + ); + expect(json).toEqual(mockResponse); + }); + + test('report', async () => { + config = new Configuration({ useReport: true }); + const json = await fetchFlags(sdkKey, context, config, basicPlatform); + + expect(platformFetch).toBeCalledWith( + 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/context', + { + method: 'REPORT', + headers: reportHeaders, + body: '{"kind":"user","key":"test-user-key-1"}', + }, + ); + expect(json).toEqual(mockResponse); + }); + + test('withReasons', async () => { + mockFetch(mockResponseWithReasons); + config = new Configuration({ withReasons: true }); + const json = await fetchFlags(sdkKey, context, config, basicPlatform); + + expect(platformFetch).toBeCalledWith( + 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9?withReasons=true', + { + method: 'GET', + headers: getHeaders, + }, + ); + expect(json).toEqual(mockResponseWithReasons); + }); + + test('hash', async () => { + config = new Configuration({ hash: 'test-hash', withReasons: false }); + const json = await fetchFlags(sdkKey, context, config, basicPlatform); + + expect(platformFetch).toBeCalledWith( + 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9?h=test-hash', + { + method: 'GET', + headers: getHeaders, + }, + ); + expect(json).toEqual(mockResponse); + }); +}); diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts new file mode 100644 index 000000000..567055705 --- /dev/null +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts @@ -0,0 +1,31 @@ +import { LDContext, LDEvaluationReason, LDFlagValue, Platform } from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; +import { createFetchOptions, createFetchUrl } from './fetchUtils'; + +export type Flag = { + version: number; + flagVersion: number; + value: LDFlagValue; + variation: number; + trackEvents: boolean; + reason?: LDEvaluationReason; +}; + +export type Flags = { + [k: string]: Flag; +}; + +const fetchFlags = async ( + sdkKey: string, + context: LDContext, + config: Configuration, + { encoding, info, requests }: Platform, +): Promise => { + const fetchUrl = createFetchUrl(sdkKey, context, config, encoding!); + const fetchOptions = createFetchOptions(sdkKey, context, config, info); + const response = await requests.fetch(fetchUrl, fetchOptions); + return response.json(); +}; + +export default fetchFlags; diff --git a/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts b/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts new file mode 100644 index 000000000..f078c08ca --- /dev/null +++ b/packages/shared/sdk-client/src/evaluation/fetchUtils.test.ts @@ -0,0 +1,4 @@ +// TODO: +describe('fetchUtils', () => { + test('sucesss', () => {}); +}); diff --git a/packages/shared/sdk-client/src/evaluation/fetchUtils.ts b/packages/shared/sdk-client/src/evaluation/fetchUtils.ts new file mode 100644 index 000000000..ded227f76 --- /dev/null +++ b/packages/shared/sdk-client/src/evaluation/fetchUtils.ts @@ -0,0 +1,86 @@ +import { defaultHeaders, Encoding, Info, LDContext, Options } from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; + +/** + * In react-native use base64-js to polyfill btoa. This is safe + * because the react-native repo uses it too. Set the global.btoa to the encode + * function of base64-js. + * https://github.com/beatgammit/base64-js + * https://github.com/axios/axios/issues/2235#issuecomment-512204616 + * + * Ripped from https://thewoods.blog/base64url/ + */ +export const base64UrlEncode = (s: string, encoding: Encoding): string => + encoding.btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +export const createFetchPath = ( + sdkKey: string, + context: LDContext, + baseUrlPolling: string, + useReport: boolean, + encoding: Encoding, +) => + useReport + ? `${baseUrlPolling}/sdk/evalx/${sdkKey}/context` + : `${baseUrlPolling}/sdk/evalx/${sdkKey}/contexts/${base64UrlEncode( + JSON.stringify(context), + encoding, + )}`; + +export const createQueryString = (hash: string | undefined, withReasons: boolean) => { + const qs = { + h: hash, + withReasons, + }; + + const qsArray: string[] = []; + Object.entries(qs).forEach(([key, value]) => { + if (value) { + qsArray.push(`${key}=${value}`); + } + }); + + return qsArray.join('&'); +}; + +export const createFetchUrl = ( + sdkKey: string, + context: LDContext, + config: Configuration, + encoding: Encoding, +) => { + const { + withReasons, + hash, + serviceEndpoints: { polling }, + useReport, + } = config; + const path = createFetchPath(sdkKey, context, polling, useReport, encoding); + const qs = createQueryString(hash, withReasons); + + return qs ? `${path}?${qs}` : path; +}; + +export const createFetchOptions = ( + sdkKey: string, + context: LDContext, + config: Configuration, + info: Info, +): Options => { + const { useReport, tags } = config; + const headers = defaultHeaders(sdkKey, info, tags); + + if (useReport) { + return { + method: 'REPORT', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify(context), + }; + } + + return { + method: 'GET', + headers, + }; +}; diff --git a/packages/shared/sdk-client/src/evaluation/mockResponse.json b/packages/shared/sdk-client/src/evaluation/mockResponse.json new file mode 100644 index 000000000..d8f8eb5ea --- /dev/null +++ b/packages/shared/sdk-client/src/evaluation/mockResponse.json @@ -0,0 +1,58 @@ +{ + "easter-specials": { + "version": 827, + "flagVersion": 37, + "value": "no specials", + "variation": 3, + "trackEvents": false + }, + "log-level": { + "version": 827, + "flagVersion": 14, + "value": "warn", + "variation": 3, + "trackEvents": false + }, + "test1": { + "version": 827, + "flagVersion": 5, + "value": "s1", + "variation": 0, + "trackEvents": false + }, + "fdsafdsafdsafdsa": { + "version": 827, + "flagVersion": 3, + "value": true, + "variation": 0, + "trackEvents": false + }, + "easter-i-tunes-special": { + "version": 827, + "flagVersion": 15, + "value": false, + "variation": 1, + "trackEvents": false + }, + "moonshot-demo": { + "version": 827, + "flagVersion": 91, + "value": true, + "variation": 0, + "trackEvents": true + }, + "dev-test-flag": { + "version": 827, + "flagVersion": 555, + "value": true, + "variation": 0, + "trackEvents": true + }, + "this-is-a-test": { + "version": 827, + "flagVersion": 5, + "value": true, + "variation": 0, + "trackEvents": false + } +} diff --git a/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json b/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json new file mode 100644 index 000000000..0e198ad32 --- /dev/null +++ b/packages/shared/sdk-client/src/evaluation/mockResponseWithReasons.json @@ -0,0 +1,66 @@ +{ + "fdsafdsafdsafdsa": { + "version": 827, + "flagVersion": 3, + "value": true, + "variation": 0, + "trackEvents": false, + "reason": { "kind": "FALLTHROUGH" } + }, + "this-is-a-test": { + "version": 827, + "flagVersion": 5, + "value": true, + "variation": 0, + "trackEvents": false, + "reason": { "kind": "FALLTHROUGH" } + }, + "dev-test-flag": { + "version": 827, + "flagVersion": 555, + "value": true, + "variation": 0, + "trackEvents": true, + "reason": { "kind": "FALLTHROUGH" } + }, + "easter-specials": { + "version": 827, + "flagVersion": 37, + "value": "no specials", + "variation": 3, + "trackEvents": false, + "reason": { "kind": "FALLTHROUGH" } + }, + "moonshot-demo": { + "version": 827, + "flagVersion": 91, + "value": true, + "variation": 0, + "trackEvents": true, + "reason": { "kind": "FALLTHROUGH" } + }, + "test1": { + "version": 827, + "flagVersion": 5, + "value": "s1", + "variation": 0, + "trackEvents": false, + "reason": { "kind": "FALLTHROUGH" } + }, + "easter-i-tunes-special": { + "version": 827, + "flagVersion": 15, + "value": false, + "variation": 1, + "trackEvents": false, + "reason": { "kind": "FALLTHROUGH" } + }, + "log-level": { + "version": 827, + "flagVersion": 14, + "value": "warn", + "variation": 3, + "trackEvents": false, + "reason": { "kind": "OFF" } + } +} diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index 503705339..9f6b38b94 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -1,12 +1,11 @@ -import { ClientContext, internal, subsystem } from '@launchdarkly/js-sdk-common'; +import { ClientContext, internal, Platform, subsystem } from '@launchdarkly/js-sdk-common'; import Configuration from '../configuration'; -import { PlatformDom } from '../platform/PlatformDom'; const createEventProcessor = ( clientSideID: string, config: Configuration, - platform: PlatformDom, + platform: Platform, diagnosticsManager?: internal.DiagnosticsManager, ): subsystem.LDEventProcessor => config.sendEvents diff --git a/packages/shared/sdk-client/src/platform/PlatformDom.ts b/packages/shared/sdk-client/src/platform/PlatformDom.ts deleted file mode 100644 index 6e7c4f288..000000000 --- a/packages/shared/sdk-client/src/platform/PlatformDom.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Platform } from '@launchdarkly/js-sdk-common'; - -export interface Storage { - get(key: string): Promise; - - set(key: string, value: string): Promise; - - clear(): Promise; -} - -export interface PlatformDom extends Platform { - storage: Storage; -} diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json index 365daad86..a3374fce0 100644 --- a/packages/shared/sdk-client/tsconfig.json +++ b/packages/shared/sdk-client/tsconfig.json @@ -12,7 +12,9 @@ "sourceMap": true, "declaration": true, "declarationMap": true, // enables importers to jump to source - "stripInternal": true + "stripInternal": true, + "resolveJsonModule": true, + "types": ["jest", "node"] }, "include": ["src"], "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] From fc2c212097a13f1cc0f0cf193f5d25f6ce85a7ab Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 13 Oct 2023 13:13:20 -0700 Subject: [PATCH 55/57] chore: implement variation functions (#298) --- .../src/internal}/evaluation/ErrorKinds.ts | 5 - .../internal/evaluation/EventFactoryBase.ts | 85 +++++++++++ .../internal/evaluation/evaluationDetail.ts | 18 +++ .../common/src/internal/evaluation/index.ts | 11 ++ .../src/internal/events}/ClientMessages.ts | 2 - .../src/internal/events/InputEvalEvent.ts | 7 +- .../common/src/internal/events/index.ts | 2 + packages/shared/common/src/internal/index.ts | 1 + packages/shared/common/src/utils/clone.ts | 3 + packages/shared/common/src/utils/index.ts | 12 +- .../sdk-client/src/LDClientImpl.test.ts | 83 +++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 133 +++++++++++++++--- .../shared/sdk-client/src/api/LDClient.ts | 21 +-- .../sdk-client/src/evaluation/fetchFlags.ts | 4 + .../sdk-client/src/events/EventFactory.ts | 32 +++++ .../__tests__/evaluation/variations.test.ts | 5 +- .../shared/sdk-server/src/LDClientImpl.ts | 10 +- .../sdk-server/src/evaluation/EvalResult.ts | 21 +-- .../sdk-server/src/evaluation/Evaluator.ts | 7 +- .../sdk-server/src/evaluation/variations.ts | 3 +- .../sdk-server/src/events/EventFactory.ts | 76 ++-------- 21 files changed, 407 insertions(+), 134 deletions(-) rename packages/shared/{sdk-server/src => common/src/internal}/evaluation/ErrorKinds.ts (89%) create mode 100644 packages/shared/common/src/internal/evaluation/EventFactoryBase.ts create mode 100644 packages/shared/common/src/internal/evaluation/evaluationDetail.ts create mode 100644 packages/shared/common/src/internal/evaluation/index.ts rename packages/shared/{sdk-server/src => common/src/internal/events}/ClientMessages.ts (93%) create mode 100644 packages/shared/common/src/utils/clone.ts create mode 100644 packages/shared/sdk-client/src/LDClientImpl.test.ts create mode 100644 packages/shared/sdk-client/src/events/EventFactory.ts diff --git a/packages/shared/sdk-server/src/evaluation/ErrorKinds.ts b/packages/shared/common/src/internal/evaluation/ErrorKinds.ts similarity index 89% rename from packages/shared/sdk-server/src/evaluation/ErrorKinds.ts rename to packages/shared/common/src/internal/evaluation/ErrorKinds.ts index 33ba8667a..003c46b0d 100644 --- a/packages/shared/sdk-server/src/evaluation/ErrorKinds.ts +++ b/packages/shared/common/src/internal/evaluation/ErrorKinds.ts @@ -1,7 +1,5 @@ /** * Different kinds of error which may be encountered during evaluation. - * - * @internal */ enum ErrorKinds { MalformedFlag = 'MALFORMED_FLAG', @@ -11,7 +9,4 @@ enum ErrorKinds { WrongType = 'WRONG_TYPE', } -/** - * @internal - */ export default ErrorKinds; diff --git a/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts b/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts new file mode 100644 index 000000000..0b877a400 --- /dev/null +++ b/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts @@ -0,0 +1,85 @@ +import { LDEvaluationReason, LDFlagValue } from '../../api'; +import Context from '../../Context'; +import { InputCustomEvent, InputEvalEvent, InputIdentifyEvent } from '../events'; + +export type EvalEventArgs = { + addExperimentData?: boolean; + context: Context; + debugEventsUntilDate?: number; + defaultVal: any; + excludeFromSummaries?: boolean; + flagKey: string; + prereqOfFlagKey?: string; + reason?: LDEvaluationReason; + samplingRatio?: number; + trackEvents: boolean; + value: LDFlagValue; + variation?: number; + version: number; +}; + +export default class EventFactoryBase { + constructor(private readonly withReasons: boolean) {} + + evalEvent(e: EvalEventArgs): InputEvalEvent { + return new InputEvalEvent( + this.withReasons, + e.context, + e.flagKey, + e.value, + e.defaultVal, + e.version, + // Exclude null as a possibility. + e.variation ?? undefined, + e.trackEvents || e.addExperimentData, + e.prereqOfFlagKey, + this.withReasons || e.addExperimentData ? e.reason : undefined, + e.debugEventsUntilDate, + e.excludeFromSummaries, + e.samplingRatio, + ); + } + + unknownFlagEvent(key: string, defVal: LDFlagValue, context: Context) { + return new InputEvalEvent( + this.withReasons, + context, + key, + defVal, + defVal, + // This isn't ideal, but the purpose of the factory is to at least + // handle this situation. + undefined, // version + undefined, // variation index + undefined, // track events + undefined, // prereqOf + undefined, // reason + undefined, // debugEventsUntilDate + undefined, // exclude from summaries + undefined, // sampling ratio + ); + } + + /* eslint-disable-next-line class-methods-use-this */ + identifyEvent(context: Context) { + // Currently sampling for identify events is always 1. + return new InputIdentifyEvent(context, 1); + } + + /* eslint-disable-next-line class-methods-use-this */ + customEvent( + key: string, + context: Context, + data?: any, + metricValue?: number, + samplingRatio: number = 1, + ) { + return new InputCustomEvent( + context, + key, + data ?? undefined, + metricValue ?? undefined, + samplingRatio, + ); + } +} diff --git a/packages/shared/common/src/internal/evaluation/evaluationDetail.ts b/packages/shared/common/src/internal/evaluation/evaluationDetail.ts new file mode 100644 index 000000000..c08cc8687 --- /dev/null +++ b/packages/shared/common/src/internal/evaluation/evaluationDetail.ts @@ -0,0 +1,18 @@ +import { LDEvaluationReason, LDFlagValue } from '../../api'; +import ErrorKinds from './ErrorKinds'; + +export const createErrorEvaluationDetail = (errorKind: ErrorKinds, def?: LDFlagValue) => ({ + value: def ?? null, + variationIndex: null, + reason: { kind: 'ERROR', errorKind }, +}); + +export const createSuccessEvaluationDetail = ( + value: LDFlagValue, + variationIndex?: number, + reason?: LDEvaluationReason, +) => ({ + value, + variationIndex: variationIndex ?? null, + reason: reason ?? null, +}); diff --git a/packages/shared/common/src/internal/evaluation/index.ts b/packages/shared/common/src/internal/evaluation/index.ts new file mode 100644 index 000000000..175c5d4e9 --- /dev/null +++ b/packages/shared/common/src/internal/evaluation/index.ts @@ -0,0 +1,11 @@ +import ErrorKinds from './ErrorKinds'; +import { createErrorEvaluationDetail, createSuccessEvaluationDetail } from './evaluationDetail'; +import EventFactoryBase, { EvalEventArgs } from './EventFactoryBase'; + +export { + createSuccessEvaluationDetail, + createErrorEvaluationDetail, + ErrorKinds, + EvalEventArgs, + EventFactoryBase, +}; diff --git a/packages/shared/sdk-server/src/ClientMessages.ts b/packages/shared/common/src/internal/events/ClientMessages.ts similarity index 93% rename from packages/shared/sdk-server/src/ClientMessages.ts rename to packages/shared/common/src/internal/events/ClientMessages.ts index a254a7866..1d7652ba0 100644 --- a/packages/shared/sdk-server/src/ClientMessages.ts +++ b/packages/shared/common/src/internal/events/ClientMessages.ts @@ -1,7 +1,5 @@ /** * Messages for issues which can be encountered processing client requests. - * - * @internal */ export default class ClientMessages { static readonly missingContextKeyNoEvent = diff --git a/packages/shared/common/src/internal/events/InputEvalEvent.ts b/packages/shared/common/src/internal/events/InputEvalEvent.ts index ce7e24727..5c9b9377b 100644 --- a/packages/shared/common/src/internal/events/InputEvalEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvalEvent.ts @@ -1,4 +1,4 @@ -import { LDEvaluationDetail, LDEvaluationReason } from '../../api/data'; +import { LDEvaluationReason, LDFlagValue } from '../../api/data'; import Context from '../../Context'; export default class InputEvalEvent { @@ -28,8 +28,8 @@ export default class InputEvalEvent { public readonly withReasons: boolean, public readonly context: Context, public readonly key: string, + value: LDFlagValue, defValue: any, // default is a reserved keyword in this context. - detail: LDEvaluationDetail, version?: number, variation?: number, trackEvents?: boolean, @@ -40,9 +40,8 @@ export default class InputEvalEvent { public readonly samplingRatio: number = 1, ) { this.creationDate = Date.now(); + this.value = value; this.default = defValue; - this.variation = detail.variationIndex ?? undefined; - this.value = detail.value; if (version !== undefined) { this.version = version; diff --git a/packages/shared/common/src/internal/events/index.ts b/packages/shared/common/src/internal/events/index.ts index ccecc1eee..547a87ceb 100644 --- a/packages/shared/common/src/internal/events/index.ts +++ b/packages/shared/common/src/internal/events/index.ts @@ -1,3 +1,4 @@ +import ClientMessages from './ClientMessages'; import EventProcessor from './EventProcessor'; import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; @@ -8,6 +9,7 @@ import NullEventProcessor from './NullEventProcessor'; import shouldSample from './sampling'; export { + ClientMessages, InputCustomEvent, InputEvalEvent, InputEvent, diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index 241ab9c87..db6af8042 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -1,3 +1,4 @@ export * from './diagnostics'; +export * from './evaluation'; export * from './events'; export * from './stream'; diff --git a/packages/shared/common/src/utils/clone.ts b/packages/shared/common/src/utils/clone.ts new file mode 100644 index 000000000..5ae8a7abe --- /dev/null +++ b/packages/shared/common/src/utils/clone.ts @@ -0,0 +1,3 @@ +export default function clone(obj: any) { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index ea82ccfb1..86bda2eb6 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,7 +1,17 @@ +import clone from './clone'; import { secondsToMillis } from './date'; import { defaultHeaders, httpErrorMessage, LDHeaders } from './http'; import noop from './noop'; import sleep from './sleep'; import { VoidFunction } from './VoidFunction'; -export { defaultHeaders, httpErrorMessage, noop, LDHeaders, secondsToMillis, sleep, VoidFunction }; +export { + clone, + defaultHeaders, + httpErrorMessage, + noop, + LDHeaders, + secondsToMillis, + sleep, + VoidFunction, +}; diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts new file mode 100644 index 000000000..d0c36d84c --- /dev/null +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -0,0 +1,83 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + +import fetchFlags from './evaluation/fetchFlags'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import LDClientImpl from './LDClientImpl'; + +jest.mock('./evaluation/fetchFlags', () => { + const actual = jest.requireActual('./evaluation/fetchFlags'); + return { + __esModule: true, + ...actual, + default: jest.fn(), + }; +}); + +describe('sdk-client object', () => { + const testSdkKey = 'test-sdk-key'; + const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; + const mockFetchFlags = fetchFlags as jest.Mock; + + let ldc: LDClientImpl; + + beforeEach(async () => { + mockFetchFlags.mockResolvedValue(mockResponseJson); + + ldc = new LDClientImpl(testSdkKey, context, basicPlatform, {}); + await ldc.start(); + }); + + test('instantiate with blank options', () => { + expect(ldc.config).toMatchObject({ + allAttributesPrivate: false, + baseUri: 'https://sdk.launchdarkly.com', + capacity: 100, + diagnosticOptOut: false, + diagnosticRecordingInterval: 900, + eventsUri: 'https://events.launchdarkly.com', + flushInterval: 2, + inspectors: [], + logger: { + destination: expect.any(Function), + formatter: expect.any(Function), + logLevel: 1, + name: 'LaunchDarkly', + }, + privateAttributes: [], + sendEvents: true, + sendLDHeaders: true, + serviceEndpoints: { + events: 'https://events.launchdarkly.com', + polling: 'https://sdk.launchdarkly.com', + streaming: 'https://clientstream.launchdarkly.com', + }, + streamInitialReconnectDelay: 1, + streamUri: 'https://clientstream.launchdarkly.com', + tags: {}, + useReport: false, + withReasons: false, + }); + }); + + test('all flags', async () => { + const all = ldc.allFlags(); + + expect(all).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('variation', async () => { + const devTestFlag = ldc.variation('dev-test-flag'); + + expect(devTestFlag).toBe(true); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index e003f182f..d6874e10a 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -2,15 +2,19 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { + clone, Context, internal, + LDClientError, LDContext, LDEvaluationDetail, + LDEvaluationDetailTyped, LDFlagSet, LDFlagValue, LDLogger, Platform, subsystem, + TypeValidators, } from '@launchdarkly/js-sdk-common'; import { LDClient } from './api/LDClient'; @@ -20,11 +24,18 @@ import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import fetchFlags, { Flags } from './evaluation/fetchFlags'; import createEventProcessor from './events/createEventProcessor'; +import EventFactory from './events/EventFactory'; + +const { createErrorEvaluationDetail, createSuccessEvaluationDetail, ClientMessages, ErrorKinds } = + internal; export default class LDClientImpl implements LDClient { config: Configuration; diagnosticsManager?: internal.DiagnosticsManager; eventProcessor: subsystem.LDEventProcessor; + + private eventFactoryDefault = new EventFactory(false); + private eventFactoryWithReasons = new EventFactory(true); private emitter: LDEmitter; private flags: Flags = {}; private logger: LDLogger; @@ -61,6 +72,8 @@ export default class LDClientImpl implements LDClient { this.diagnosticsManager, ); this.emitter = new LDEmitter(); + + // TODO: init streamer } async start() { @@ -75,19 +88,29 @@ export default class LDClientImpl implements LDClient { } allFlags(): LDFlagSet { - return {}; + const result: LDFlagSet = {}; + Object.entries(this.flags).forEach(([k, r]) => { + result[k] = r.value; + }); + return result; } - close(onDone?: () => void): Promise { - return Promise.resolve(undefined); + async close(): Promise { + await this.flush(); + this.eventProcessor.close(); } - flush(onDone?: () => void): Promise { - return Promise.resolve(undefined); + async flush(): Promise<{ error?: Error; result: boolean }> { + try { + await this.eventProcessor.flush(); + } catch (e) { + return { error: e as Error, result: false }; + } + return { result: true }; } getContext(): LDContext { - return { kind: 'user', key: 'test-context-1' }; + return clone(this.context); } identify( @@ -95,6 +118,7 @@ export default class LDClientImpl implements LDClient { hash?: string, onDone?: (err: Error | null, flags: LDFlagSet | null) => void, ): Promise { + // TODO: return Promise.resolve({}); } @@ -106,28 +130,103 @@ export default class LDClientImpl implements LDClient { this.emitter.on(eventName, listener); } - setStreaming(value?: boolean): void {} + setStreaming(value?: boolean): void { + // TODO: + } + + track(key: string, data?: any, metricValue?: number): void { + const checkedContext = Context.fromLDContext(this.context); + + if (!checkedContext.valid) { + this.logger?.warn(ClientMessages.missingContextKeyNoEvent); + return; + } + + this.eventProcessor.sendEvent( + this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue), + ); + } + + private variationInternal( + flagKey: string, + defaultValue: any, + eventFactory: EventFactory, + typeChecker?: (value: any) => [boolean, string], + ): LDFlagValue { + const evalContext = Context.fromLDContext(this.context); + const found = this.flags[flagKey]; + + if (!found) { + const error = new LDClientError(`Unknown feature flag "${flagKey}"; returning default value`); + this.emitter.emit('error', error); + this.eventProcessor.sendEvent( + this.eventFactoryDefault.unknownFlagEvent(flagKey, defaultValue ?? null, evalContext), + ); + return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue); + } + + const { reason, value, variation } = found; + + if (typeChecker) { + const [matched, type] = typeChecker(value); + if (!matched) { + this.eventProcessor.sendEvent( + eventFactory.evalEventClient( + flagKey, + defaultValue, // track default value on type errors + defaultValue, + found, + evalContext, + reason, + ), + ); + return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue); + } + } - track(key: string, data?: any, metricValue?: number): void {} + const successDetail = createSuccessEvaluationDetail(value, variation, reason); + if (variation === undefined || variation === null) { + this.logger.debug('Result value is null in variation'); + successDetail.value = defaultValue; + } + this.eventProcessor.sendEvent( + eventFactory.evalEventClient(flagKey, value, defaultValue, found, evalContext, reason), + ); + return successDetail; + } + + variation(flagKey: string, defaultValue?: LDFlagValue): LDFlagValue { + const { value } = this.variationInternal(flagKey, defaultValue, this.eventFactoryDefault); + return value; + } + variationDetail(flagKey: string, defaultValue?: LDFlagValue): LDEvaluationDetail { + return this.variationInternal(flagKey, defaultValue, this.eventFactoryWithReasons); + } - variation(key: string, defaultValue?: LDFlagValue): LDFlagValue { - return undefined; + private typedEval( + key: string, + defaultValue: T, + eventFactory: EventFactory, + typeChecker: (value: unknown) => [boolean, string], + ): LDEvaluationDetailTyped { + return this.variationInternal(key, defaultValue, eventFactory, typeChecker); } - variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail { - const defaultDetail = { - value: defaultValue, - variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, - }; - return defaultDetail; + // TODO: add other typed variation functions + boolVariation(key: string, defaultValue: boolean): boolean { + return this.typedEval(key, defaultValue, this.eventFactoryDefault, (value) => [ + TypeValidators.Boolean.is(value), + TypeValidators.Boolean.getType(), + ]).value; } waitForInitialization(): Promise { + // TODO: return Promise.resolve(undefined); } waitUntilReady(): Promise { + // TODO: return Promise.resolve(undefined); } } diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 854cd5876..22bae74e0 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -119,16 +119,11 @@ export interface LDClient { * Normally, batches of events are delivered in the background at intervals determined by the * `flushInterval` property of {@link LDOptions}. Calling `flush()` triggers an immediate delivery. * - * @param onDone - * A function which will be called when the flush completes. If omitted, you - * will receive a Promise instead. - * * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which resolves once - * flushing is finished. Note that the Promise will be rejected if the HTTP request - * fails, so be sure to attach a rejection handler to it. + * A Promise which resolves once + * flushing is finished. You can inspect the result of the flush for errors. */ - flush(onDone?: () => void): Promise; + flush(): Promise<{ error?: Error; result: boolean }>; /** * Determines the variation of a feature flag for the current context. @@ -263,14 +258,6 @@ export interface LDClient { * Shuts down the client and releases its resources, after delivering any pending analytics * events. After the client is closed, all calls to {@link variation} will return default values, * and it will not make any requests to LaunchDarkly. - * - * @param onDone - * A function which will be called when the operation completes. If omitted, you - * will receive a Promise instead. - * - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which resolves once - * closing is finished. It will never be rejected. */ - close(onDone?: () => void): Promise; + close(): void; } diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts index 567055705..ff714ea4d 100644 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts @@ -9,7 +9,9 @@ export type Flag = { value: LDFlagValue; variation: number; trackEvents: boolean; + trackReason?: boolean; reason?: LDEvaluationReason; + debugEventsUntilDate?: number; }; export type Flags = { @@ -24,6 +26,8 @@ const fetchFlags = async ( ): Promise => { const fetchUrl = createFetchUrl(sdkKey, context, config, encoding!); const fetchOptions = createFetchOptions(sdkKey, context, config, info); + + // TODO: add error handling, retry and timeout const response = await requests.fetch(fetchUrl, fetchOptions); return response.json(); }; diff --git a/packages/shared/sdk-client/src/events/EventFactory.ts b/packages/shared/sdk-client/src/events/EventFactory.ts new file mode 100644 index 000000000..69bf4f889 --- /dev/null +++ b/packages/shared/sdk-client/src/events/EventFactory.ts @@ -0,0 +1,32 @@ +import { Context, internal, LDEvaluationReason, LDFlagValue } from '@launchdarkly/js-sdk-common'; + +import { Flag } from '../evaluation/fetchFlags'; + +/** + * @internal + */ +export default class EventFactory extends internal.EventFactoryBase { + evalEventClient( + flagKey: string, + value: LDFlagValue, + defaultVal: any, + flag: Flag, + context: Context, + reason?: LDEvaluationReason, + ): internal.InputEvalEvent { + const { trackEvents, debugEventsUntilDate, trackReason, version, variation } = flag; + + return super.evalEvent({ + addExperimentData: trackReason, + context, + debugEventsUntilDate, + defaultVal, + flagKey, + reason, + trackEvents, + value, + variation, + version, + }); + } +} diff --git a/packages/shared/sdk-server/__tests__/evaluation/variations.test.ts b/packages/shared/sdk-server/__tests__/evaluation/variations.test.ts index fed62c6d7..766f4b96c 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/variations.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/variations.test.ts @@ -1,9 +1,12 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { Flag } from '../../src/evaluation/data/Flag'; -import ErrorKinds from '../../src/evaluation/ErrorKinds'; import EvalResult from '../../src/evaluation/EvalResult'; import Reasons from '../../src/evaluation/Reasons'; import { getOffVariation, getVariation } from '../../src/evaluation/variations'; +const { ErrorKinds } = internal; + const baseFlag = { key: 'feature0', version: 1, diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index ce4267cc1..29dd1732d 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -27,7 +27,6 @@ import { import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; -import ClientMessages from './ClientMessages'; import { createStreamListeners } from './data_sources/createStreamListeners'; import DataSourceUpdates from './data_sources/DataSourceUpdates'; import PollingProcessor from './data_sources/PollingProcessor'; @@ -36,7 +35,6 @@ import createDiagnosticsInitConfig from './diagnostics/createDiagnosticsInitConf import { allAsync } from './evaluation/collection'; import { Flag } from './evaluation/data/Flag'; import { Segment } from './evaluation/data/Segment'; -import ErrorKinds from './evaluation/ErrorKinds'; import EvalResult from './evaluation/EvalResult'; import Evaluator from './evaluation/Evaluator'; import { Queries } from './evaluation/Queries'; @@ -50,7 +48,7 @@ import Configuration from './options/Configuration'; import AsyncStoreFacade from './store/AsyncStoreFacade'; import VersionedDataKinds from './store/VersionedDataKinds'; -const { NullEventProcessor } = internal; +const { ClientMessages, ErrorKinds, NullEventProcessor } = internal; enum InitState { Initializing, Initialized, @@ -611,7 +609,7 @@ export default class LDClientImpl implements LDClient { this.onError(error); const result = EvalResult.forError(ErrorKinds.FlagNotFound, undefined, defaultValue); this.eventProcessor.sendEvent( - this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), + this.eventFactoryDefault.unknownFlagEvent(flagKey, defaultValue, evalContext), ); cb(result); return; @@ -636,7 +634,7 @@ export default class LDClientImpl implements LDClient { `Did not receive expected type (${type}) evaluating feature flag "${flagKey}"`, defaultValue, ); - this.sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); + this.sendEvalEvent(errorRes, eventFactory, flag, evalContext, defaultValue); cb(errorRes, flag); return; } @@ -661,7 +659,7 @@ export default class LDClientImpl implements LDClient { this.eventProcessor.sendEvent({ ...event }); }); this.eventProcessor.sendEvent( - eventFactory.evalEvent(flag, evalContext, evalRes.detail, defaultValue, undefined), + eventFactory.evalEventServer(flag, evalContext, evalRes.detail, defaultValue, undefined), ); } diff --git a/packages/shared/sdk-server/src/evaluation/EvalResult.ts b/packages/shared/sdk-server/src/evaluation/EvalResult.ts index 01a712cd9..4ddb54543 100644 --- a/packages/shared/sdk-server/src/evaluation/EvalResult.ts +++ b/packages/shared/sdk-server/src/evaluation/EvalResult.ts @@ -1,8 +1,8 @@ import { internal, LDEvaluationDetail, LDEvaluationReason } from '@launchdarkly/js-sdk-common'; -import ErrorKinds from './ErrorKinds'; import Reasons from './Reasons'; +const { createErrorEvaluationDetail, createSuccessEvaluationDetail } = internal; /** * A class which encapsulates the result of an evaluation. It allows for differentiating between * successful and error result types. @@ -30,23 +30,12 @@ export default class EvalResult { this.detail.value = def; } - static forError(errorKind: ErrorKinds, message?: string, def?: any): EvalResult { - return new EvalResult( - true, - { - value: def ?? null, - variationIndex: null, - reason: { kind: 'ERROR', errorKind }, - }, - message, - ); + static forError(errorKind: internal.ErrorKinds, message?: string, def?: any): EvalResult { + return new EvalResult(true, createErrorEvaluationDetail(errorKind, def), message); } static forSuccess(value: any, reason: LDEvaluationReason, variationIndex?: number) { - return new EvalResult(false, { - value, - variationIndex: variationIndex === undefined ? null : variationIndex, - reason, - }); + const successDetail = createSuccessEvaluationDetail(value, variationIndex, reason); + return new EvalResult(false, successDetail as LDEvaluationDetail); } } diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 5ff2f4f8d..ef95b9eee 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -13,7 +13,6 @@ import { FlagRule } from './data/FlagRule'; import { Segment } from './data/Segment'; import { SegmentRule } from './data/SegmentRule'; import { VariationOrRollout } from './data/VariationOrRollout'; -import ErrorKinds from './ErrorKinds'; import EvalResult from './EvalResult'; import evalTargets from './evalTargets'; import makeBigSegmentRef from './makeBigSegmentRef'; @@ -23,6 +22,8 @@ import { Queries } from './Queries'; import Reasons from './Reasons'; import { getBucketBy, getOffVariation, getVariation } from './variations'; +const { ErrorKinds } = internal; + /** * PERFORMANCE NOTE: The evaluation algorithm uses callbacks instead of async/await to optimize * performance. This is most important for collections where iterating through rules/clauses @@ -271,11 +272,11 @@ export default class Evaluator { updatedVisitedFlags, (res) => { // eslint-disable-next-line no-param-reassign - state.events = state.events ?? []; + state.events ??= []; if (eventFactory) { state.events.push( - eventFactory.evalEvent(prereqFlag, context, res.detail, null, flag), + eventFactory.evalEventServer(prereqFlag, context, res.detail, null, flag), ); } diff --git a/packages/shared/sdk-server/src/evaluation/variations.ts b/packages/shared/sdk-server/src/evaluation/variations.ts index 7c048a2b2..909516230 100644 --- a/packages/shared/sdk-server/src/evaluation/variations.ts +++ b/packages/shared/sdk-server/src/evaluation/variations.ts @@ -1,13 +1,14 @@ import { AttributeReference, + internal, LDEvaluationReason, TypeValidators, } from '@launchdarkly/js-sdk-common'; import { Flag } from './data/Flag'; -import ErrorKinds from './ErrorKinds'; import EvalResult from './EvalResult'; +const { ErrorKinds } = internal; const KEY_ATTR_REF = new AttributeReference('key'); /** diff --git a/packages/shared/sdk-server/src/events/EventFactory.ts b/packages/shared/sdk-server/src/events/EventFactory.ts index b9067971c..53f9abc35 100644 --- a/packages/shared/sdk-server/src/events/EventFactory.ts +++ b/packages/shared/sdk-server/src/events/EventFactory.ts @@ -6,10 +6,8 @@ import isExperiment from './isExperiment'; /** * @internal */ -export default class EventFactory { - constructor(private readonly withReasons: boolean) {} - - evalEvent( +export default class EventFactory extends internal.EventFactoryBase { + evalEventServer( flag: Flag, context: Context, detail: LDEvaluationDetail, @@ -17,64 +15,20 @@ export default class EventFactory { prereqOfFlag?: Flag, ): internal.InputEvalEvent { const addExperimentData = isExperiment(flag, detail.reason); - return new internal.InputEvalEvent( - this.withReasons, + return super.evalEvent({ + addExperimentData, context, - flag.key, + debugEventsUntilDate: flag.debugEventsUntilDate, defaultVal, - detail, - flag.version, - // Exclude null as a possibility. - detail.variationIndex ?? undefined, - flag.trackEvents || addExperimentData, - prereqOfFlag?.key, - this.withReasons || addExperimentData ? detail.reason : undefined, - flag.debugEventsUntilDate, - flag.excludeFromSummaries, - flag.samplingRatio, - ); - } - - unknownFlagEvent(key: string, context: Context, detail: LDEvaluationDetail) { - return new internal.InputEvalEvent( - this.withReasons, - context, - key, - detail.value, - detail, - // This isn't ideal, but the purpose of the factory is to at least - // handle this situation. - undefined, // version - undefined, // variation index - undefined, // track events - undefined, // prereqOf - undefined, // reason - undefined, // debugEventsUntilDate - undefined, // exclude from summaries - undefined, // sampling ratio - ); - } - - /* eslint-disable-next-line class-methods-use-this */ - identifyEvent(context: Context) { - // Currently sampling for identify events is always 1. - return new internal.InputIdentifyEvent(context, 1); - } - - /* eslint-disable-next-line class-methods-use-this */ - customEvent( - key: string, - context: Context, - data?: any, - metricValue?: number, - samplingRatio: number = 1, - ) { - return new internal.InputCustomEvent( - context, - key, - data ?? undefined, - metricValue ?? undefined, - samplingRatio, - ); + excludeFromSummaries: flag.excludeFromSummaries, + flagKey: flag.key, + prereqOfFlagKey: prereqOfFlag?.key, + reason: detail.reason, + samplingRatio: flag.samplingRatio, + trackEvents: flag.trackEvents || addExperimentData, + value: detail.value, + variation: detail.variationIndex ?? undefined, + version: flag.version, + }); } } From f65707bf58ba46f4607fcf93898192b937ee3a59 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 13 Oct 2023 15:56:24 -0700 Subject: [PATCH 56/57] chore: remove hardcoded path in streamuri (#299) --- .../common/src/internal/stream/StreamingProcessor.test.ts | 2 ++ .../shared/common/src/internal/stream/StreamingProcessor.ts | 3 ++- packages/shared/mocks/src/streamingProcessor.ts | 1 + packages/shared/sdk-server/src/LDClientImpl.ts | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts index e6c845351..cf2e397eb 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts @@ -79,6 +79,7 @@ describe('given a stream processor with mock event source', () => { streamingProcessor = new StreamingProcessor( sdkKey, clientContext, + '/all', listeners, diagnosticsManager, mockErrorHandler, @@ -110,6 +111,7 @@ describe('given a stream processor with mock event source', () => { streamingProcessor = new StreamingProcessor( sdkKey, clientContext, + '/all', listeners, diagnosticsManager, mockErrorHandler, diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.ts index 9461174c4..5287b59c6 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.ts @@ -32,6 +32,7 @@ class StreamingProcessor implements LDStreamProcessor { constructor( sdkKey: string, clientContext: ClientContext, + streamUriPath: string, private readonly listeners: Map, private readonly diagnosticsManager?: DiagnosticsManager, private readonly errorHandler?: StreamingErrorHandler, @@ -44,7 +45,7 @@ class StreamingProcessor implements LDStreamProcessor { this.headers = defaultHeaders(sdkKey, info, tags); this.logger = logger; this.requests = requests; - this.streamUri = `${basicConfiguration.serviceEndpoints.streaming}/all`; + this.streamUri = `${basicConfiguration.serviceEndpoints.streaming}${streamUriPath}`; } private logConnectionStarted() { diff --git a/packages/shared/mocks/src/streamingProcessor.ts b/packages/shared/mocks/src/streamingProcessor.ts index 5b890fe3c..e58cbe583 100644 --- a/packages/shared/mocks/src/streamingProcessor.ts +++ b/packages/shared/mocks/src/streamingProcessor.ts @@ -13,6 +13,7 @@ export const setupMockStreamingProcessor = (shouldError: boolean = false) => { ( sdkKey: string, clientContext: ClientContext, + streamUriPath: string, listeners: Map, diagnosticsManager: internal.DiagnosticsManager, errorHandler: internal.StreamingErrorHandler, diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 29dd1732d..13ec18627 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -192,6 +192,7 @@ export default class LDClientImpl implements LDClient { ? new internal.StreamingProcessor( sdkKey, clientContext, + '/all', listeners, this.diagnosticsManager, (e) => this.dataSourceErrorHandler(e), From b7702f22929b7cfbf7fccfba3ecde23fbe75aa9c Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 18 Oct 2023 12:53:12 -0700 Subject: [PATCH 57/57] chore: draft implementation of identify --- .../sdk-client/src/LDClientImpl.test.ts | 63 +++++++++++++++---- .../shared/sdk-client/src/LDClientImpl.ts | 37 ++++++----- .../shared/sdk-client/src/api/LDClient.ts | 15 +---- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index d0c36d84c..b2e14d315 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -1,18 +1,13 @@ import { LDContext } from '@launchdarkly/js-sdk-common'; -import { basicPlatform } from '@launchdarkly/private-js-mocks'; +import { basicPlatform, logger } from '@launchdarkly/private-js-mocks'; +import LDEmitter from './api/LDEmitter'; import fetchFlags from './evaluation/fetchFlags'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; -jest.mock('./evaluation/fetchFlags', () => { - const actual = jest.requireActual('./evaluation/fetchFlags'); - return { - __esModule: true, - ...actual, - default: jest.fn(), - }; -}); +jest.mock('./api/LDEmitter'); +jest.mock('./evaluation/fetchFlags'); describe('sdk-client object', () => { const testSdkKey = 'test-sdk-key'; @@ -20,15 +15,21 @@ describe('sdk-client object', () => { const mockFetchFlags = fetchFlags as jest.Mock; let ldc: LDClientImpl; + let mockEmitter: LDEmitter; - beforeEach(async () => { + beforeEach(() => { mockFetchFlags.mockResolvedValue(mockResponseJson); - ldc = new LDClientImpl(testSdkKey, context, basicPlatform, {}); - await ldc.start(); + ldc = new LDClientImpl(testSdkKey, context, basicPlatform, { logger }); + [mockEmitter] = (LDEmitter as jest.Mock).mock.instances; + }); + + afterEach(() => { + jest.resetAllMocks(); }); test('instantiate with blank options', () => { + ldc = new LDClientImpl(testSdkKey, context, basicPlatform, {}); expect(ldc.config).toMatchObject({ allAttributesPrivate: false, baseUri: 'https://sdk.launchdarkly.com', @@ -61,6 +62,7 @@ describe('sdk-client object', () => { }); test('all flags', async () => { + await ldc.start(); const all = ldc.allFlags(); expect(all).toEqual({ @@ -76,8 +78,45 @@ describe('sdk-client object', () => { }); test('variation', async () => { + await ldc.start(); const devTestFlag = ldc.variation('dev-test-flag'); expect(devTestFlag).toBe(true); }); + + test('identify success', async () => { + mockResponseJson['dev-test-flag'].value = false; + mockFetchFlags.mockResolvedValue(mockResponseJson); + const carContext: LDContext = { kind: 'car', key: 'mazda-cx7' }; + + await ldc.identify(carContext); + const c = ldc.getContext(); + const all = ldc.allFlags(); + + expect(carContext).toEqual(c); + expect(all).toMatchObject({ + 'dev-test-flag': false, + }); + }); + + test('identify error invalid context', async () => { + // @ts-ignore + const carContext: LDContext = { kind: 'car', key: undefined }; + + await expect(ldc.identify(carContext)).rejects.toThrowError(/no key/); + expect(logger.error).toBeCalledTimes(1); + expect(mockEmitter.emit).toHaveBeenNthCalledWith(1, 'error', expect.any(Error)); + expect(ldc.getContext()).toEqual(context); + }); + + test('identify error fetch error', async () => { + // @ts-ignore + mockFetchFlags.mockRejectedValue(new Error('unknown test fetch error')); + const carContext: LDContext = { kind: 'car', key: 'mazda-3' }; + + await expect(ldc.identify(carContext)).rejects.toThrowError(/fetch error/); + expect(logger.error).toBeCalledTimes(1); + expect(mockEmitter.emit).toHaveBeenNthCalledWith(1, 'error', expect.any(Error)); + expect(ldc.getContext()).toEqual(context); + }); }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index d6874e10a..e98f491a6 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -45,7 +45,7 @@ export default class LDClientImpl implements LDClient { */ constructor( public readonly sdkKey: string, - public readonly context: LDContext, + public context: LDContext, public readonly platform: Platform, options: LDOptions, ) { @@ -53,11 +53,6 @@ export default class LDClientImpl implements LDClient { throw new Error('You must configure the client with a client-side SDK key'); } - const checkedContext = Context.fromLDContext(context); - if (!checkedContext.valid) { - throw new Error('Context was unspecified or had no key'); - } - if (!platform.encoding) { throw new Error('Platform must implement Encoding because btoa is required.'); } @@ -78,12 +73,11 @@ export default class LDClientImpl implements LDClient { async start() { try { - this.flags = await fetchFlags(this.sdkKey, this.context, this.config, this.platform); + await this.identify(this.context); this.emitter.emit('ready'); } catch (error: any) { - this.logger.error(error); - this.emitter.emit('error', error); this.emitter.emit('failed', error); + throw error; } } @@ -113,13 +107,24 @@ export default class LDClientImpl implements LDClient { return clone(this.context); } - identify( - context: LDContext, - hash?: string, - onDone?: (err: Error | null, flags: LDFlagSet | null) => void, - ): Promise { - // TODO: - return Promise.resolve({}); + // TODO: implement secure mode + async identify(context: LDContext, hash?: string): Promise { + const checkedContext = Context.fromLDContext(context); + if (!checkedContext.valid) { + const error = new Error('Context was unspecified or had no key'); + this.logger.error(error); + this.emitter.emit('error', error); + throw error; + } + + try { + this.flags = await fetchFlags(this.sdkKey, context, this.config, this.platform); + this.context = context; + } catch (error: any) { + this.logger.error(error); + this.emitter.emit('error', error); + throw error; + } } off(eventName: EventName, listener?: Function): void { diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 22bae74e0..5a9c3e4b7 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -89,21 +89,10 @@ export interface LDClient { * The context properties. Must contain at least the `key` property. * @param hash * The signed context key if you are using [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). - * @param onDone - * A function which will be called as soon as the flag values for the new context are available, - * with two parameters: an error value (if any), and an {@link LDFlagSet} containing the new values - * (which can also be obtained by calling {@link variation}). If the callback is omitted, you will - * receive a Promise instead. * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which resolve once the flag - * values for the new context are available, providing an {@link LDFlagSet} containing the new values - * (which can also be obtained by calling {@link variation}). + * A Promise which resolve once the flag values for the new context are available. */ - identify( - context: LDContext, - hash?: string, - onDone?: (err: Error | null, flags: LDFlagSet | null) => void, - ): Promise; + identify(context: LDContext, hash?: string): Promise; /** * Returns the client's current context.