diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 3b16893..4c45778 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -6,7 +6,8 @@ repo: branches: - name: main - description: 4.x + description: 5.x + - name: 4.x - name: 3.x publications: diff --git a/package.json b/package.json index afc6689..c44940d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "lint": "eslint --format 'node_modules/eslint-formatter-pretty' --ignore-path .eslintignore", "lint:all": "eslint --format 'node_modules/eslint-formatter-pretty' --ignore-path .eslintignore src", + "lint-fix:all": "eslint --fix --format 'node_modules/eslint-formatter-pretty' --ignore-path .eslintignore src", "format": "npm run format:md && npm run format:js", "format:md": "prettier --parser markdown --ignore-path .prettierignore --write '*.md'", "format:js": "prettier --ignore-path .prettierignore --write 'src/**/*.js'", @@ -26,6 +27,7 @@ "@babel/preset-env": "^7.6.3", "@babel/runtime": "7.6.3", "@rollup/plugin-replace": "^2.2.0", + "@types/jest": "^27.4.1", "babel-eslint": "^10.1.0", "babel-jest": "^25.1.0", "cross-env": "^5.1.4", diff --git a/src/AnonymousContextProcessor.js b/src/AnonymousContextProcessor.js new file mode 100644 index 0000000..8223b5c --- /dev/null +++ b/src/AnonymousContextProcessor.js @@ -0,0 +1,95 @@ +const { v1: uuidv1 } = require('uuid'); +const { getContextKinds } = require('./context'); + +const errors = require('./errors'); +const messages = require('./messages'); +const utils = require('./utils'); + +const ldUserIdKey = 'ld:$anonUserId'; + +/** + * Create an object which can process a context and populate any required keys + * for anonymous objects. + * + * @param {Object} persistentStorage The persistent storage from which to store + * and access persisted anonymous context keys. + * @returns An AnonymousContextProcessor. + */ +function AnonymousContextProcessor(persistentStorage) { + function getContextKeyIdString(kind) { + if (kind === undefined || kind === null || kind === 'user') { + return ldUserIdKey; + } + return `ld:$contextKey:${kind}`; + } + + function getCachedContextKey(kind) { + return persistentStorage.get(getContextKeyIdString(kind)); + } + + function setCachedContextKey(id, kind) { + return persistentStorage.set(getContextKeyIdString(kind), id); + } + + /** + * Process a single kind context, or a single context within a multi-kind context. + * @param {string} kind The kind of the context. Independent because the kind is not prevent + * within a context in a multi-kind context. + * @param {Object} context + * @returns {Promise} a promise that resolves to a processed contexts, or rejects + * a context which cannot be processed. + */ + function processSingleKindContext(kind, context) { + // We are working on a copy of an original context, so we want to re-assign + // versus duplicating it again. + + /* eslint-disable no-param-reassign */ + if (context.key !== null && context.key !== undefined) { + context.key = context.key.toString(); + return Promise.resolve(context); + } + + if (context.anonymous) { + // If the key doesn't exist, then the persistent storage will resolve + // with undefined. + return getCachedContextKey(kind).then(cachedId => { + if (cachedId) { + context.key = cachedId; + return context; + } else { + const id = uuidv1(); + context.key = id; + return setCachedContextKey(id, kind).then(() => context); + } + }); + } else { + return Promise.reject(new errors.LDInvalidUserError(messages.invalidContext())); + } + /* eslint-enable no-param-reassign */ + } + + /** + * Process the context, returning a Promise that resolves to the processed context, or rejects if there is an error. + * @param {Object} context + * @returns {Promise} A promise which resolves to a processed context, or a rejection if the context cannot be + * processed. The context should still be checked for overall validity after being processed. + */ + this.processContext = context => { + if (!context) { + return Promise.reject(new errors.LDInvalidUserError(messages.contextNotSpecified())); + } + + const processedContext = utils.clone(context); + + if (context.kind === 'multi') { + const kinds = getContextKinds(processedContext); + + return Promise.all(kinds.map(kind => processSingleKindContext(kind, processedContext[kind]))).then( + () => processedContext + ); + } + return processSingleKindContext(context.kind, processedContext); + }; +} + +module.exports = AnonymousContextProcessor; diff --git a/src/ContextFilter.js b/src/ContextFilter.js new file mode 100644 index 0000000..e4f56e1 --- /dev/null +++ b/src/ContextFilter.js @@ -0,0 +1,137 @@ +const AttributeReference = require('./attributeReference'); + +function ContextFilter(config) { + const filter = {}; + + const allAttributesPrivate = config.allAttributesPrivate; + const privateAttributes = config.privateAttributes || []; + + // These attributes cannot be removed via a private attribute. + const protectedAttributes = ['key', 'kind', '_meta', 'anonymous']; + + const legacyTopLevelCopyAttributes = ['name', 'ip', 'firstName', 'lastName', 'email', 'avatar', 'country']; + + /** + * For the given context and configuration get a list of attributes to filter. + * @param {Object} context + * @returns {string[]} A list of the attributes to filter. + */ + const getAttributesToFilter = context => + (allAttributesPrivate + ? Object.keys(context) + : [...privateAttributes, ...((context._meta && context._meta.privateAttributes) || [])] + ).filter(attr => !protectedAttributes.some(protectedAttr => AttributeReference.compare(attr, protectedAttr))); + + /** + * @param {Object} context + * @returns {Object} A copy of the context with private attributes removed, + * and the redactedAttributes meta populated. + */ + const filterSingleKind = context => { + if (typeof context !== 'object' || context === null || Array.isArray(context)) { + return undefined; + } + + const { cloned, excluded } = AttributeReference.cloneExcluding(context, getAttributesToFilter(context)); + cloned.key = String(cloned.key); + if (excluded.length) { + if (!cloned._meta) { + cloned._meta = {}; + } + cloned._meta.redactedAttributes = excluded; + } + if (cloned._meta) { + delete cloned._meta['privateAttributes']; + if (Object.keys(cloned._meta).length === 0) { + delete cloned._meta; + } + } + // Make sure anonymous is boolean if present. + // Null counts as present, and would be falsy, which is the default. + if (cloned.anonymous !== undefined) { + cloned.anonymous = !!cloned.anonymous; + } + + return cloned; + }; + + /** + * @param {Object} context + * @returns {Object} A copy of the context with the private attributes removed, + * and the redactedAttributes meta populated for each sub-context. + */ + const filterMultiKind = context => { + const filtered = { + kind: context.kind, + }; + const contextKeys = Object.keys(context); + + for (const contextKey of contextKeys) { + if (contextKey !== 'kind') { + const filteredContext = filterSingleKind(context[contextKey]); + if (filteredContext) { + filtered[contextKey] = filteredContext; + } + } + } + return filtered; + }; + + /** + * Convert the LDUser object into an LDContext object. + * @param {Object} user The LDUser to produce an LDContext for. + * @returns {Object} A single kind context based on the provided user. + */ + const legacyToSingleKind = user => { + const filtered = { + /* Destructure custom items into the top level. + Duplicate keys will be overridden by previously + top level items. + */ + ...(user.custom || {}), + + // Implicity a user kind. + kind: 'user', + + key: user.key, + }; + + if (user.anonymous !== undefined) { + filtered.anonymous = !!user.anonymous; + } + + // Copy top level keys and convert them to strings. + // Remove keys that may have been destructured from `custom`. + for (const key of legacyTopLevelCopyAttributes) { + delete filtered[key]; + if (user[key] !== undefined && user[key] !== null) { + filtered[key] = String(user[key]); + } + } + + if (user.privateAttributeNames !== undefined && user.privateAttributeNames !== null) { + filtered._meta = filtered._meta || {}; + // If any private attributes started with '/' we need to convert them to references, otherwise the '/' will + // cause the literal to incorrectly be treated as a reference. + filtered._meta.privateAttributes = user.privateAttributeNames.map( + literal => (literal.startsWith('/') ? AttributeReference.literalToReference(literal) : literal) + ); + } + + return filtered; + }; + + filter.filter = context => { + if (context.kind === undefined || context.kind === null) { + return filterSingleKind(legacyToSingleKind(context)); + } else if (context.kind === 'multi') { + return filterMultiKind(context); + } else { + return filterSingleKind(context); + } + }; + + return filter; +} + +module.exports = ContextFilter; diff --git a/src/EventProcessor.js b/src/EventProcessor.js index c5d0946..f1afd49 100644 --- a/src/EventProcessor.js +++ b/src/EventProcessor.js @@ -1,9 +1,10 @@ const EventSender = require('./EventSender'); const EventSummarizer = require('./EventSummarizer'); -const UserFilter = require('./UserFilter'); +const ContextFilter = require('./ContextFilter'); const errors = require('./errors'); const messages = require('./messages'); const utils = require('./utils'); +const { getContextKeys } = require('./context'); function EventProcessor( platform, @@ -17,8 +18,7 @@ function EventProcessor( const eventSender = sender || EventSender(platform, environmentId, options); const mainEventsUrl = utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + environmentId); const summarizer = EventSummarizer(); - const userFilter = UserFilter(options); - const inlineUsers = options.inlineUsersInEvents; + const contextFilter = ContextFilter(options); const samplingInterval = options.samplingInterval; const eventCapacity = options.eventCapacity; const flushInterval = options.flushInterval; @@ -47,16 +47,12 @@ function EventProcessor( // Transform an event from its internal format to the format we use when sending a payload. function makeOutputEvent(e) { const ret = utils.extend({}, e); - if (e.kind === 'alias') { - // alias events do not require any transformation - return ret; - } - if (inlineUsers || e.kind === 'identify') { - // identify events always have an inline user - ret.user = userFilter.filterUser(e.user); + if (e.kind === 'identify') { + // identify events always have an inline context + ret.context = contextFilter.filter(e.context); } else { - ret.userKey = e.user.key; - delete ret['user']; + ret.contextKeys = getContextKeysFromEvent(e); + delete ret['context']; } if (e.kind === 'feature') { delete ret['trackEvents']; @@ -65,6 +61,10 @@ function EventProcessor( return ret; } + function getContextKeysFromEvent(event) { + return getContextKeys(event.context, logger); + } + function addToOutbox(event) { if (queue.length < eventCapacity) { queue.push(event); @@ -107,7 +107,7 @@ function EventProcessor( } if (addDebugEvent) { const debugEvent = utils.extend({}, event, { kind: 'debug' }); - debugEvent.user = userFilter.filterUser(debugEvent.user); + debugEvent.context = contextFilter.filter(debugEvent.context); delete debugEvent['trackEvents']; delete debugEvent['debugEventsUntilDate']; addToOutbox(debugEvent); @@ -136,7 +136,8 @@ function EventProcessor( } queue = []; logger.debug(messages.debugPostingEvents(eventsToSend.length)); - return eventSender.sendEvents(eventsToSend, mainEventsUrl).then(responseInfo => { + return eventSender.sendEvents(eventsToSend, mainEventsUrl).then(responses => { + const responseInfo = responses && responses[0]; if (responseInfo) { if (responseInfo.serverTime) { lastKnownPastTime = responseInfo.serverTime; diff --git a/src/EventSender.js b/src/EventSender.js index 01f117a..ac5300e 100644 --- a/src/EventSender.js +++ b/src/EventSender.js @@ -31,7 +31,7 @@ function EventSender(platform, environmentId, options) { const headers = isDiagnostic ? baseHeaders : utils.extend({}, baseHeaders, { - 'X-LaunchDarkly-Event-Schema': '3', + 'X-LaunchDarkly-Event-Schema': '4', 'X-LaunchDarkly-Payload-ID': payloadId, }); return platform @@ -73,7 +73,7 @@ function EventSender(platform, environmentId, options) { // no need to break up events into chunks if we can send a POST chunks = [events]; } else { - chunks = utils.chunkUserEventsForUrl(MAX_URL_LENGTH - url.length, events); + chunks = utils.chunkEventsForUrl(MAX_URL_LENGTH - url.length, events); } const results = []; for (let i = 0; i < chunks.length; i++) { diff --git a/src/EventSummarizer.js b/src/EventSummarizer.js index 57bf577..4c1033c 100644 --- a/src/EventSummarizer.js +++ b/src/EventSummarizer.js @@ -1,11 +1,24 @@ +const { getContextKinds } = require('./context'); + +function getKinds(event) { + if (event.context) { + return getContextKinds(event.context); + } + if (event.contextKeys) { + return Object.keys(event.contextKeys); + } + return []; +} + function EventSummarizer() { const es = {}; let startDate = 0, endDate = 0, - counters = {}; + counters = {}, + contextKinds = {}; - es.summarizeEvent = function(event) { + es.summarizeEvent = event => { if (event.kind === 'feature') { const counterKey = event.key + @@ -14,14 +27,21 @@ function EventSummarizer() { ':' + (event.version !== null && event.version !== undefined ? event.version : ''); const counterVal = counters[counterKey]; + let kinds = contextKinds[event.key]; + if (!kinds) { + kinds = new Set(); + contextKinds[event.key] = kinds; + } + getKinds(event).forEach(kind => kinds.add(kind)); + if (counterVal) { counterVal.count = counterVal.count + 1; } else { counters[counterKey] = { count: 1, key: event.key, - variation: event.variation, version: event.version, + variation: event.variation, value: event.value, default: event.default, }; @@ -35,16 +55,16 @@ function EventSummarizer() { } }; - es.getSummary = function() { + es.getSummary = () => { const flagsOut = {}; let empty = true; - for (const i in counters) { - const c = counters[i]; + for (const c of Object.values(counters)) { let flag = flagsOut[c.key]; if (!flag) { flag = { default: c.default, counters: [], + contextKinds: [...contextKinds[c.key]], }; flagsOut[c.key] = flag; } @@ -55,7 +75,7 @@ function EventSummarizer() { if (c.variation !== undefined && c.variation !== null) { counterOut.variation = c.variation; } - if (c.version) { + if (c.version !== undefined && c.version !== null) { counterOut.version = c.version; } else { counterOut.unknown = true; @@ -72,10 +92,11 @@ function EventSummarizer() { }; }; - es.clearSummary = function() { + es.clearSummary = () => { startDate = 0; endDate = 0; counters = {}; + contextKinds = {}; }; return es; diff --git a/src/Identity.js b/src/Identity.js index e80368c..9a7eef9 100644 --- a/src/Identity.js +++ b/src/Identity.js @@ -1,23 +1,22 @@ const utils = require('./utils'); -function Identity(initialUser, onChange) { +function Identity(initialContext, onChange) { const ident = {}; - let user; + let context; - ident.setUser = function(u) { - const previousUser = user && utils.clone(user); - user = utils.sanitizeUser(u); - if (user && onChange) { - onChange(utils.clone(user), previousUser); + ident.setContext = function(c) { + context = utils.sanitizeContext(c); + if (context && onChange) { + onChange(utils.clone(context)); } }; - ident.getUser = function() { - return user ? utils.clone(user) : null; + ident.getContext = function() { + return context ? utils.clone(context) : null; }; - if (initialUser) { - ident.setUser(initialUser); + if (initialContext) { + ident.setContext(initialContext); } return ident; diff --git a/src/InspectorManager.js b/src/InspectorManager.js index 1ec53e9..72585cb 100644 --- a/src/InspectorManager.js +++ b/src/InspectorManager.js @@ -1,4 +1,4 @@ -const { messages } = require('.'); +const messages = require('./messages'); const SafeInspector = require('./SafeInspector'); const { onNextTick } = require('./utils'); @@ -57,12 +57,12 @@ function InspectorManager(inspectors, logger) { * * @param {string} flagKey The key for the flag. * @param {Object} detail The LDEvaluationDetail for the flag. - * @param {Object} user The LDUser for the flag. + * @param {Object} context The LDContext for the flag. */ - manager.onFlagUsed = (flagKey, detail, user) => { + manager.onFlagUsed = (flagKey, detail, context) => { if (inspectorsByType[InspectorTypes.flagUsed].length) { onNextTick(() => { - inspectorsByType[InspectorTypes.flagUsed].forEach(inspector => inspector.method(flagKey, detail, user)); + inspectorsByType[InspectorTypes.flagUsed].forEach(inspector => inspector.method(flagKey, detail, context)); }); } }; @@ -99,16 +99,16 @@ function InspectorManager(inspectors, logger) { }; /** - * Notify the registered inspectors that the user identity has changed. + * Notify the registered inspectors that the context identity has changed. * * The notification itself will be dispatched asynchronously. * - * @param {Object} user The `LDUser` which is now identified. + * @param {Object} context The `LDContext` which is now identified. */ - manager.onIdentityChanged = user => { + manager.onIdentityChanged = context => { if (inspectorsByType[InspectorTypes.clientIdentityChanged].length) { onNextTick(() => { - inspectorsByType[InspectorTypes.clientIdentityChanged].forEach(inspector => inspector.method(user)); + inspectorsByType[InspectorTypes.clientIdentityChanged].forEach(inspector => inspector.method(context)); }); } }; diff --git a/src/PersistentFlagStore.js b/src/PersistentFlagStore.js index 7e33bd0..05d31b6 100644 --- a/src/PersistentFlagStore.js +++ b/src/PersistentFlagStore.js @@ -5,9 +5,9 @@ function PersistentFlagStore(storage, environment, hash, ident) { function getFlagsKey() { let key = ''; - const user = ident.getUser(); - if (user) { - key = hash || utils.btoa(JSON.stringify(user)); + const context = ident.getContext(); + if (context) { + key = hash || utils.btoa(JSON.stringify(context)); } return 'ld:' + environment + ':' + key; } diff --git a/src/Requestor.js b/src/Requestor.js index b8beb40..0507a4d 100644 --- a/src/Requestor.js +++ b/src/Requestor.js @@ -79,20 +79,20 @@ function Requestor(platform, options, environment) { return fetchJSON(utils.appendUrlPath(baseUrl, path), null); }; - // Requests the current state of all flags for the given user from LaunchDarkly. Returns a Promise + // Requests the current state of all flags for the given context from LaunchDarkly. Returns a Promise // which will resolve with the parsed JSON response, or will be rejected if the request failed. - requestor.fetchFlagSettings = function(user, hash) { + requestor.fetchFlagSettings = function(context, hash) { let data; let endpoint; let query = ''; let body; if (useReport) { - endpoint = [baseUrl, '/sdk/evalx/', environment, '/user'].join(''); - body = JSON.stringify(user); + endpoint = [baseUrl, '/sdk/evalx/', environment, '/context'].join(''); + body = JSON.stringify(context); } else { - data = utils.base64URLEncode(JSON.stringify(user)); - endpoint = [baseUrl, '/sdk/evalx/', environment, '/users/', data].join(''); + data = utils.base64URLEncode(JSON.stringify(context)); + endpoint = [baseUrl, '/sdk/evalx/', environment, '/contexts/', data].join(''); } if (hash) { query = 'h=' + hash; diff --git a/src/SafeInspector.js b/src/SafeInspector.js index d41e9ce..59274bd 100644 --- a/src/SafeInspector.js +++ b/src/SafeInspector.js @@ -1,4 +1,4 @@ -const { messages } = require('.'); +const messages = require('./messages'); /** * Wrap an inspector ensuring that calling its methods are safe. diff --git a/src/Stream.js b/src/Stream.js index 75eed23..16df5fb 100644 --- a/src/Stream.js +++ b/src/Stream.js @@ -33,7 +33,7 @@ function Stream(platform, config, environment, diagnosticsAccumulator) { let es = null; let reconnectTimeoutReference = null; let connectionAttemptStartTime; - let user = null; + let context = null; let hash = null; let handlers = null; let retryCount = 0; @@ -53,8 +53,8 @@ function Stream(platform, config, environment, diagnosticsAccumulator) { return delay; } - stream.connect = function(newUser, newHash, newHandlers) { - user = newUser; + stream.connect = function(newContext, newHash, newHandlers) { + context = newContext; hash = newHash; handlers = {}; for (const key in newHandlers || {}) { @@ -133,14 +133,14 @@ function Stream(platform, config, environment, diagnosticsAccumulator) { url = evalUrlPrefix; options.method = 'REPORT'; options.headers['Content-Type'] = 'application/json'; - options.body = JSON.stringify(user); + options.body = JSON.stringify(context); } else { // if we can't do REPORT, fall back to the old ping-based stream url = appendUrlPath(baseUrl, '/ping/' + environment); query = ''; } } else { - url = evalUrlPrefix + '/' + base64URLEncode(JSON.stringify(user)); + url = evalUrlPrefix + '/' + base64URLEncode(JSON.stringify(context)); } options.headers = transformHeaders(options.headers, config); if (withReasons) { diff --git a/src/UserFilter.js b/src/UserFilter.js deleted file mode 100644 index c2b88bf..0000000 --- a/src/UserFilter.js +++ /dev/null @@ -1,75 +0,0 @@ -const utils = require('./utils'); - -/** - * The UserFilter object transforms user objects into objects suitable to be sent as JSON to - * the server, hiding any private user attributes. - * - * @param {Object} the LaunchDarkly client configuration object - **/ -function UserFilter(config) { - const filter = {}; - const allAttributesPrivate = config.allAttributesPrivate; - const privateAttributeNames = config.privateAttributeNames || []; - const ignoreAttrs = { key: true, custom: true, anonymous: true }; - const allowedTopLevelAttrs = { - key: true, - secondary: true, - ip: true, - country: true, - email: true, - firstName: true, - lastName: true, - avatar: true, - name: true, - anonymous: true, - custom: true, - }; - - filter.filterUser = function(user) { - if (!user) { - return null; - } - const userPrivateAttrs = user.privateAttributeNames || []; - - const isPrivateAttr = function(name) { - return ( - !ignoreAttrs[name] && - (allAttributesPrivate || userPrivateAttrs.indexOf(name) !== -1 || privateAttributeNames.indexOf(name) !== -1) - ); - }; - const filterAttrs = function(props, isAttributeAllowed) { - return Object.keys(props).reduce( - (acc, name) => { - const ret = acc; - if (isAttributeAllowed(name)) { - if (isPrivateAttr(name)) { - // add to hidden list - ret[1][name] = true; - } else { - ret[0][name] = props[name]; - } - } - return ret; - }, - [{}, {}] - ); - }; - const result = filterAttrs(user, key => allowedTopLevelAttrs[key]); - const filteredProps = result[0]; - let removedAttrs = result[1]; - if (user.custom) { - const customResult = filterAttrs(user.custom, () => true); - filteredProps.custom = customResult[0]; - removedAttrs = utils.extend({}, removedAttrs, customResult[1]); - } - const removedAttrNames = Object.keys(removedAttrs); - if (removedAttrNames.length) { - removedAttrNames.sort(); - filteredProps.privateAttrs = removedAttrNames; - } - return filteredProps; - }; - return filter; -} - -module.exports = UserFilter; diff --git a/src/UserValidator.js b/src/UserValidator.js deleted file mode 100644 index cdacc8a..0000000 --- a/src/UserValidator.js +++ /dev/null @@ -1,56 +0,0 @@ -const { v1: uuidv1 } = require('uuid'); - -const errors = require('./errors'); -const messages = require('./messages'); -const utils = require('./utils'); - -// Transforms the user object if necessary to make sure it has a valid key. -// 1. If a key is present, but is not a string, change it to a string. -// 2. If no key is present, and "anonymous" is true, use a UUID as a key. This is cached in local -// storage if possible. -// 3. If there is no key (or no user object), return an error. - -const ldUserIdKey = 'ld:$anonUserId'; - -function UserValidator(persistentStorage) { - function getCachedUserId() { - return persistentStorage.get(ldUserIdKey); - } - - function setCachedUserId(id) { - return persistentStorage.set(ldUserIdKey, id); - } - - const ret = {}; - - // Validates the user, returning a Promise that resolves to the validated user, or rejects if there is an error. - ret.validateUser = user => { - if (!user) { - return Promise.reject(new errors.LDInvalidUserError(messages.userNotSpecified())); - } - - const userOut = utils.clone(user); - if (userOut.key !== null && userOut.key !== undefined) { - userOut.key = userOut.key.toString(); - return Promise.resolve(userOut); - } - if (userOut.anonymous) { - return getCachedUserId().then(cachedId => { - if (cachedId) { - userOut.key = cachedId; - return userOut; - } else { - const id = uuidv1(); - userOut.key = id; - return setCachedUserId(id).then(() => userOut); - } - }); - } else { - return Promise.reject(new errors.LDInvalidUserError(messages.invalidUser())); - } - }; - - return ret; -} - -module.exports = UserValidator; diff --git a/src/__tests__/ContextFilter-test.js b/src/__tests__/ContextFilter-test.js new file mode 100644 index 0000000..e66f174 --- /dev/null +++ b/src/__tests__/ContextFilter-test.js @@ -0,0 +1,427 @@ +const ContextFilter = require('../ContextFilter'); + +describe('when handling legacy user contexts', () => { + // users to serialize + const user = { + key: 'abc', + firstName: 'Sue', + custom: { bizzle: 'def', dizzle: 'ghi' }, + }; + + const userSpecifyingOwnPrivateAttr = { + key: 'abc', + firstName: 'Sue', + custom: { bizzle: 'def', dizzle: 'ghi' }, + privateAttributeNames: ['dizzle', 'unused'], + }; + + const userWithUnknownTopLevelAttrs = { + key: 'abc', + firstName: 'Sue', + species: 'human', + hatSize: 6, + custom: { bizzle: 'def', dizzle: 'ghi' }, + }; + + const anonUser = { + key: 'abc', + anonymous: true, + custom: { bizzle: 'def', dizzle: 'ghi' }, + }; + + const userWithNonStringsInStringRequiredFields = { + key: -1, + name: 0, + ip: 1, + firstName: 2, + lastName: ['a', 99, null], + email: 4, + avatar: 5, + country: 6, + custom: { + validNumericField: 7, + }, + }; + + // expected results from serializing user + const userWithNothingHidden = { + bizzle: 'def', + dizzle: 'ghi', + firstName: 'Sue', + key: 'abc', + kind: 'user', + }; + + const userWithAllAttrsHidden = { + kind: 'user', + key: 'abc', + _meta: { + redactedAttributes: ['/bizzle', '/dizzle', '/firstName'], + }, + }; + + const userWithSomeAttrsHidden = { + kind: 'user', + key: 'abc', + dizzle: 'ghi', + _meta: { + redactedAttributes: ['/bizzle', '/firstName'], + }, + }; + + const userWithOwnSpecifiedAttrHidden = { + kind: 'user', + key: 'abc', + firstName: 'Sue', + bizzle: 'def', + _meta: { + redactedAttributes: ['/dizzle'], + }, + }; + + const anonUserWithAllAttrsHidden = { + kind: 'user', + key: 'abc', + anonymous: true, + _meta: { + redactedAttributes: ['/bizzle', '/dizzle'], + }, + }; + + const userWithStringFieldsConverted = { + key: '-1', + kind: 'user', + name: '0', + ip: '1', + firstName: '2', + lastName: 'a,99,', + email: '4', + avatar: '5', + country: '6', + validNumericField: 7, + }; + + const userWithPrivateFieldsWithAPrecedingSlash = { + key: 'annoying', + custom: { + '/why': 'not', + why: 'because', + }, + privateAttributeNames: ['/why'], + }; + + const userWithPrivateFieldsWithAPrecedingSlashFiltered = { + kind: 'user', + key: 'annoying', + why: 'because', + _meta: { + redactedAttributes: ['/~1why'], + }, + }; + + it('includes all user attributes by default', () => { + const uf = ContextFilter({}); + expect(uf.filter(user)).toEqual(userWithNothingHidden); + }); + + it('hides all except key if allAttributesPrivate is true', () => { + const uf = ContextFilter({ allAttributesPrivate: true }); + expect(uf.filter(user)).toEqual(userWithAllAttrsHidden); + }); + + it('hides some attributes if privateAttributes is set', () => { + const uf = ContextFilter({ privateAttributes: ['firstName', 'bizzle'] }); + expect(uf.filter(user)).toEqual(userWithSomeAttrsHidden); + }); + + it('hides attributes specified in per-user redactedAttributes', () => { + const uf = ContextFilter({}); + expect(uf.filter(userSpecifyingOwnPrivateAttr)).toEqual(userWithOwnSpecifiedAttrHidden); + }); + + it('looks at both per-user redactedAttributes and global config', () => { + const uf = ContextFilter({ privateAttributes: ['firstName', 'bizzle'] }); + expect(uf.filter(userSpecifyingOwnPrivateAttr)).toEqual(userWithAllAttrsHidden); + }); + + it('strips unknown top-level attributes', () => { + const uf = ContextFilter({}); + expect(uf.filter(userWithUnknownTopLevelAttrs)).toEqual(userWithNothingHidden); + }); + + it('maintains anonymous in conversion to a single kind context', () => { + const uf = ContextFilter({ allAttributesPrivate: true }); + expect(uf.filter(anonUser)).toEqual(anonUserWithAllAttrsHidden); + }); + + it('converts non-boolean "anonymous" to boolean "anonymous"', () => { + const uf = ContextFilter({ allAttributesPrivate: true }); + expect(uf.filter({ key: 'user', anonymous: 'yes' })).toEqual({ key: 'user', kind: 'user', anonymous: true }); + }); + + it('converts fields to string types when needed', () => { + const uf = ContextFilter({}); + expect(uf.filter(userWithNonStringsInStringRequiredFields)).toEqual(userWithStringFieldsConverted); + }); + + it('it handles legacy names which had a preceding slash', () => { + const uf = ContextFilter({}); + expect(uf.filter(userWithPrivateFieldsWithAPrecedingSlash)).toEqual( + userWithPrivateFieldsWithAPrecedingSlashFiltered + ); + }); + + it.each([null, undefined])('handles null and undefined the same for built-in attributes', value => { + const cf = ContextFilter({}); + const user = { + key: 'userKey', + name: value, + ip: value, + firstName: value, + lastName: value, + email: value, + avatar: value, + country: value, + }; + expect(cf.filter(user)).toEqual({ key: 'userKey', kind: 'user' }); + }); +}); + +describe('when handling single kind contexts', () => { + // users to serialize + const context = { + kind: 'organization', + key: 'abc', + firstName: 'Sue', + bizzle: 'def', + dizzle: 'ghi', + }; + + const contextSpecifyingOwnPrivateAttr = { + kind: 'organization', + key: 'abc', + firstName: 'Sue', + bizzle: 'def', + dizzle: 'ghi', + _meta: { + privateAttributes: ['dizzle', 'unused'], + }, + }; + + const anonymousContext = { + kind: 'organization', + key: 'abc', + anonymous: true, + bizzle: 'def', + dizzle: 'ghi', + }; + + // expected results from serializing context + const userWithAllAttrsHidden = { + kind: 'organization', + key: 'abc', + _meta: { + redactedAttributes: ['/bizzle', '/dizzle', '/firstName'], + }, + }; + + const contextWithSomeAttrsHidden = { + kind: 'organization', + key: 'abc', + dizzle: 'ghi', + _meta: { + redactedAttributes: ['/bizzle', '/firstName'], + }, + }; + + const contextWithOwnSpecifiedAttrHidden = { + kind: 'organization', + key: 'abc', + firstName: 'Sue', + bizzle: 'def', + _meta: { + redactedAttributes: ['/dizzle'], + }, + }; + + const contextWithAllAttrsHidden = { + kind: 'organization', + key: 'abc', + anonymous: true, + _meta: { + redactedAttributes: ['/bizzle', '/dizzle'], + }, + }; + + it('includes all attributes by default', () => { + const uf = ContextFilter({}); + expect(uf.filter(context)).toEqual(context); + }); + + it('hides all except key if allAttributesPrivate is true', () => { + const uf = ContextFilter({ allAttributesPrivate: true }); + expect(uf.filter(context)).toEqual(userWithAllAttrsHidden); + }); + + it('hides some attributes if privateAttributes is set', () => { + const uf = ContextFilter({ privateAttributes: ['firstName', 'bizzle'] }); + expect(uf.filter(context)).toEqual(contextWithSomeAttrsHidden); + }); + + it('hides attributes specified in per-context redactedAttributes', () => { + const uf = ContextFilter({}); + expect(uf.filter(contextSpecifyingOwnPrivateAttr)).toEqual(contextWithOwnSpecifiedAttrHidden); + }); + + it('looks at both per-context redactedAttributes and global config', () => { + const uf = ContextFilter({ privateAttributes: ['firstName', 'bizzle'] }); + expect(uf.filter(contextSpecifyingOwnPrivateAttr)).toEqual(userWithAllAttrsHidden); + }); + + it('context remains anonymous even when all attributes are hidden', () => { + const uf = ContextFilter({ allAttributesPrivate: true }); + expect(uf.filter(anonymousContext)).toEqual(contextWithAllAttrsHidden); + }); + + it('converts non-boolean anonymous to boolean.', () => { + const uf = ContextFilter({}); + expect(uf.filter({ kind: 'user', key: 'user', anonymous: 'string' })).toEqual({ + kind: 'user', + key: 'user', + anonymous: true, + }); + + expect(uf.filter({ kind: 'user', key: 'user', anonymous: null })).toEqual({ + kind: 'user', + key: 'user', + anonymous: false, + }); + }); +}); + +describe('when handling mult-kind contexts', () => { + const contextWithBadContexts = { + kind: 'multi', + string: 'string', + null: null, + number: 0, + real: { + key: 'real', + }, + }; + + const contextWithBadContextsRemoved = { + kind: 'multi', + real: { + key: 'real', + }, + }; + + const orgAndUserContext = { + kind: 'multi', + organization: { + key: 'LD', + rocks: true, + name: 'name', + department: { + name: 'sdk', + }, + }, + user: { + key: 'abc', + name: 'alphabet', + letters: ['a', 'b', 'c'], + order: 3, + object: { + a: 'a', + b: 'b', + }, + _meta: { + privateAttributes: ['letters', '/object/b'], + }, + }, + }; + + const orgAndUserContextAllPrivate = { + kind: 'multi', + organization: { + key: 'LD', + _meta: { + redactedAttributes: ['/department', '/name', '/rocks'], + }, + }, + user: { + key: 'abc', + _meta: { + redactedAttributes: ['/letters', '/name', '/object', '/order'], + }, + }, + }; + + const orgAndUserGlobalNamePrivate = { + kind: 'multi', + organization: { + key: 'LD', + rocks: true, + department: { + name: 'sdk', + }, + _meta: { + redactedAttributes: ['/name'], + }, + }, + user: { + key: 'abc', + order: 3, + object: { + a: 'a', + }, + _meta: { + redactedAttributes: ['/letters', '/name', '/object/b'], + }, + }, + }; + + const orgAndUserContextIncludedPrivate = { + kind: 'multi', + organization: { + key: 'LD', + rocks: true, + name: 'name', + department: { + name: 'sdk', + }, + }, + user: { + key: 'abc', + name: 'alphabet', + order: 3, + object: { + a: 'a', + }, + _meta: { + redactedAttributes: ['/letters', '/object/b'], + }, + }, + }; + + it('it should not include invalid contexts', () => { + const uf = ContextFilter({}); + expect(uf.filter(contextWithBadContexts)).toEqual(contextWithBadContextsRemoved); + }); + + it('it should remove attributes from all contexts when all attributes are private.', () => { + const uf = ContextFilter({ allAttributesPrivate: true }); + expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserContextAllPrivate); + }); + + it('it should apply private attributes from the context to the context.', () => { + const uf = ContextFilter({}); + expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserContextIncludedPrivate); + }); + + it('it should apply global private attributes to all contexts.', () => { + const uf = ContextFilter({ privateAttributes: ['name'] }); + expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserGlobalNamePrivate); + }); +}); diff --git a/src/__tests__/EventProcessor-test.js b/src/__tests__/EventProcessor-test.js index 59e5964..5db211a 100644 --- a/src/__tests__/EventProcessor-test.js +++ b/src/__tests__/EventProcessor-test.js @@ -9,9 +9,20 @@ import { MockEventSender } from './testUtils'; // various inputs. The actual delivery of data is done by EventSender, which has its own // tests; here, we use a mock EventSender. -describe('EventProcessor', () => { - const user = { key: 'userKey', name: 'Red' }; - const filteredUser = { key: 'userKey', privateAttrs: ['name'] }; +describe.each([ + [{ key: 'userKey', name: 'Red' }, { key: 'userKey', kind: 'user', _meta: { redactedAttributes: ['/name'] } }], + [ + { kind: 'user', key: 'userKey', name: 'Red' }, + { key: 'userKey', kind: 'user', _meta: { redactedAttributes: ['/name'] } }, + ], + [ + { kind: 'multi', user: { key: 'userKey', name: 'Red' } }, + { kind: 'multi', user: { key: 'userKey', _meta: { redactedAttributes: ['/name'] } } }, + ], +])('EventProcessor', (context, filteredContext) => { + // const user = { key: 'userKey', name: 'Red' }; + const eventContext = { ...context, kind: context.kind || 'user' }; + // const filteredUser = { key: 'userKey', kind: 'user', _meta: { redactedAttributes: ['/name'] } }; const eventsUrl = '/fake-url'; const envId = 'env'; const logger = stubPlatform.logger(); @@ -47,11 +58,11 @@ describe('EventProcessor', () => { function checkUserInline(e, source, inlineUser) { if (inlineUser) { - expect(e.user).toEqual(inlineUser); - expect(e.userKey).toBeUndefined(); + expect(e.context).toEqual(inlineUser); + expect(e.contextKeys).toBeUndefined(); } else { - expect(e.userKey).toEqual(source.user.key); - expect(e.user).toBeUndefined(); + expect(e.contextKeys).toEqual({ user: source.context.key || source.context.user.key }); + expect(e.context).toBeUndefined(); } } @@ -67,13 +78,13 @@ describe('EventProcessor', () => { checkUserInline(e, source, inlineUser); } - function checkCustomEvent(e, source, inlineUser) { + function checkCustomEvent(e, source) { expect(e.kind).toEqual('custom'); expect(e.creationDate).toEqual(source.creationDate); expect(e.key).toEqual(source.key); expect(e.data).toEqual(source.data); expect(e.metricValue).toEqual(source.metricValue); - checkUserInline(e, source, inlineUser); + checkUserInline(e, source); } function checkSummaryEvent(e) { @@ -82,7 +93,7 @@ describe('EventProcessor', () => { it('should enqueue identify event', async () => { await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { - const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; + const event = { kind: 'identify', creationDate: 1000, context: eventContext }; ep.enqueue(event); await ep.flush(); @@ -94,7 +105,7 @@ describe('EventProcessor', () => { it('filters user in identify event', async () => { const config = { ...defaultConfig, allAttributesPrivate: true }; await withProcessorAndSender(config, async (ep, mockEventSender) => { - const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; + const event = { kind: 'identify', creationDate: 1000, context: eventContext }; ep.enqueue(event); await ep.flush(); @@ -103,8 +114,8 @@ describe('EventProcessor', () => { { kind: 'identify', creationDate: event.creationDate, - key: user.key, - user: filteredUser, + + context: filteredContext, }, ]); }); @@ -116,7 +127,7 @@ describe('EventProcessor', () => { kind: 'feature', creationDate: 1000, key: 'flagkey', - user: user, + context: eventContext, trackEvents: true, }; ep.enqueue(event); @@ -130,36 +141,15 @@ describe('EventProcessor', () => { }); }); - it('can include inline user in feature event', async () => { - const config = { ...defaultConfig, inlineUsersInEvents: true }; - await withProcessorAndSender(config, async (ep, mockEventSender) => { - const event = { - kind: 'feature', - creationDate: 1000, - key: 'flagkey', - user: user, - trackEvents: true, - }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length()).toEqual(1); - const output = (await mockEventSender.calls.take()).events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, user); - checkSummaryEvent(output[1]); - }); - }); - it('can include reason in feature event', async () => { - const config = { ...defaultConfig, inlineUsersInEvents: true }; + const config = { ...defaultConfig }; const reason = { kind: 'FALLTHROUGH' }; await withProcessorAndSender(config, async (ep, mockEventSender) => { const event = { kind: 'feature', creationDate: 1000, key: 'flagkey', - user: user, + context: eventContext, trackEvents: true, reason: reason, }; @@ -169,28 +159,7 @@ describe('EventProcessor', () => { expect(mockEventSender.calls.length()).toEqual(1); const output = (await mockEventSender.calls.take()).events; expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, user); - checkSummaryEvent(output[1]); - }); - }); - - it('filters user in feature event', async () => { - const config = { ...defaultConfig, allAttributesPrivate: true, inlineUsersInEvents: true }; - await withProcessorAndSender(config, async (ep, mockEventSender) => { - const event = { - kind: 'feature', - creationDate: 1000, - key: 'flagkey', - user: user, - trackEvents: true, - }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length()).toEqual(1); - const output = (await mockEventSender.calls.take()).events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, filteredUser); + checkFeatureEvent(output[0], event, false); checkSummaryEvent(output[1]); }); }); @@ -201,7 +170,7 @@ describe('EventProcessor', () => { const e = { kind: 'feature', creationDate: 1000, - user: user, + context: eventContext, key: 'flagkey', version: 11, variation: 1, @@ -215,7 +184,7 @@ describe('EventProcessor', () => { expect(mockEventSender.calls.length()).toEqual(1); const output = (await mockEventSender.calls.take()).events; expect(output.length).toEqual(2); - checkFeatureEvent(output[0], e, true, user); + checkFeatureEvent(output[0], e, true, { ...context, kind: context.kind || 'user' }); checkSummaryEvent(output[1]); }); }); @@ -227,7 +196,7 @@ describe('EventProcessor', () => { const e = { kind: 'feature', creationDate: 1000, - user: user, + context: eventContext, key: 'flagkey', version: 11, variation: 1, @@ -242,7 +211,7 @@ describe('EventProcessor', () => { expect(mockEventSender.calls.length()).toEqual(1); const output = (await mockEventSender.calls.take()).events; expect(output.length).toEqual(2); - checkFeatureEvent(output[0], e, true, filteredUser); + checkFeatureEvent(output[0], e, true, filteredContext); checkSummaryEvent(output[1]); }); }); @@ -253,7 +222,7 @@ describe('EventProcessor', () => { const e = { kind: 'feature', creationDate: 1000, - user: user, + context: eventContext, key: 'flagkey', version: 11, variation: 1, @@ -268,7 +237,7 @@ describe('EventProcessor', () => { const output = (await mockEventSender.calls.take()).events; expect(output.length).toEqual(3); checkFeatureEvent(output[0], e, false); - checkFeatureEvent(output[1], e, true, user); + checkFeatureEvent(output[1], e, true, { ...context, kind: context.kind || 'user' }); checkSummaryEvent(output[2]); }); }); @@ -280,7 +249,7 @@ describe('EventProcessor', () => { mockEventSender.setServerTime(serverTime); // Send and flush an event we don't care about, just to set the last server time - ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); + ep.enqueue({ kind: 'identify', context: { key: 'otherUser' } }); await ep.flush(); // Now send an event with debug mode on, with a "debug until" time that is further in @@ -289,7 +258,7 @@ describe('EventProcessor', () => { const e = { kind: 'feature', creationDate: 1000, - user: user, + context: eventContext, key: 'flagkey', version: 11, variation: 1, @@ -316,7 +285,7 @@ describe('EventProcessor', () => { mockEventSender.setServerTime(serverTime); // Send and flush an event we don't care about, just to set the last server time - ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); + ep.enqueue({ kind: 'identify', context: { key: 'otherUser' } }); await ep.flush(); // Now send an event with debug mode on, with a "debug until" time that is further in @@ -325,7 +294,7 @@ describe('EventProcessor', () => { const e = { kind: 'feature', creationDate: 1000, - user: user, + context: eventContext, key: 'flagkey', version: 11, variation: 1, @@ -351,7 +320,7 @@ describe('EventProcessor', () => { return { kind: 'feature', creationDate: date, - user: user, + context: eventContext, key: key, version: version, variation: variation, @@ -375,10 +344,12 @@ describe('EventProcessor', () => { expect(se.endDate).toEqual(2000); expect(se.features).toEqual({ flagkey1: { + contextKinds: ['user'], default: 'default1', counters: [{ version: 11, variation: 1, value: 'value1', count: 1 }], }, flagkey2: { + contextKinds: ['user'], default: 'default2', counters: [{ version: 22, variation: 1, value: 'value2', count: 1 }], }, @@ -391,7 +362,7 @@ describe('EventProcessor', () => { const e = { kind: 'custom', creationDate: 1000, - user: user, + context: eventContext, key: 'eventkey', data: { thing: 'stuff' }, metricValue: 1.5, @@ -406,51 +377,11 @@ describe('EventProcessor', () => { }); }); - it('can include inline user in custom event', async () => { - const config = { ...defaultConfig, inlineUsersInEvents: true }; - await withProcessorAndSender(config, async (ep, mockEventSender) => { - const e = { - kind: 'custom', - creationDate: 1000, - user: user, - key: 'eventkey', - data: { thing: 'stuff' }, - }; - ep.enqueue(e); - await ep.flush(); - - expect(mockEventSender.calls.length()).toEqual(1); - const output = (await mockEventSender.calls.take()).events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e, user); - }); - }); - - it('filters user in custom event', async () => { - const config = { ...defaultConfig, allAttributesPrivate: true, inlineUsersInEvents: true }; - await withProcessorAndSender(config, async (ep, mockEventSender) => { - const e = { - kind: 'custom', - creationDate: 1000, - user: user, - key: 'eventkey', - data: { thing: 'stuff' }, - }; - ep.enqueue(e); - await ep.flush(); - - expect(mockEventSender.calls.length()).toEqual(1); - const output = (await mockEventSender.calls.take()).events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e, filteredUser); - }); - }); - it('enforces event capacity', async () => { const config = { ...defaultConfig, eventCapacity: 1, logger: stubPlatform.logger() }; - const e0 = { kind: 'custom', creationDate: 1000, user: user, key: 'key0' }; - const e1 = { kind: 'custom', creationDate: 1001, user: user, key: 'key1' }; - const e2 = { kind: 'custom', creationDate: 1002, user: user, key: 'key2' }; + const e0 = { kind: 'custom', creationDate: 1000, context: eventContext, key: 'key0' }; + const e1 = { kind: 'custom', creationDate: 1001, context: eventContext, key: 'key1' }; + const e2 = { kind: 'custom', creationDate: 1002, context: eventContext, key: 'key2' }; await withProcessorAndSender(config, async (ep, mockEventSender) => { ep.enqueue(e0); ep.enqueue(e1); @@ -475,7 +406,7 @@ describe('EventProcessor', () => { async function verifyUnrecoverableHttpError(status) { await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { - const e = { kind: 'identify', creationDate: 1000, user: user }; + const e = { kind: 'identify', creationDate: 1000, context: eventContext }; ep.enqueue(e); mockEventSender.setStatus(status); await ep.flush(); @@ -490,7 +421,7 @@ describe('EventProcessor', () => { async function verifyRecoverableHttpError(status) { await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { - const e = { kind: 'identify', creationDate: 1000, user: user }; + const e = { kind: 'identify', creationDate: 1000, context: eventContext }; ep.enqueue(e); mockEventSender.setStatus(status); await ep.flush(); @@ -517,8 +448,8 @@ describe('EventProcessor', () => { describe('interaction with diagnostic events', () => { it('sets eventsInLastBatch on flush', async () => { - const e0 = { kind: 'custom', creationDate: 1000, user: user, key: 'key0' }; - const e1 = { kind: 'custom', creationDate: 1001, user: user, key: 'key1' }; + const e0 = { kind: 'custom', creationDate: 1000, context: eventContext, key: 'key0' }; + const e1 = { kind: 'custom', creationDate: 1001, context: eventContext, key: 'key1' }; await withDiagnosticProcessorAndSender(defaultConfig, async (ep, mockEventSender, diagnosticAccumulator) => { expect(diagnosticAccumulator.getProps().eventsInLastBatch).toEqual(0); @@ -536,9 +467,9 @@ describe('EventProcessor', () => { it('increments droppedEvents when capacity is exceeded', async () => { const config = { ...defaultConfig, eventCapacity: 1, logger: stubPlatform.logger() }; - const e0 = { kind: 'custom', creationDate: 1000, user: user, key: 'key0' }; - const e1 = { kind: 'custom', creationDate: 1001, user: user, key: 'key1' }; - const e2 = { kind: 'custom', creationDate: 1002, user: user, key: 'key2' }; + const e0 = { kind: 'custom', creationDate: 1000, context: eventContext, key: 'key0' }; + const e1 = { kind: 'custom', creationDate: 1001, context: eventContext, key: 'key1' }; + const e2 = { kind: 'custom', creationDate: 1002, context: eventContext, key: 'key2' }; await withDiagnosticProcessorAndSender(config, async (ep, mockEventSender, diagnosticAccumulator) => { ep.enqueue(e0); ep.enqueue(e1); diff --git a/src/__tests__/EventSummarizer-test.js b/src/__tests__/EventSummarizer-test.js index be5e065..26ebc31 100644 --- a/src/__tests__/EventSummarizer-test.js +++ b/src/__tests__/EventSummarizer-test.js @@ -39,7 +39,7 @@ describe('EventSummarizer', () => { creationDate: 1000, key: key, version: version, - user: user, + context: user, variation: variation, value: value, default: defaultVal, @@ -63,6 +63,7 @@ describe('EventSummarizer', () => { data.features.key1.counters.sort((a, b) => a.value - b.value); const expectedFeatures = { key1: { + contextKinds: ['user'], default: 111, counters: [ { value: 100, variation: 1, version: 11, count: 2 }, @@ -70,10 +71,12 @@ describe('EventSummarizer', () => { ], }, key2: { + contextKinds: ['user'], default: 222, counters: [{ value: 999, variation: 1, version: 22, count: 1 }], }, badkey: { + contextKinds: ['user'], default: 333, counters: [{ value: 333, unknown: true, count: 1 }], }, @@ -94,10 +97,58 @@ describe('EventSummarizer', () => { data.features.key1.counters.sort((a, b) => a.value - b.value); const expectedFeatures = { key1: { + contextKinds: ['user'], default: 111, counters: [{ variation: 0, value: 100, version: 11, count: 1 }, { value: 111, version: 11, count: 2 }], }, }; expect(data.features).toEqual(expectedFeatures); }); + + it('includes keys from all kinds', () => { + const es = EventSummarizer(); + const event1 = { + kind: 'feature', + creationDate: 1000, + key: 'key1', + version: 11, + context: { key: 'test' }, + variation: 1, + value: 100, + default: 111, + }; + const event2 = { + kind: 'feature', + creationDate: 1000, + key: 'key1', + version: 11, + context: { kind: 'org', key: 'test' }, + variation: 1, + value: 100, + default: 111, + }; + const event3 = { + kind: 'feature', + creationDate: 1000, + key: 'key1', + version: 11, + context: { kind: 'multi', bacon: { key: 'crispy' }, eggs: { key: 'scrambled' } }, + variation: 1, + value: 100, + default: 111, + }; + es.summarizeEvent(event1); + es.summarizeEvent(event2); + es.summarizeEvent(event3); + const data = es.getSummary(); + + const expectedFeatures = { + key1: { + default: 111, + counters: [{ variation: 1, value: 100, version: 11, count: 3 }], + contextKinds: ['user', 'org', 'bacon', 'eggs'], + }, + }; + expect(data.features).toEqual(expectedFeatures); + }); }); diff --git a/src/__tests__/InspectorManager-test.js b/src/__tests__/InspectorManager-test.js index 1444b19..3f37acf 100644 --- a/src/__tests__/InspectorManager-test.js +++ b/src/__tests__/InspectorManager-test.js @@ -39,16 +39,16 @@ describe('given an inspector with callbacks of every type', () => { { type: 'flag-used', name: 'my-flag-used-inspector', - method: (flagKey, flagDetail, user) => { - eventQueue.add({ type: 'flag-used', flagKey, flagDetail, user }); + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); }, }, // 'flag-used registered twice. { type: 'flag-used', name: 'my-other-flag-used-inspector', - method: (flagKey, flagDetail, user) => { - eventQueue.add({ type: 'flag-used', flagKey, flagDetail, user }); + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); }, }, { @@ -75,10 +75,10 @@ describe('given an inspector with callbacks of every type', () => { { type: 'client-identity-changed', name: 'my-identity-inspector', - method: user => { + method: context => { eventQueue.add({ type: 'client-identity-changed', - user, + context, }); }, }, @@ -137,7 +137,7 @@ describe('given an inspector with callbacks of every type', () => { kind: 'OFF', }, }, - user: { key: 'test-key' }, + context: { key: 'test-key' }, }; const event1 = await eventQueue.take(); expect(event1).toMatchObject(expectedEvent); @@ -180,7 +180,7 @@ describe('given an inspector with callbacks of every type', () => { const event = await eventQueue.take(); expect(event).toMatchObject({ type: 'client-identity-changed', - user: { key: 'the-key' }, + context: { key: 'the-key' }, }); }); }); diff --git a/src/__tests__/LDClient-events-test.js b/src/__tests__/LDClient-events-test.js index f84c188..99c75a5 100644 --- a/src/__tests__/LDClient-events-test.js +++ b/src/__tests__/LDClient-events-test.js @@ -50,26 +50,7 @@ describe('LDClient events', () => { function expectIdentifyEvent(e, user) { expect(e.kind).toEqual('identify'); - expect(e.user).toEqual(user); - } - - function expectAliasEvent(e, user, previousUser) { - function userContextKind(user) { - return user.anonymous ? 'anonymousUser' : 'user'; - } - expect(e.kind).toEqual('alias'); - expect(e.key).toEqual(user.key); - expect(e.previousKey).toEqual(previousUser.key); - expect(e.contextKind).toEqual(userContextKind(user)); - expect(e.previousContextKind).toEqual(userContextKind(previousUser)); - } - - function expectContextKindInEvent(e, user) { - if (user.anonymous) { - expect(e.contextKind).toEqual('anonymousUser'); - } else { - expect(e.contextKind).toBe(undefined); - } + expect(e.context).toEqual(user); } function expectFeatureEvent({ @@ -91,7 +72,7 @@ describe('LDClient events', () => { expect(e.default).toEqual(defaultVal); expect(e.trackEvents).toEqual(trackEvents); expect(e.debugEventsUntilDate).toEqual(debugEventsUntilDate); - expectContextKindInEvent(e, user); + expect(e.context).toEqual(user); } it('sends an identify event at startup', async () => { @@ -129,126 +110,6 @@ describe('LDClient events', () => { }); }); - it('sends an alias event when alias() is called', async () => { - await withClientAndEventProcessor(user, {}, async (client, ep) => { - const anon1 = { key: 'user1', anonymous: true }; - const anon2 = { key: 'user2', anonymous: true }; - const known1 = { key: 'user3' }; - const known2 = { key: 'user4' }; - await client.waitForInitialization(); - expect(ep.events.length).toEqual(1); - - await client.alias(anon2, anon1); - expectAliasEvent(ep.events[1], anon2, anon1); - - await client.alias(known1, anon1); - expectAliasEvent(ep.events[2], known1, anon1); - - await client.alias(known2, known1); - expectAliasEvent(ep.events[3], known2, known1); - - await client.alias(anon1, known1); - expectAliasEvent(ep.events[4], anon1, known1); - - expect(ep.events.length).toEqual(5); - }); - }); - - it('sends an alias event when identify() is called for anon to known', async () => { - // need a server because it'll do a polling request when we call identify - await withServer(async server => { - const anonUser = { key: 'anon-user', anonymous: true }; - const knownUser = { key: 'known-user' }; - await withClientAndEventProcessor(anonUser, { baseUrl: server.url }, async (client, ep) => { - await client.waitForInitialization(); - - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], anonUser); - - await client.identify(knownUser); - expect(ep.events.length).toEqual(3); - expectIdentifyEvent(ep.events[1], knownUser); - expectAliasEvent(ep.events[2], knownUser, anonUser); - }); - }); - }); - - it('does not send an alias event when identify() is called if auto-aliasing is disabled', async () => { - // need a server because it'll do a polling request when we call identify - await withServer(async server => { - const anonUser = { key: 'anon-user', anonymous: true }; - const knownUser = { key: 'known-user' }; - await withClientAndEventProcessor( - anonUser, - { baseUrl: server.url, autoAliasingOptOut: true }, - async (client, ep) => { - await client.waitForInitialization(); - - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], anonUser); - - await client.identify(knownUser); - expect(ep.events.length).toEqual(2); //no additional alias events - expectIdentifyEvent(ep.events[1], knownUser); - } - ); - }); - }); - - it('does not send an alias event when identify() is called for known to anon', async () => { - // need a server because it'll do a polling request when we call identify - await withServer(async server => { - const knownUser = { key: 'known-user' }; - const anonUser = { key: 'anon-user', anonymous: true }; - await withClientAndEventProcessor(knownUser, { baseUrl: server.url }, async (client, ep) => { - await client.waitForInitialization(); - - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], knownUser); - - await client.identify(anonUser); - expect(ep.events.length).toEqual(2); //no additional alias events - expectIdentifyEvent(ep.events[1], anonUser); - }); - }); - }); - - it('does not send an alias event when identify() is called for anon to anon', async () => { - // need a server because it'll do a polling request when we call identify - await withServer(async server => { - const anonUser1 = { key: 'anon-user1', anonymous: true }; - const anonUser2 = { key: 'anon-user2', anonymous: true }; - await withClientAndEventProcessor(anonUser1, { baseUrl: server.url }, async (client, ep) => { - await client.waitForInitialization(); - - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], anonUser1); - - await client.identify(anonUser2); - expect(ep.events.length).toEqual(2); //no additional alias events - expectIdentifyEvent(ep.events[1], anonUser2); - }); - }); - }); - - it('does not send an alias event when identify() is called for known to known', async () => { - // need a server because it'll do a polling request when we call identify - await withServer(async server => { - const knownUser1 = { key: 'known-user1' }; - const knownUser2 = { key: 'known-user2' }; - await withClientAndEventProcessor(knownUser1, { baseUrl: server.url }, async (client, ep) => { - await client.waitForInitialization(); - - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], knownUser1); - - await client.identify(knownUser2); - expect(ep.events.length).toEqual(2); //no additional alias events - expectIdentifyEvent(ep.events[1], knownUser2); - }); - }); - }); - it('stringifies user attributes in the identify event when identify() is called', async () => { // This just verifies that the event is being sent with the sanitized user, not the user that was passed in await withServer(async server => { @@ -435,7 +296,7 @@ describe('LDClient events', () => { it('does not send a feature event for a new flag value if there is a state provider', async () => { const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; - const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: oldFlags }); + const sp = stubPlatform.mockStateProvider({ environment: envName, context: user, flags: oldFlags }); await withServer(async server => { server.byDefault(respondJson(newFlags)); const extraConfig = { stateProvider: sp, baseUrl: server.url }; @@ -546,10 +407,9 @@ describe('LDClient events', () => { const trackEvent = ep.events[1]; expect(trackEvent.kind).toEqual('custom'); expect(trackEvent.key).toEqual('eventkey'); - expect(trackEvent.user).toEqual(user); + expect(trackEvent.context).toEqual(user); expect(trackEvent.data).toEqual(undefined); expect(trackEvent.url).toEqual(fakeUrl); - expectContextKindInEvent(trackEvent, user); }); }); @@ -565,10 +425,9 @@ describe('LDClient events', () => { const trackEvent = ep.events[1]; expect(trackEvent.kind).toEqual('custom'); expect(trackEvent.key).toEqual('eventkey'); - expect(trackEvent.user).toEqual(anonUser); + expect(trackEvent.context).toEqual(anonUser); expect(trackEvent.data).toEqual(undefined); expect(trackEvent.url).toEqual(fakeUrl); - expectContextKindInEvent(trackEvent, anonUser); }); }); }); @@ -584,10 +443,9 @@ describe('LDClient events', () => { const trackEvent = ep.events[1]; expect(trackEvent.kind).toEqual('custom'); expect(trackEvent.key).toEqual('eventkey'); - expect(trackEvent.user).toEqual(user); + expect(trackEvent.context).toEqual(user); expect(trackEvent.data).toEqual(eventData); expect(trackEvent.url).toEqual(fakeUrl); - expectContextKindInEvent(trackEvent, user); }); }); @@ -603,11 +461,10 @@ describe('LDClient events', () => { const trackEvent = ep.events[1]; expect(trackEvent.kind).toEqual('custom'); expect(trackEvent.key).toEqual('eventkey'); - expect(trackEvent.user).toEqual(user); + expect(trackEvent.context).toEqual(user); expect(trackEvent.data).toEqual(eventData); expect(trackEvent.metricValue).toEqual(metricValue); expect(trackEvent.url).toEqual(fakeUrl); - expectContextKindInEvent(trackEvent, user); }); }); @@ -646,12 +503,12 @@ describe('LDClient events', () => { it('should warn about missing user on first event', async () => { await withClientAndEventProcessor(null, {}, async client => { client.track('eventkey', null); - expect(platform.testing.logger.output.warn).toEqual([messages.eventWithoutUser()]); + expect(platform.testing.logger.output.warn).toEqual([messages.eventWithoutContext()]); }); }); it('allows stateProvider to take over sending an event', async () => { - const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: {} }); + const sp = stubPlatform.mockStateProvider({ environment: envName, context: user, flags: {} }); const divertedEvents = []; sp.enqueueEvent = event => divertedEvents.push(event); diff --git a/src/__tests__/LDClient-inspectors-test.js b/src/__tests__/LDClient-inspectors-test.js index 58be959..25db828 100644 --- a/src/__tests__/LDClient-inspectors-test.js +++ b/src/__tests__/LDClient-inspectors-test.js @@ -3,7 +3,7 @@ const { respondJson } = require('./mockHttp'); const stubPlatform = require('./stubPlatform'); const envName = 'UNKNOWN_ENVIRONMENT_ID'; -const user = { key: 'user' }; +const context = { key: 'context-key' }; describe('given a streaming client with registered inspectors', () => { const eventQueue = new AsyncQueue(); @@ -11,15 +11,15 @@ describe('given a streaming client with registered inspectors', () => { const inspectors = [ { type: 'flag-used', - method: (flagKey, flagDetail, user) => { - eventQueue.add({ type: 'flag-used', flagKey, flagDetail, user }); + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); }, }, // 'flag-used registered twice. { type: 'flag-used', - method: (flagKey, flagDetail, user) => { - eventQueue.add({ type: 'flag-used', flagKey, flagDetail, user }); + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); }, }, { @@ -43,10 +43,10 @@ describe('given a streaming client with registered inspectors', () => { }, { type: 'client-identity-changed', - method: user => { + method: context => { eventQueue.add({ type: 'client-identity-changed', - user, + context, }); }, }, @@ -59,8 +59,8 @@ describe('given a streaming client with registered inspectors', () => { platform = stubPlatform.defaults(); const server = platform.testing.http.newServer(); server.byDefault(respondJson({})); - const config = { streaming: true, baseUrl: server.url, inspectors }; - client = platform.testing.makeClient(envName, user, config); + const config = { streaming: true, baseUrl: server.url, inspectors, sendEvents: false }; + client = platform.testing.makeClient(envName, context, config); await client.waitUntilReady(); }); @@ -81,7 +81,7 @@ describe('given a streaming client with registered inspectors', () => { const ident = await eventQueue.take(); expect(ident).toMatchObject({ type: 'client-identity-changed', - user, + context, }); const flagsEvent = await eventQueue.take(); expect(flagsEvent).toMatchObject({ diff --git a/src/__tests__/LDClient-test.js b/src/__tests__/LDClient-test.js index a757557..5eba1fb 100644 --- a/src/__tests__/LDClient-test.js +++ b/src/__tests__/LDClient-test.js @@ -127,7 +127,7 @@ describe('LDClient', () => { await withServers(async baseConfig => { await withClient(numericUser, baseConfig, async client => { await client.waitForInitialization(); - expect(client.getUser()).toEqual(stringifiedNumericUser); + expect(client.getContext()).toEqual(stringifiedNumericUser); }); }); }); @@ -139,14 +139,14 @@ describe('LDClient', () => { await withClient(anonUser, baseConfig, async client0 => { await client0.waitForInitialization(); - generatedUser = client0.getUser(); + generatedUser = client0.getContext(); expect(generatedUser.key).toEqual(expect.anything()); expect(generatedUser).toMatchObject(anonUser); }); await withClient(anonUser, baseConfig, async client1 => { await client1.waitForInitialization(); - const newUser1 = client1.getUser(); + const newUser1 = client1.getContext(); expect(newUser1).toEqual(generatedUser); }); }); @@ -161,7 +161,7 @@ describe('LDClient', () => { await withClient(anonUser, baseConfig, async client0 => { await client0.waitForInitialization(); - generatedUser = client0.getUser(); + generatedUser = client0.getContext(); expect(generatedUser.key).toEqual(expect.anything()); expect(generatedUser).toMatchObject(anonUser); }); @@ -169,7 +169,7 @@ describe('LDClient', () => { await withClient(anonUser, baseConfig, async client1 => { await client1.waitForInitialization(); - const newUser1 = client1.getUser(); + const newUser1 = client1.getContext(); expect(newUser1.key).toEqual(expect.anything()); expect(newUser1.key).not.toEqual(generatedUser.key); expect(newUser1).toMatchObject(anonUser); @@ -466,12 +466,12 @@ describe('LDClient', () => { const identifyPromise = client.identify(user2); await sleepAsync(100); // sleep to jump some async ticks - expect(client.getUser()).toEqual(user); + expect(client.getContext()).toEqual(user); signal.add(); await identifyPromise; - expect(client.getUser()).toEqual(user2); + expect(client.getContext()).toEqual(user2); }); }); }); @@ -498,7 +498,11 @@ describe('LDClient', () => { }); }); - it('returns an error and does not update flags when identify is called with invalid user', async () => { + it.each([ + { country: 'US' }, // Legacy user with no key, and not anonymous. + { kind: 'user' }, // A single kind that is not anonymous and has no key. + { kind: 'multi', app: { anonymous: true }, org: {}, user: { key: 'yes' } }, // Multi kind with 1 non-anonymous context without a key. + ])('returns an error and does not update flags when identify is called with invalid contexts', async badContext => { const flags0 = { 'enable-foo': { value: false } }; const flags1 = { 'enable-foo': { value: true } }; await withServers(async (baseConfig, pollServer) => { @@ -516,8 +520,7 @@ describe('LDClient', () => { expect(client.variation('enable-foo')).toBe(false); expect(pollServer.requests.length()).toEqual(1); - const userWithNoKey = { country: 'US' }; - await expect(client.identify(userWithNoKey)).rejects.toThrow(); + await expect(client.identify(badContext)).rejects.toThrow(); expect(client.variation('enable-foo')).toBe(false); expect(pollServer.requests.length()).toEqual(1); @@ -533,7 +536,7 @@ describe('LDClient', () => { const anonUser = { anonymous: true, country: 'US' }; await client.identify(anonUser); - const newUser = client.getUser(); + const newUser = client.getContext(); expect(newUser.key).toEqual(expect.anything()); expect(newUser).toMatchObject(anonUser); }); @@ -546,7 +549,7 @@ describe('LDClient', () => { const user = { key: 'user' }; const state = { environment: 'env', - user: user, + context: user, flags: { flagkey: { value: 'value' } }, }; const sp = stubPlatform.mockStateProvider(state); @@ -575,7 +578,7 @@ describe('LDClient', () => { const user = { key: 'user' }; const state = { environment: 'env', - user: user, + context: user, flags: { flagkey: { value: 'value' } }, }; const sp = stubPlatform.mockStateProvider(null); @@ -592,7 +595,7 @@ describe('LDClient', () => { const user = { key: 'user' }; const state0 = { environment: 'env', - user: user, + context: user, flags: { flagkey: { value: 'value0' } }, }; const sp = stubPlatform.mockStateProvider(state0); @@ -618,7 +621,7 @@ describe('LDClient', () => { it('disables identify()', async () => { const user = { key: 'user' }; const user1 = { key: 'user1' }; - const state = { environment: 'env', user: user, flags: { flagkey: { value: 'value' } } }; + const state = { environment: 'env', context: user, flags: { flagkey: { value: 'value' } } }; const sp = stubPlatform.mockStateProvider(state); await withServers(async (baseConfig, pollServer) => { diff --git a/src/__tests__/PersistentFlagStore-test.js b/src/__tests__/PersistentFlagStore-test.js index d40e7a7..ecdcee3 100644 --- a/src/__tests__/PersistentFlagStore-test.js +++ b/src/__tests__/PersistentFlagStore-test.js @@ -7,10 +7,10 @@ import PersistentStorage from '../PersistentStorage'; import * as utils from '../utils'; describe('PersistentFlagStore', () => { - const user = { key: 'user' }; - const ident = Identity(user); + const context = { key: 'context-key', kind: 'user' }; + const ident = Identity(context); const env = 'ENVIRONMENT'; - const lsKey = 'ld:' + env + ':' + utils.btoa(JSON.stringify(user)); + const lsKey = 'ld:' + env + ':' + utils.btoa(JSON.stringify(context)); it('stores flags', async () => { const platform = stubPlatform.defaults(); @@ -73,7 +73,7 @@ describe('PersistentFlagStore', () => { expect(platform.testing.getLocalStorageImmediately(lsKey)).toBe(undefined); }); - it('uses hash, if present, instead of user properties', async () => { + it('uses hash, if present, instead of context properties', async () => { const platform = stubPlatform.defaults(); const storage = PersistentStorage(platform.localStorage, platform.testing.logger); const hash = '12345'; diff --git a/src/__tests__/Requestor-test.js b/src/__tests__/Requestor-test.js index e629a26..a7399fe 100644 --- a/src/__tests__/Requestor-test.js +++ b/src/__tests__/Requestor-test.js @@ -69,7 +69,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}`); + expect(req.path).toEqual(`/sdk/evalx/${env}/contexts/${encodedUser}`); }); }); @@ -81,7 +81,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}?h=hash1`); + expect(req.path).toEqual(`/sdk/evalx/${env}/contexts/${encodedUser}?h=hash1`); }); }); @@ -93,7 +93,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}?withReasons=true`); + expect(req.path).toEqual(`/sdk/evalx/${env}/contexts/${encodedUser}?withReasons=true`); }); }); @@ -105,7 +105,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}?h=hash1&withReasons=true`); + expect(req.path).toEqual(`/sdk/evalx/${env}/contexts/${encodedUser}?h=hash1&withReasons=true`); }); }); @@ -117,7 +117,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/user`); + expect(req.path).toEqual(`/sdk/evalx/${env}/context`); }); }); @@ -129,7 +129,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/user?h=hash1`); + expect(req.path).toEqual(`/sdk/evalx/${env}/context?h=hash1`); }); }); @@ -141,7 +141,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/user?withReasons=true`); + expect(req.path).toEqual(`/sdk/evalx/${env}/context?withReasons=true`); }); }); @@ -153,7 +153,7 @@ describe('Requestor', () => { expect(server.requests.length()).toEqual(1); const req = await server.requests.take(); - expect(req.path).toEqual(`/sdk/evalx/${env}/user?h=hash1&withReasons=true`); + expect(req.path).toEqual(`/sdk/evalx/${env}/context?h=hash1&withReasons=true`); }); }); diff --git a/src/__tests__/TransientContextProcessor-test.js b/src/__tests__/TransientContextProcessor-test.js new file mode 100644 index 0000000..f94fff1 --- /dev/null +++ b/src/__tests__/TransientContextProcessor-test.js @@ -0,0 +1,115 @@ +import AnonymousContextProcessor from '../AnonymousContextProcessor'; + +describe('AnonymousContextProcessor', () => { + let localStorage; + let logger; + let uv; + + beforeEach(() => { + localStorage = {}; + logger = { + warn: jest.fn(), + }; + uv = new AnonymousContextProcessor(localStorage, logger); + }); + + it('rejects null user', async () => { + await expect(uv.processContext(null)).rejects.toThrow(); + }); + + it('leaves user with string key unchanged', async () => { + const u = { key: 'someone', name: 'me' }; + expect(await uv.processContext(u)).toEqual(u); + }); + + it('stringifies non-string key', async () => { + const u0 = { key: 123, name: 'me' }; + const u1 = { key: '123', name: 'me' }; + expect(await uv.processContext(u0)).toEqual(u1); + }); + + it('uses cached key for anonymous user', async () => { + const cachedKey = 'thing'; + let storageKey; + localStorage.get = async key => { + storageKey = key; + return cachedKey; + }; + const u = { anonymous: true }; + expect(await uv.processContext(u)).toEqual({ key: cachedKey, anonymous: true }); + expect(storageKey).toEqual('ld:$anonUserId'); + }); + + it('generates and stores key for anonymous user', async () => { + let storageKey; + let storedValue; + localStorage.get = async () => null; + localStorage.set = async (key, value) => { + storageKey = key; + storedValue = value; + }; + const u0 = { anonymous: true }; + const u1 = await uv.processContext(u0); + expect(storedValue).toEqual(expect.anything()); + expect(u1).toEqual({ key: storedValue, anonymous: true }); + expect(storageKey).toEqual('ld:$anonUserId'); + }); + + it('generates and stores a key for each anonymous context in a multi-kind context', async () => { + const context = { + kind: 'multi', + user: { anonymous: true }, + org: { anonymous: true }, + app: { key: 'app' }, + }; + + const storage = {}; + localStorage.get = async key => storage[key]; + localStorage.set = async (key, value) => { + storage[key] = value; + }; + + const processed = await uv.processContext(context); + expect(processed.user.key).toBeDefined(); + expect(processed.user.key).not.toEqual(processed.org.key); + expect(processed.org.key).toBeDefined(); + expect(processed.app.key).toEqual('app'); + }); + + it('uses cached keys for context kinds that have already been generated', async () => { + const context = { + kind: 'multi', + user: { anonymous: true }, + org: { anonymous: true }, + another: { anonymous: true }, + app: { key: 'app' }, + }; + + const storage = { + 'ld:$contextKey:org': 'cachedOrgKey', + 'ld:$anonUserId': 'cachedUserKey', + }; + localStorage.get = async key => storage[key]; + localStorage.set = async (key, value) => { + storage[key] = value; + }; + + const processed = await uv.processContext(context); + expect(processed.user.key).toEqual('cachedUserKey'); + expect(processed.org.key).toEqual('cachedOrgKey'); + expect(processed.another.key).toBeDefined(); + expect(processed.app.key).toEqual('app'); + }); + + it.each([{ anonymous: true }, { kind: 'user', anonymous: true }, { kind: 'multi', user: { anonymous: true } }])( + 'uses the same key to store any user context (legacy, single, multi)', + async context => { + const storage = {}; + localStorage.get = async key => expect(key).toEqual('ld:$anonUserId'); + localStorage.set = async (key, value) => { + storage[key] = value; + }; + await uv.processContext(context); + } + ); +}); diff --git a/src/__tests__/UserFilter-test.js b/src/__tests__/UserFilter-test.js deleted file mode 100644 index 09f7919..0000000 --- a/src/__tests__/UserFilter-test.js +++ /dev/null @@ -1,93 +0,0 @@ -import UserFilter from '../UserFilter'; - -describe('UserFilter', () => { - // users to serialize - const user = { - key: 'abc', - firstName: 'Sue', - custom: { bizzle: 'def', dizzle: 'ghi' }, - }; - - const userSpecifyingOwnPrivateAttr = { - key: 'abc', - firstName: 'Sue', - custom: { bizzle: 'def', dizzle: 'ghi' }, - privateAttributeNames: ['dizzle', 'unused'], - }; - - const userWithUnknownTopLevelAttrs = { - key: 'abc', - firstName: 'Sue', - species: 'human', - hatSize: 6, - custom: { bizzle: 'def', dizzle: 'ghi' }, - }; - - const anonUser = { - key: 'abc', - anonymous: true, - custom: { bizzle: 'def', dizzle: 'ghi' }, - }; - - // expected results from serializing user - const userWithAllAttrsHidden = { - key: 'abc', - custom: {}, - privateAttrs: ['bizzle', 'dizzle', 'firstName'], - }; - - const userWithSomeAttrsHidden = { - key: 'abc', - custom: { dizzle: 'ghi' }, - privateAttrs: ['bizzle', 'firstName'], - }; - - const userWithOwnSpecifiedAttrHidden = { - key: 'abc', - firstName: 'Sue', - custom: { bizzle: 'def' }, - privateAttrs: ['dizzle'], - }; - - const anonUserWithAllAttrsHidden = { - key: 'abc', - anonymous: true, - custom: {}, - privateAttrs: ['bizzle', 'dizzle'], - }; - - it('includes all user attributes by default', () => { - const uf = UserFilter({}); - expect(uf.filterUser(user)).toEqual(user); - }); - - it('hides all except key if allAttributesPrivate is true', () => { - const uf = UserFilter({ allAttributesPrivate: true }); - expect(uf.filterUser(user)).toEqual(userWithAllAttrsHidden); - }); - - it('hides some attributes if privateAttributeNames is set', () => { - const uf = UserFilter({ privateAttributeNames: ['firstName', 'bizzle'] }); - expect(uf.filterUser(user)).toEqual(userWithSomeAttrsHidden); - }); - - it('hides attributes specified in per-user privateAttrs', () => { - const uf = UserFilter({}); - expect(uf.filterUser(userSpecifyingOwnPrivateAttr)).toEqual(userWithOwnSpecifiedAttrHidden); - }); - - it('looks at both per-user privateAttrs and global config', () => { - const uf = UserFilter({ privateAttributeNames: ['firstName', 'bizzle'] }); - expect(uf.filterUser(userSpecifyingOwnPrivateAttr)).toEqual(userWithAllAttrsHidden); - }); - - it('strips unknown top-level attributes', () => { - const uf = UserFilter({}); - expect(uf.filterUser(userWithUnknownTopLevelAttrs)).toEqual(user); - }); - - it('leaves the "anonymous" attribute as is', () => { - const uf = UserFilter({ allAttributesPrivate: true }); - expect(uf.filterUser(anonUser)).toEqual(anonUserWithAllAttrsHidden); - }); -}); diff --git a/src/__tests__/UserValidator-test.js b/src/__tests__/UserValidator-test.js deleted file mode 100644 index 5dae70c..0000000 --- a/src/__tests__/UserValidator-test.js +++ /dev/null @@ -1,57 +0,0 @@ -import UserValidator from '../UserValidator'; - -describe('UserValidator', () => { - let localStorage; - let logger; - let uv; - - beforeEach(() => { - localStorage = {}; - logger = { - warn: jest.fn(), - }; - uv = UserValidator(localStorage, logger); - }); - - it('rejects null user', async () => { - await expect(uv.validateUser(null)).rejects.toThrow(); - }); - - it('leaves user with string key unchanged', async () => { - const u = { key: 'someone', name: 'me' }; - expect(await uv.validateUser(u)).toEqual(u); - }); - - it('stringifies non-string key', async () => { - const u0 = { key: 123, name: 'me' }; - const u1 = { key: '123', name: 'me' }; - expect(await uv.validateUser(u0)).toEqual(u1); - }); - - it('uses cached key for anonymous user', async () => { - const cachedKey = 'thing'; - let storageKey; - localStorage.get = async key => { - storageKey = key; - return cachedKey; - }; - const u = { anonymous: true }; - expect(await uv.validateUser(u)).toEqual({ key: cachedKey, anonymous: true }); - expect(storageKey).toEqual('ld:$anonUserId'); - }); - - it('generates and stores key for anonymous user', async () => { - let storageKey; - let storedValue; - localStorage.get = async () => null; - localStorage.set = async (key, value) => { - storageKey = key; - storedValue = value; - }; - const u0 = { anonymous: true }; - const u1 = await uv.validateUser(u0); - expect(storedValue).toEqual(expect.anything()); - expect(u1).toEqual({ key: storedValue, anonymous: true }); - expect(storageKey).toEqual('ld:$anonUserId'); - }); -}); diff --git a/src/__tests__/attributeReference-test.js b/src/__tests__/attributeReference-test.js new file mode 100644 index 0000000..3669c90 --- /dev/null +++ b/src/__tests__/attributeReference-test.js @@ -0,0 +1,400 @@ +const AttributeReference = require('../attributeReference'); + +describe('when filtering attributes by reference', () => { + it('should be able to remove a top level value', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['ld'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual(['/ld']); + }); + + it('should be able to exclude a nested value', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['/launchdarkly/u2c'] + ); + expect(cloned).toEqual({ + launchdarkly: {}, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual(['/launchdarkly/u2c']); + }); + + it('sould be able to exclude an object', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['launchdarkly'] + ); + expect(cloned).toEqual({ + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual(['/launchdarkly']); + }); + + it('sould be able to exclude an array', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['foo'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual(['/foo']); + }); + + it('should not allow exclude an array index', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['foo/0'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual([]); + }); + + it('should not allow exclude a property inside an index of an array.', () => { + const objWithArrayOfObjects = { + array: [ + { + toRemove: true, + toLeave: true, + }, + ], + }; + + const { cloned, excluded } = AttributeReference.cloneExcluding(objWithArrayOfObjects, ['array/0/toRemove']); + expect(cloned).toEqual(objWithArrayOfObjects); + expect(excluded).toEqual([]); + }); + + it('should not allow exclude the root object', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['/'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual([]); + }); + + it('should allow exclude a null value', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['null'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + }); + expect(excluded).toEqual(['/null']); + }); + + it('should not allow exclude a value inside null', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['/null/null'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual([]); + }); + + it('should not allow exclude a value inside explicit undefined', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['undefined/null'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual([]); + }); + + it('should allow removing an explicit undefined value', () => { + const objToClone = { undefined: undefined }; + const { cloned, excluded } = AttributeReference.cloneExcluding(objToClone, ['undefined']); + expect(cloned).toEqual({}); + expect(excluded).toEqual(['/undefined']); + }); + + it('should allow removing references with escape characters', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['/a~1b', '/m~0n'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + ' ': ' ', + null: null, + }); + expect(excluded).toEqual(['/a~1b', '/m~0n']); + }); + + it('should allow removing literals without escape characters', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + ['a/b', 'm~n'] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + ' ': ' ', + null: null, + }); + expect(excluded).toEqual(['/a~1b', '/m~0n']); + }); + + it('should handle cycles', () => { + const item = {}; + const objWithCycle = { + item, + name: 'test', + remove: 'remove', + }; + item.parent = objWithCycle; + const { cloned, excluded } = AttributeReference.cloneExcluding(objWithCycle, ['remove']); + expect(cloned).toEqual({ + item: {}, + name: 'test', + }); + expect(excluded).toEqual(['/remove']); + }); + + it('should allow non-circular reference and should treat them independently for filtering', () => { + const item = { value: 'value' }; + const objWithSharedPeer = { + item: item, + second: item, + third: item, + fourth: item, + }; + const { cloned, excluded } = AttributeReference.cloneExcluding(objWithSharedPeer, ['third', '/second/value']); + expect(cloned).toEqual({ + item: { value: 'value' }, + second: {}, + fourth: { value: 'value' }, + }); + expect(excluded).toEqual(['/second/value', '/third']); + }); + + it('should allow for an empty reference list', () => { + const { cloned, excluded } = AttributeReference.cloneExcluding( + { + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }, + [] + ); + expect(cloned).toEqual({ + launchdarkly: { + u2c: true, + }, + ld: false, + foo: ['bar', 'baz'], + 'a/b': 1, + 'm~n': 2, + ' ': ' ', + null: null, + }); + expect(excluded).toEqual([]); + }); +}); + +describe('when given a literal', () => { + it('can convert it to a reference', () => { + expect(AttributeReference.literalToReference('/~why')).toEqual('/~1~0why'); + }); +}); diff --git a/src/__tests__/configuration-test.js b/src/__tests__/configuration-test.js index 68369bd..348ea7b 100644 --- a/src/__tests__/configuration-test.js +++ b/src/__tests__/configuration-test.js @@ -44,24 +44,26 @@ describe('configuration', () => { await listener.expectNoErrors(); } - function checkDeprecated(oldName, newName, value) { - const desc = newName - ? 'allows "' + oldName + '" as a deprecated equivalent to "' + newName + '"' - : 'warns that "' + oldName + '" is deprecated'; - it(desc, async () => { - const listener = errorListener(); - const config0 = {}; - config0[oldName] = value; - const config1 = configuration.validate(config0, listener.emitter, null, listener.logger); - if (newName) { - expect(config1[newName]).toBe(value); - expect(config1[oldName]).toBeUndefined(); - } else { - expect(config1[oldName]).toEqual(value); - } - await listener.expectWarningOnly(messages.deprecated(oldName, newName)); - }); - } + // As of the latest major version, there are no deprecated options. This logic can be restored + // the next time we deprecate something. + // function checkDeprecated(oldName, newName, value) { + // const desc = newName + // ? 'allows "' + oldName + '" as a deprecated equivalent to "' + newName + '"' + // : 'warns that "' + oldName + '" is deprecated'; + // it(desc, async () => { + // const listener = errorListener(); + // const config0 = {}; + // config0[oldName] = value; + // const config1 = configuration.validate(config0, listener.emitter, null, listener.logger); + // if (newName) { + // expect(config1[newName]).toBe(value); + // expect(config1[oldName]).toBeUndefined(); + // } else { + // expect(config1[oldName]).toEqual(value); + // } + // await listener.expectWarningOnly(messages.deprecated(oldName, newName)); + // }); + // } function checkBooleanProperty(name) { it('enforces boolean type and default for "' + name + '"', async () => { @@ -100,15 +102,12 @@ describe('configuration', () => { checkBooleanProperty('sendEvents'); checkBooleanProperty('allAttributesPrivate'); checkBooleanProperty('sendLDHeaders'); - checkBooleanProperty('inlineUsersInEvents'); checkBooleanProperty('sendEventsOnlyForVariation'); checkBooleanProperty('useReport'); checkBooleanProperty('evaluationReasons'); checkBooleanProperty('diagnosticOptOut'); checkBooleanProperty('streaming'); - checkDeprecated('allowFrequentDuplicateEvents', undefined, true); - function checkNumericProperty(name, validValue) { it('enforces numeric type and default for "' + name + '"', async () => { await expectDefault(name); diff --git a/src/__tests__/context-test.js b/src/__tests__/context-test.js new file mode 100644 index 0000000..ca24c4a --- /dev/null +++ b/src/__tests__/context-test.js @@ -0,0 +1,201 @@ +const { checkContext, getContextKeys, getContextKinds, getCanonicalKey } = require('../context'); + +describe.each([{ key: 'test' }, { kind: 'user', key: 'test' }, { kind: 'multi', user: { key: 'test' } }])( + 'given a context which contains a single kind', + context => { + it('should get the context kind', () => { + expect(getContextKinds(context)).toEqual(['user']); + }); + + it('should be valid', () => { + expect(checkContext(context, false)).toBeTruthy(); + }); + } +); + +describe('given a valid multi-kind context', () => { + const context = { + kind: 'multi', + user: { + key: 'user', + }, + org: { + key: 'org', + }, + }; + + it('should get a list of the kinds', () => { + expect(getContextKinds(context).sort()).toEqual(['org', 'user']); + }); + + it('should be valid', () => { + expect(checkContext(context, false)).toBeTruthy(); + }); +}); + +// A sample of invalid characters. +const invalidSampleChars = [ + ...`#$%&'()*+,/:;<=>?@[\\]^\`{|}~ ¡¢£¤¥¦§¨©ª«¬­®¯°±² +³´µ¶·¸¹º»¼½¾¿À汉字`, +]; +const badKinds = invalidSampleChars.map(char => ({ kind: char, key: 'test' })); + +describe.each([ + {}, // An empty object is not a valid context. + { key: '' }, // If allowLegacyKey is not true, then this should be invalid. + { kind: 'kind', key: 'kind' }, // The kind cannot be kind. + { kind: 'user' }, // The context needs to have a key. + { kind: 'org', key: '' }, // For a non-legacy context the key cannot be empty. + { kind: ' ', key: 'test' }, // Kind cannot be whitespace only. + { kind: 'cat dog', key: 'test' }, // Kind cannot contain whitespace + { kind: '~!@#$%^&*()_+', key: 'test' }, // Special characters are not valid. + ...badKinds, +])('given invalid contexts', context => { + it('should not be valid', () => { + expect(checkContext(context, false)).toBeFalsy(); + }); +}); + +const validChars = ['0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_.']; +const goodKinds = validChars.map(char => [{ kind: char, key: 'test' }, false]); + +describe.each([ + [{ key: '' }, true], // Allow a legacy context with an empty key. + ...goodKinds, +])('given valid contexts', (context, allowLegacyKey) => { + it('should be valid and can get context kinds', () => { + expect(checkContext(context, allowLegacyKey)).toBeTruthy(); + expect(getContextKinds(context)).toEqual([context.kind || 'user']); + }); +}); + +describe('when determining canonical keys', () => { + it.each([ + [{ key: 'test' }, 'test'], + [{ kind: 'user', key: 'test' }, 'test'], + [{ kind: 'org', key: 'orgtest' }, 'org:orgtest'], + [{ kind: 'multi', user: { key: 'usertest' } }, 'user:usertest'], + [{ kind: 'multi', user: { key: 'usertest' }, org: { key: 'orgtest' } }, 'org:orgtest:user:usertest'], + [{ kind: 'multi', user: { key: 'user:test' }, org: { key: 'org:test' } }, 'org:org%3Atest:user:user%3Atest'], + [{ kind: 'multi', user: { key: 'user%test' }, org: { key: 'org%test' } }, 'org:org%25test:user:user%25test'], + [ + { kind: 'multi', user: { key: 'user%:test' }, org: { key: 'org%:test' } }, + 'org:org%25%3Atest:user:user%25%3Atest', + ], + ])('produces a canonical key for valid contexts', (context, canonicalKey) => { + expect(getCanonicalKey(context)).toEqual(canonicalKey); + }); + + it('does not break with an null/undefined context', () => { + expect(getCanonicalKey(undefined)).toBeUndefined(); + expect(getCanonicalKey(null)).toBeUndefined(); + }); +}); + +describe('getContextKeys', () => { + it('returns undefined if argument is undefined', () => { + const context = undefined; + const keys = getContextKeys(context); + expect(keys).toBeUndefined(); + }); + + it('works with legacy user without kind attribute', () => { + const user = { + key: 'legacy-user-key', + name: 'Test User', + custom: { + customAttribute1: true, + }, + }; + const keys = getContextKeys(user); + expect(keys).toEqual({ user: 'legacy-user-key' }); + }); + + it('gets keys from multi context', () => { + const context = { + kind: 'multi', + user: { + key: 'test-user-key', + name: 'Test User', + isPremiumCustomer: true, + }, + organization: { + key: 'test-organization-key', + name: 'Test Company', + industry: 'technology', + }, + }; + const keys = getContextKeys(context); + expect(keys).toEqual({ user: 'test-user-key', organization: 'test-organization-key' }); + }); + + it('ignores undefined keys from multi context', () => { + const context = { + kind: 'multi', + user: { + key: 'test-user-key', + name: 'Test User', + isPremiumCustomer: true, + }, + organization: { + name: 'Test Company', + industry: 'technology', + }, + rogueAttribute: undefined, + }; + const keys = getContextKeys(context); + expect(keys).toEqual({ user: 'test-user-key' }); + }); + + it.only('ignores empty string and null keys from multi context', () => { + const context = { + kind: 'multi', + user: { + key: 'test-user-key', + name: 'Test User', + isPremiumCustomer: true, + }, + organization: { + key: '', + name: 'Test Company', + industry: 'technology', + }, + drone: { + key: null, + name: 'test-drone', + }, + }; + const keys = getContextKeys(context); + expect(keys).toEqual({ user: 'test-user-key' }); + }); + + it('gets keys from single context', () => { + const context = { + kind: 'drone', + key: 'test-drone-key', + name: 'test-drone', + }; + const keys = getContextKeys(context); + expect(keys).toEqual({ drone: 'test-drone-key' }); + }); + + it('ignores kind when it is an empty string', () => { + const context = { + kind: '', + key: 'test-drone-key', + name: 'test-drone', + }; + const keys = getContextKeys(context); + expect(keys).toEqual({}); + }); + + it('ignores kind when it is null', () => { + const context = { + kind: null, + key: 'test-drone-key', + name: 'test-drone', + }; + const keys = getContextKeys(context); + expect(keys).toEqual({}); + }); +}); diff --git a/src/__tests__/diagnosticEvents-test.js b/src/__tests__/diagnosticEvents-test.js index 7a6e215..6280f76 100644 --- a/src/__tests__/diagnosticEvents-test.js +++ b/src/__tests__/diagnosticEvents-test.js @@ -101,7 +101,6 @@ describe('DiagnosticsManager', () => { }; const defaultConfigInEvent = { allAttributesPrivate: false, - autoAliasingOptOut: false, bootstrapMode: false, customBaseURI: false, customEventsURI: false, @@ -110,7 +109,6 @@ describe('DiagnosticsManager', () => { eventsCapacity: defaultConfig.eventCapacity, eventsFlushIntervalMillis: defaultConfig.flushInterval, fetchGoalsDisabled: false, - inlineUsersInEvents: false, reconnectTimeMillis: defaultConfig.streamReconnectDelay, sendEventsOnlyForVariation: false, streamingDisabled: true, @@ -196,12 +194,10 @@ describe('DiagnosticsManager', () => { [{ eventCapacity: 222 }, { eventsCapacity: 222 }], [{ flushInterval: 2222 }, { eventsFlushIntervalMillis: 2222 }], [{ fetchGoals: false }, { fetchGoalsDisabled: true }], - [{ inlineUsersInEvents: true }, { inlineUsersInEvents: true }], [{ streamReconnectDelay: 2222 }, { reconnectTimeMillis: 2222 }], [{ sendEventsOnlyForVariation: true }, { sendEventsOnlyForVariation: true }], [{ streaming: true }, { streamingDisabled: false }], [{ hash: 'x' }, { usingSecureMode: true }], - [{ autoAliasingOptOut: true }, { autoAliasingOptOut: true }], ]; for (const i in configAndResultValues) { const configOverrides = configAndResultValues[i][0]; diff --git a/src/__tests__/stubPlatform.js b/src/__tests__/stubPlatform.js index 38f0370..aaac5e3 100644 --- a/src/__tests__/stubPlatform.js +++ b/src/__tests__/stubPlatform.js @@ -83,14 +83,14 @@ export function defaults() { eventSourcesCreated, - makeClient: (env, user, options = {}) => { + makeClient: (env, context, options = {}) => { const config = { logger: p.testing.logger, ...options }; // We want to simulate what the platform-specific SDKs will do in their own initialization functions. // They will call the common package's LDClient.initialize() and receive the clientVars object which // contains both the underlying client (in its "client" property) and some internal methods that the // platform-specific SDKs can use to do internal stuff. One of those is start(), which they will // call after doing any other initialization things they may need to do. - const clientVars = LDClient.initialize(env, user, config, p); + const clientVars = LDClient.initialize(env, context, config, p); clientVars.start(); return clientVars.client; }, diff --git a/src/__tests__/testUtils.js b/src/__tests__/testUtils.js index 862cbef..871ef22 100644 --- a/src/__tests__/testUtils.js +++ b/src/__tests__/testUtils.js @@ -2,7 +2,6 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; export const numericUser = { key: 1, - secondary: 2, ip: 3, country: 4, email: 5, @@ -33,7 +32,6 @@ export function promiseListener() { export const stringifiedNumericUser = { key: '1', - secondary: '2', ip: '3', country: '4', email: '5', @@ -64,7 +62,7 @@ export function MockEventSender() { calls, sendEvents: (events, url) => { calls.add({ events, url }); - return Promise.resolve({ serverTime, status }); + return Promise.resolve([{ serverTime, status }]); }, setServerTime: time => { serverTime = time; diff --git a/src/__tests__/utils-test.js b/src/__tests__/utils-test.js index 2429767..0c21be3 100644 --- a/src/__tests__/utils-test.js +++ b/src/__tests__/utils-test.js @@ -1,10 +1,4 @@ -import { - appendUrlPath, - base64URLEncode, - getLDUserAgentString, - wrapPromiseCallback, - chunkUserEventsForUrl, -} from '../utils'; +import { appendUrlPath, base64URLEncode, chunkEventsForUrl, getLDUserAgentString, wrapPromiseCallback } from '../utils'; import * as stubPlatform from './stubPlatform'; @@ -70,13 +64,13 @@ describe('utils', () => { }); }); - describe('chunkUserEventsForUrl', () => { + describe('chunkEventsForUrl', () => { it('should properly chunk the list of events', () => { - const user = { key: 'foo' }; - const event = { kind: 'identify', key: user.key }; + const context = { key: 'foo', kind: 'user' }; + const event = { kind: 'identify', key: context.key }; const eventLength = base64URLEncode(JSON.stringify(event)).length; const events = [event, event, event, event, event]; - const chunks = chunkUserEventsForUrl(eventLength * 2, events); + const chunks = chunkEventsForUrl(eventLength * 2, events); expect(chunks).toEqual([[event, event], [event, event], [event]]); }); }); diff --git a/src/attributeReference.js b/src/attributeReference.js new file mode 100644 index 0000000..e3921de --- /dev/null +++ b/src/attributeReference.js @@ -0,0 +1,143 @@ +/** + * Take a key string and escape the characters to allow it to be used as a reference. + * @param {string} key + * @returns {string} The processed key. + */ +function processEscapeCharacters(key) { + return key.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** + * @param {string} reference The reference to get the components of. + * @returns {string[]} The components of the reference. Escape characters will be converted to their representative values. + */ +function getComponents(reference) { + const referenceWithoutPrefix = reference.startsWith('/') ? reference.substring(1) : reference; + return referenceWithoutPrefix + .split('/') + .map(component => (component.indexOf('~') >= 0 ? component.replace(/~1/g, '/').replace(/~0/g, '~') : component)); +} + +/** + * @param {string} reference The reference to check if it is a literal. + * @returns true if the reference is a literal. + */ +function isLiteral(reference) { + return !reference.startsWith('/'); +} + +/** + * Compare two references and determine if they are equivalent. + * @param {string} a + * @param {string} b + */ +function compare(a, b) { + const aIsLiteral = isLiteral(a); + const bIsLiteral = isLiteral(b); + if (aIsLiteral && bIsLiteral) { + return a === b; + } + if (aIsLiteral) { + const bComponents = getComponents(b); + if (bComponents.length !== 1) { + return false; + } + return a === bComponents[0]; + } + if (bIsLiteral) { + const aComponents = getComponents(a); + if (aComponents.length !== 1) { + return false; + } + return b === aComponents[0]; + } + return a === b; +} + +/** + * @param {string} a + * @param {string} b + * @returns The two strings joined by '/'. + */ +function join(a, b) { + return `${a}/${b}`; +} + +/** + * There are cases where a field could have been named with a preceeding '/'. + * If that attribute was private, then the literal would appear to be a reference. + * This method can be used to convert a literal to a reference in such situations. + * @param {string} literal The literal to convert to a reference. + * @returns A literal which has been converted to a reference. + */ +function literalToReference(literal) { + return `/${processEscapeCharacters(literal)}`; +} + +/** + * Clone an object excluding the values referenced by a list of references. + * @param {Object} target The object to clone. + * @param {string[]} references A list of references from the cloned object. + * @returns {{cloned: Object, excluded: string[]}} The cloned object and a list of excluded values. + */ +function cloneExcluding(target, references) { + const stack = []; + const cloned = {}; + const excluded = []; + + stack.push( + ...Object.keys(target).map(key => ({ + key, + ptr: literalToReference(key), + source: target, + parent: cloned, + visited: [target], + })) + ); + + while (stack.length) { + const item = stack.pop(); + if (!references.some(ptr => compare(ptr, item.ptr))) { + const value = item.source[item.key]; + + // Handle null because it overlaps with object, which we will want to handle later. + if (value === null) { + item.parent[item.key] = value; + } else if (Array.isArray(value)) { + item.parent[item.key] = [...value]; + } else if (typeof value === 'object') { + //Arrays and null must already be handled. + + //Prevent cycles by not visiting the same object + //with in the same branch. Parallel branches + //may contain the same object. + if (item.visited.includes(value)) { + continue; + } + + item.parent[item.key] = {}; + + stack.push( + ...Object.keys(value).map(key => ({ + key, + ptr: join(item.ptr, processEscapeCharacters(key)), + source: value, + parent: item.parent[item.key], + visited: [...item.visited, value], + })) + ); + } else { + item.parent[item.key] = value; + } + } else { + excluded.push(item.ptr); + } + } + return { cloned, excluded: excluded.sort() }; +} + +module.exports = { + cloneExcluding, + compare, + literalToReference, +}; diff --git a/src/configuration.js b/src/configuration.js index 09abb49..5e98680 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -20,8 +20,6 @@ const baseOptionDefs = { streaming: { type: 'boolean' }, // default for this is undefined, which is different from false sendLDHeaders: { default: true }, requestHeaderTransform: { type: 'function' }, - inlineUsersInEvents: { default: false }, - allowFrequentDuplicateEvents: { default: false }, sendEventsOnlyForVariation: { default: false }, useReport: { default: false }, evaluationReasons: { default: false }, @@ -30,14 +28,13 @@ const baseOptionDefs = { samplingInterval: { default: 0, minimum: 0 }, streamReconnectDelay: { default: 1000, minimum: 0 }, allAttributesPrivate: { default: false }, - privateAttributeNames: { default: [] }, + privateAttributes: { default: [] }, bootstrap: { type: 'string|object' }, diagnosticRecordingInterval: { default: 900000, minimum: 2000 }, diagnosticOptOut: { default: false }, wrapperName: { type: 'string' }, wrapperVersion: { type: 'string' }, stateProvider: { type: 'object' }, // not a public option, used internally - autoAliasingOptOut: { default: false }, application: { validator: applicationConfigValidator }, inspectors: { default: [] }, }; @@ -47,6 +44,10 @@ const baseOptionDefs = { */ const allowedTagCharacters = /^(\w|\.|-)+$/; +function canonicalizeUrl(url) { + return url?.replace(/\/+$/, ''); +} + /** * Verify that a value meets the requirements for a tag value. * @param {string} tagValue @@ -79,10 +80,9 @@ function validate(options, emitter, extraOptionDefs, logger) { const optionDefs = utils.extend({ logger: { default: logger } }, baseOptionDefs, extraOptionDefs); const deprecatedOptions = { - // The property name is the deprecated name, and the property value is the preferred name if - // any, or null/undefined if there is no replacement. This should be removed, along with - // the option, in the next major version. - allowFrequentDuplicateEvents: undefined, + // As of the latest major version, there are no deprecated options. Next time we deprecate + // something, add an item here where the property name is the deprecated name, and the + // property value is the preferred name if any, or null/undefined if there is no replacement. }; function checkDeprecatedOptions(config) { @@ -169,6 +169,11 @@ function validate(options, emitter, extraOptionDefs, logger) { } } }); + + ret.baseUrl = canonicalizeUrl(ret.baseUrl); + ret.streamUrl = canonicalizeUrl(ret.streamUrl); + ret.eventsUrl = canonicalizeUrl(ret.eventsUrl); + return ret; } diff --git a/src/context.js b/src/context.js new file mode 100644 index 0000000..06b1b3b --- /dev/null +++ b/src/context.js @@ -0,0 +1,134 @@ +/** + * Validate a context kind. + * @param {string} kind + * @returns true if the kind is valid. + */ +const { commonBasicLogger } = require('./loggers'); + +function validKind(kind) { + return typeof kind === 'string' && kind !== 'kind' && kind.match(/^(\w|\.|-)+$/); +} + +/** + * Perform a check of basic context requirements. + * @param {Object} context + * @param {boolean} allowLegacyKey If true, then a legacy user can have an + * empty or non-string key. A legacy user is a context without a kind. + * @returns true if the context meets basic requirements. + */ +function checkContext(context, allowLegacyKey) { + if (context) { + if (allowLegacyKey && (context.kind === undefined || context.kind === null)) { + return context.key !== undefined && context.key !== null; + } + const key = context.key; + const kind = context.kind === undefined ? 'user' : context.kind; + const kindValid = validKind(kind); + const keyValid = kind === 'multi' || (key !== undefined && key !== null && key !== ''); + if (kind === 'multi') { + const kinds = Object.keys(context).filter(key => key !== 'kind'); + return ( + keyValid && + kinds.every(key => validKind(key)) && + kinds.every(key => { + const contextKey = context[key].key; + return contextKey !== undefined && contextKey !== null && contextKey !== ''; + }) + ); + } + return keyValid && kindValid; + } + return false; +} + +/** + * For a given context get a list of context kinds. + * @param {Object} context + * @returns A list of kinds in the context. + */ +function getContextKinds(context) { + if (context) { + if (context.kind === null || context.kind === undefined) { + return ['user']; + } + if (context.kind !== 'multi') { + return [context.kind]; + } + return Object.keys(context).filter(kind => kind !== 'kind'); + } + return []; +} + +/** + * The partial URL encoding is needed because : is a valid character in context keys. + * + * Partial encoding is the replacement of all colon (:) characters with the URL + * encoded equivalent (%3A) and all percent (%) characters with the URL encoded + * equivalent (%25). + * @param {string} key The key to encode. + * @returns {string} Partially URL encoded key. + */ +function encodeKey(key) { + if (key.includes('%') || key.includes(':')) { + return key.replace(/%/g, '%25').replace(/:/g, '%3A'); + } + return key; +} + +function getCanonicalKey(context) { + if (context) { + if ((context.kind === undefined || context.kind === null || context.kind === 'user') && context.key) { + return context.key; + } else if (context.kind !== 'multi' && context.key) { + return `${context.kind}:${encodeKey(context.key)}`; + } else if (context.kind === 'multi') { + return Object.keys(context) + .sort() + .filter(key => key !== 'kind') + .map(key => `${key}:${encodeKey(context[key].key)}`) + .join(':'); + } + } +} + +function getContextKeys(context, logger = commonBasicLogger()) { + if (!context) { + return undefined; + } + + const keys = {}; + const { kind, key } = context; + + switch (kind) { + case undefined: + keys.user = `${key}`; + break; + case 'multi': + Object.entries(context) + .filter(([key]) => key !== 'kind') + .forEach(([key, value]) => { + if (value?.key) { + keys[key] = value.key; + } + }); + break; + case null: + logger.warn(`null is not a valid context kind: ${context}`); + break; + case '': + logger.warn(`'' is not a valid context kind: ${context}`); + break; + default: + keys[kind] = `${key}`; + break; + } + + return keys; +} + +module.exports = { + checkContext, + getContextKeys, + getContextKinds, + getCanonicalKey, +}; diff --git a/src/diagnosticEvents.js b/src/diagnosticEvents.js index 8be6ca9..4d7ad19 100644 --- a/src/diagnosticEvents.js +++ b/src/diagnosticEvents.js @@ -1,6 +1,6 @@ const { v1: uuidv1 } = require('uuid'); // Note that in the diagnostic events spec, these IDs are to be generated with UUID v4. However, -// in JS we were already using v1 for unique user keys, so to avoid bringing in two packages we +// in JS we were already using v1 for unique context keys, so to avoid bringing in two packages we // will use v1 here as well. const { baseOptionDefs } = require('./configuration'); @@ -195,14 +195,12 @@ function DiagnosticsManager( reconnectTimeMillis: config.streamReconnectDelay, streamingDisabled: !streamingEnabled, allAttributesPrivate: !!config.allAttributesPrivate, - inlineUsersInEvents: !!config.inlineUsersInEvents, diagnosticRecordingIntervalMillis: config.diagnosticRecordingInterval, // The following extra properties are only provided by client-side JS SDKs: usingSecureMode: !!config.hash, bootstrapMode: !!config.bootstrap, fetchGoalsDisabled: !config.fetchGoals, sendEventsOnlyForVariation: !!config.sendEventsOnlyForVariation, - autoAliasingOptOut: !!config.autoAliasingOptOut, }; // Client-side JS SDKs do not have the following properties which other SDKs have: // connectTimeoutMillis diff --git a/src/index.js b/src/index.js index bd61e57..455bf93 100644 --- a/src/index.js +++ b/src/index.js @@ -7,13 +7,14 @@ const PersistentStorage = require('./PersistentStorage'); const Stream = require('./Stream'); const Requestor = require('./Requestor'); const Identity = require('./Identity'); -const UserValidator = require('./UserValidator'); +const AnonymousContextProcessor = require('./AnonymousContextProcessor'); const configuration = require('./configuration'); const diagnostics = require('./diagnosticEvents'); const { commonBasicLogger } = require('./loggers'); const utils = require('./utils'); const errors = require('./errors'); const messages = require('./messages'); +const { checkContext, getContextKeys } = require('./context'); const { InspectorTypes, InspectorManager } = require('./InspectorManager'); const changeEvent = 'change'; @@ -28,7 +29,7 @@ const internalChangeEvent = 'internal-change'; // // For definitions of the API in the platform object, see stubPlatform.js in the test code. -function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { +function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const logger = createLogger(); const emitter = EventEmitter(logger); const initializationStateTracker = InitializationStateTracker(emitter); @@ -77,19 +78,19 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { // The "stateProvider" object is used in the Electron SDK, to allow one client instance to take partial // control of another. If present, it has the following contract: // - getInitialState() returns the initial client state if it is already available. The state is an - // object whose properties are "environment", "user", and "flags". + // object whose properties are "environment", "context", and "flags". // - on("init", listener) triggers an event when the initial client state becomes available, passing // the state object to the listener. - // - on("update", listener) triggers an event when flag values change and/or the current user changes. - // The parameter is an object that *may* contain "user" and/or "flags". + // - on("update", listener) triggers an event when flag values change and/or the current context changes. + // The parameter is an object that *may* contain "context" and/or "flags". // - enqueueEvent(event) accepts an analytics event object and returns true if the stateProvider will // be responsible for delivering it, or false if we still should deliver it ourselves. const stateProvider = options.stateProvider; const ident = Identity(null, onIdentifyChange); - const userValidator = UserValidator(persistentStorage); + const anonymousContextProcessor = new AnonymousContextProcessor(persistentStorage); const persistentFlagStore = persistentStorage.isEnabled() - ? new PersistentFlagStore(persistentStorage, environment, hash, ident, logger) + ? PersistentFlagStore(persistentStorage, environment, hash, ident, logger) : null; function createLogger() { @@ -134,22 +135,22 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { function enqueueEvent(event) { if (!environment) { - // We're in paired mode and haven't been initialized with an environment or user yet + // We're in paired mode and haven't been initialized with an environment or context yet return; } if (stateProvider && stateProvider.enqueueEvent && stateProvider.enqueueEvent(event)) { return; // it'll be handled elsewhere } - if (event.kind !== 'alias') { - if (!event.user) { - if (firstEvent) { - logger.warn(messages.eventWithoutUser()); - firstEvent = false; - } - return; + + if (!event.context) { + if (firstEvent) { + logger.warn(messages.eventWithoutContext()); + firstEvent = false; } - firstEvent = false; + return; } + firstEvent = false; + if (shouldEnqueueEvent()) { logger.debug(messages.debugEnqueueingEvent(event.kind)); events.enqueue(event); @@ -158,13 +159,13 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { function notifyInspectionFlagUsed(key, detail) { if (inspectorManager.hasListeners(InspectorTypes.flagUsed)) { - inspectorManager.onFlagUsed(key, detail, ident.getUser()); + inspectorManager.onFlagUsed(key, detail, ident.getContext()); } } function notifyInspectionIdentityChanged() { if (inspectorManager.hasListeners(InspectorTypes.clientIdentityChanged)) { - inspectorManager.onIdentityChanged(ident.getUser()); + inspectorManager.onIdentityChanged(ident.getContext()); } } @@ -188,46 +189,39 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { } } - function onIdentifyChange(user, previousUser) { - sendIdentifyEvent(user); - if (!options.autoAliasingOptOut && previousUser && previousUser.anonymous && user && !user.anonymous) { - alias(user, previousUser); - } + function onIdentifyChange(context) { + sendIdentifyEvent(context); notifyInspectionIdentityChanged(); } - function sendIdentifyEvent(user) { + function sendIdentifyEvent(context) { if (stateProvider) { // In paired mode, the other client is responsible for sending identify events return; } - if (user) { + if (context) { enqueueEvent({ kind: 'identify', - key: user.key, - user: user, + context, creationDate: new Date().getTime(), }); } } function sendFlagEvent(key, detail, defaultValue, includeReason) { - const user = ident.getUser(); + const context = ident.getContext(); const now = new Date(); const value = detail ? detail.value : null; const event = { kind: 'feature', key: key, - user: user, + context, value: value, variation: detail ? detail.variationIndex : null, default: defaultValue, creationDate: now.getTime(), }; - if (user && user.anonymous) { - event.contextKind = userContextKind(user); - } const flag = flags[key]; if (flag) { event.version = flag.flagVersion ? flag.flagVersion : flag.version; @@ -241,26 +235,37 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { enqueueEvent(event); } - function identify(user, newHash, onDone) { + function verifyContext(context) { + // The context will already have been processed to have a string key, so we + // do not need to allow for legacy keys in the check. + if (checkContext(context, false)) { + return Promise.resolve(context); + } else { + return Promise.reject(new errors.LDInvalidUserError(messages.invalidContext())); + } + } + + function identify(context, newHash, onDone) { if (closed) { return utils.wrapPromiseCallback(Promise.resolve({}), onDone); } if (stateProvider) { - // We're being controlled by another client instance, so only that instance is allowed to change the user + // We're being controlled by another client instance, so only that instance is allowed to change the context logger.warn(messages.identifyDisabled()); return utils.wrapPromiseCallback(Promise.resolve(utils.transformVersionedValuesToValues(flags)), onDone); } const clearFirst = useLocalStorage && persistentFlagStore ? persistentFlagStore.clearFlags() : Promise.resolve(); return utils.wrapPromiseCallback( clearFirst - .then(() => userValidator.validateUser(user)) - .then(realUser => + .then(() => anonymousContextProcessor.processContext(context)) + .then(verifyContext) + .then(validatedContext => requestor - .fetchFlagSettings(realUser, newHash) + .fetchFlagSettings(validatedContext, newHash) // the following then() is nested within this one so we can use realUser from the previous closure .then(requestedFlags => { const flagValueMap = utils.transformVersionedValuesToValues(requestedFlags); - ident.setUser(realUser); + ident.setContext(validatedContext); hash = newHash; if (requestedFlags) { return replaceAllFlags(requestedFlags).then(() => flagValueMap); @@ -283,8 +288,8 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { ); } - function getUser() { - return ident.getUser(); + function getContext() { + return ident.getContext(); } function flush(onDone) { @@ -355,26 +360,6 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { return user.anonymous ? 'anonymousUser' : 'user'; } - function alias(user, previousUser) { - if (stateProvider) { - // In paired mode, the other client is responsible for sending alias events - return; - } - - if (!user || !previousUser) { - return; - } - - enqueueEvent({ - kind: 'alias', - key: user.key, - contextKind: userContextKind(user), - previousKey: previousUser.key, - previousContextKind: userContextKind(previousUser), - creationDate: new Date().getTime(), - }); - } - function track(key, data, metricValue) { if (typeof key !== 'string') { emitter.maybeReportError(new errors.LDInvalidEventKeyError(messages.unknownCustomEventKey(key))); @@ -390,16 +375,16 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { logger.warn(messages.unknownCustomEventKey(key)); } - const user = ident.getUser(); + const context = ident.getContext(); const e = { kind: 'custom', key: key, - user: user, + context, url: platform.getCurrentUrl(), creationDate: new Date().getTime(), }; - if (user && user.anonymous) { - e.contextKind = userContextKind(user); + if (context && context.anonymous) { + e.contextKind = userContextKind(context); } // Note, check specifically for null/undefined because it is legal to set these fields to a falsey value. if (data !== null && data !== undefined) { @@ -413,7 +398,7 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { function connectStream() { streamActive = true; - if (!ident.getUser()) { + if (!ident.getContext()) { return; } const tryParseData = jsonData => { @@ -424,16 +409,16 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { return undefined; } }; - stream.connect(ident.getUser(), hash, { + stream.connect(ident.getContext(), hash, { ping: function() { logger.debug(messages.debugStreamPing()); - const userAtTimeOfPingEvent = ident.getUser(); + const contextAtTimeOfPingEvent = ident.getContext(); requestor - .fetchFlagSettings(userAtTimeOfPingEvent, hash) + .fetchFlagSettings(contextAtTimeOfPingEvent, hash) .then(requestedFlags => { - // Check whether the current user is still the same - we don't want to overwrite the flags if + // Check whether the current context is still the same - we don't want to overwrite the flags if // the application has called identify() while this request was in progress - if (utils.deepEquals(userAtTimeOfPingEvent, ident.getUser())) { + if (utils.deepEquals(contextAtTimeOfPingEvent, ident.getContext())) { replaceAllFlags(requestedFlags || {}); } }) @@ -663,17 +648,20 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { if (!env) { return Promise.reject(new errors.LDInvalidEnvironmentIdError(messages.environmentNotSpecified())); } - return userValidator.validateUser(user).then(realUser => { - ident.setUser(realUser); - if (typeof options.bootstrap === 'object') { - // flags have already been set earlier - return signalSuccessfulInit(); - } else if (useLocalStorage) { - return finishInitWithLocalStorage(); - } else { - return finishInitWithPolling(); - } - }); + return anonymousContextProcessor + .processContext(context) + .then(verifyContext) + .then(validatedContext => { + ident.setContext(validatedContext); + if (typeof options.bootstrap === 'object') { + // flags have already been set earlier + return signalSuccessfulInit(); + } else if (useLocalStorage) { + return finishInitWithLocalStorage(); + } else { + return finishInitWithPolling(); + } + }); } function finishInitWithLocalStorage() { @@ -681,7 +669,7 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { if (storedFlags === null || storedFlags === undefined) { flags = {}; return requestor - .fetchFlagSettings(ident.getUser(), hash) + .fetchFlagSettings(ident.getContext(), hash) .then(requestedFlags => replaceAllFlags(requestedFlags || {})) .then(signalSuccessfulInit) .catch(err => { @@ -696,7 +684,7 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { utils.onNextTick(signalSuccessfulInit); return requestor - .fetchFlagSettings(ident.getUser(), hash) + .fetchFlagSettings(ident.getContext(), hash) .then(requestedFlags => replaceAllFlags(requestedFlags)) .catch(err => emitter.maybeReportError(err)); } @@ -705,7 +693,7 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { function finishInitWithPolling() { return requestor - .fetchFlagSettings(ident.getUser(), hash) + .fetchFlagSettings(ident.getContext(), hash) .then(requestedFlags => { flags = requestedFlags || {}; @@ -721,14 +709,14 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { function initFromStateProvider(state) { environment = state.environment; - ident.setUser(state.user); + ident.setContext(state.context); flags = { ...state.flags }; utils.onNextTick(signalSuccessfulInit); } function updateFromStateProvider(state) { - if (state.user) { - ident.setUser(state.user); + if (state.context) { + ident.setContext(state.context); } if (state.flags) { replaceAllFlags(state.flags); // don't wait for this Promise to be resolved @@ -788,11 +776,10 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { waitForInitialization: () => initializationStateTracker.getInitializationPromise(), waitUntilReady: () => initializationStateTracker.getReadyPromise(), identify: identify, - getUser: getUser, + getContext: getContext, variation: variation, variationDetail: variationDetail, track: track, - alias: alias, on: on, off: off, setStreaming: setStreaming, @@ -805,7 +792,7 @@ function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { client: client, // The client object containing all public methods. options: options, // The validated configuration object, including all defaults. emitter: emitter, // The event emitter which can be used to log errors or trigger events. - ident: ident, // The Identity object that manages the current user. + ident: ident, // The Identity object that manages the current context. logger: logger, // The logging abstraction. requestor: requestor, // The Requestor object. start: start, // Starts the client once the environment is ready. @@ -822,4 +809,5 @@ module.exports = { errors, messages, utils, + getContextKeys, }; diff --git a/src/messages.js b/src/messages.js index 0727e87..79edae9 100644 --- a/src/messages.js +++ b/src/messages.js @@ -25,7 +25,7 @@ const eventCapacityExceeded = function() { return 'Exceeded event queue capacity. Increase capacity to avoid dropping events.'; }; -const eventWithoutUser = function() { +const eventWithoutContext = function() { return 'Be sure to call `identify` in the LaunchDarkly client: https://docs.launchdarkly.com/sdk/features/identify#javascript'; }; @@ -60,12 +60,12 @@ const errorFetchingFlags = function(err) { return 'Error fetching flag settings: ' + errorString(err); }; -const userNotSpecified = function() { - return 'No user specified.' + docLink; +const contextNotSpecified = function() { + return 'No context specified.' + docLink; }; -const invalidUser = function() { - return 'Invalid user specified.' + docLink; +const invalidContext = function() { + return 'Invalid context specified.' + docLink; }; const invalidData = function() { @@ -210,7 +210,7 @@ module.exports = { environmentNotSpecified, errorFetchingFlags, eventCapacityExceeded, - eventWithoutUser, + eventWithoutContext, httpErrorMessage, httpUnavailable, identifyDisabled, @@ -219,8 +219,8 @@ module.exports = { invalidData, invalidInspector, invalidKey, + invalidContext, invalidTagValue, - invalidUser, localStorageUnavailable, networkError, optionBelowMinimum, @@ -230,8 +230,8 @@ module.exports = { tagValueTooLong, unknownCustomEventKey, unknownOption, + contextNotSpecified, unrecoverableStreamError, - userNotSpecified, wrongOptionType, wrongOptionTypeBoolean, }; diff --git a/src/utils.js b/src/utils.js index 55182db..7fc19ad 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ const base64 = require('base64-js'); const fastDeepEqual = require('fast-deep-equal'); -const userAttrsToStringify = ['key', 'secondary', 'ip', 'country', 'email', 'firstName', 'lastName', 'avatar', 'name']; +const userAttrsToStringify = ['key', 'ip', 'country', 'email', 'firstName', 'lastName', 'avatar', 'name']; function appendUrlPath(baseUrl, path) { // Ensure that URL concatenation is done correctly regardless of whether the @@ -120,7 +120,7 @@ function transformVersionedValuesToValues(flagsState) { * @param {Array[Object}]} events queue of events to divide * @returns Array[Array[Object]] */ -function chunkUserEventsForUrl(maxLength, events) { +function chunkEventsForUrl(maxLength, events) { const allEvents = events.slice(0); const allChunks = []; let remainingSpace = maxLength; @@ -165,34 +165,37 @@ function objectHasOwnProperty(object, name) { return Object.prototype.hasOwnProperty.call(object, name); } -function sanitizeUser(user) { - if (!user) { - return user; +function sanitizeContext(context) { + if (!context) { + return context; } - let newUser; - for (const i in userAttrsToStringify) { - const attr = userAttrsToStringify[i]; - const value = user[attr]; - if (value !== undefined && typeof value !== 'string') { - newUser = newUser || { ...user }; - newUser[attr] = String(value); - } + let newContext; + // Only stringify user attributes for legacy users. + if (context.kind === null || context.kind === undefined) { + userAttrsToStringify.forEach(attr => { + const value = context[attr]; + if (value !== undefined && typeof value !== 'string') { + newContext = newContext || { ...context }; + newContext[attr] = String(value); + } + }); } - return newUser || user; + + return newContext || context; } module.exports = { appendUrlPath, base64URLEncode, btoa, - chunkUserEventsForUrl, + chunkEventsForUrl, clone, deepEquals, extend, getLDUserAgentString, objectHasOwnProperty, onNextTick, - sanitizeUser, + sanitizeContext, transformValuesToVersionedValues, transformVersionedValuesToValues, wrapPromiseCallback, diff --git a/test-types.ts b/test-types.ts index c6c1564..90fe996 100644 --- a/test-types.ts +++ b/test-types.ts @@ -7,9 +7,8 @@ import * as ld from 'launchdarkly-js-sdk-common'; var userWithKeyOnly: ld.LDUser = { key: 'user' }; var anonUserWithNoKey: ld.LDUser = { anonymous: true }; var anonUserWithKey: ld.LDUser = { key: 'anon-user', anonymous: true }; -var user: ld.LDUser = { +var user: ld.LDContext = { key: 'user', - secondary: 'otherkey', name: 'name', firstName: 'first', lastName: 'last', @@ -41,9 +40,7 @@ var allBaseOptions: ld.LDOptionsBase = { evaluationReasons: true, sendEvents: true, allAttributesPrivate: true, - privateAttributeNames: [ 'x' ], - inlineUsersInEvents: true, - allowFrequentDuplicateEvents: true, + privateAttributes: [ 'x' ], sendEventsOnlyForVariation: true, flushInterval: 1, streamReconnectDelay: 1, @@ -63,9 +60,7 @@ client.identify(user).then(() => {}); client.identify(user, undefined, () => {}); client.identify(user, 'hash').then(() => {}); -client.alias(user, anonUserWithKey); - -var user: ld.LDUser = client.getUser(); +var user: ld.LDContext = client.getContext(); client.flush(() => {}); client.flush().then(() => {}); @@ -96,3 +91,5 @@ var flagSetValue: ld.LDFlagValue = flagSet['key']; client.close(() => {}); client.close().then(() => {}); + +var contextKeys = ld.getContextKeys(user); diff --git a/typings.d.ts b/typings.d.ts index 54153bd..20fb876 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -58,8 +58,8 @@ declare module 'launchdarkly-js-sdk-common' { * 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 [[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 [[variation]] + * 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). * @@ -93,7 +93,7 @@ declare module 'launchdarkly-js-sdk-common' { * * 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 [[LDClient.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. */ @@ -103,9 +103,9 @@ declare module 'launchdarkly-js-sdk-common' { * 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 user object. + * including a JSON entity body with the context object. * - * Otherwise (by default) a GET request will be issued with the user passed as + * 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. @@ -139,7 +139,7 @@ declare module 'launchdarkly-js-sdk-common' { * calculated. * * The additional information will then be available through the client's - * [[LDClient.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; @@ -150,7 +150,7 @@ declare module 'launchdarkly-js-sdk-common' { sendEvents?: boolean; /** - * Whether all user attributes (except the user key) should be marked as private, and + * 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. @@ -158,30 +158,23 @@ declare module 'launchdarkly-js-sdk-common' { allAttributesPrivate?: boolean; /** - * The names of user attributes that should be marked as private, and not sent - * to LaunchDarkly in analytics events. You can also specify this on a per-user basis - * with [[LDUser.privateAttributeNames]]. - */ - privateAttributeNames?: Array; - - /** - * Whether to include full user details in every analytics event. - * - * The default is `false`: events will only include the user key, except for one "index" event - * that provides the full details for the user. - */ - inlineUsersInEvents?: boolean; - - /** - * This option is deprecated, and setting it has no effect. - * - * The behavior is now to allow frequent duplicate events. - * - * This is not a problem because most events will be summarized, and - * events which are not summarized are important to the operation of features such as - * experimentation. + * 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". */ - allowFrequentDuplicateEvents?: boolean; + privateAttributes?: Array; /** * Whether analytics events should be sent only when you call variation (true), or also when you @@ -216,7 +209,7 @@ declare module 'launchdarkly-js-sdk-common' { * 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 [[streaming]] to true or + * 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; @@ -235,7 +228,7 @@ declare module 'launchdarkly-js-sdk-common' { /** * 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 [[diagnosticOptOut]] + * 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; @@ -255,14 +248,6 @@ declare module 'launchdarkly-js-sdk-common' { */ wrapperVersion?: string; - /** - * Whether to disable the automatic sending of an alias event when [[identify]] is - * called with a non-anonymous user when the previous user is anonymous. - * - * The default value is `false`. - */ - autoAliasingOptOut?: boolean; - /** * Information about the application where the LaunchDarkly SDK is running. */ @@ -294,8 +279,203 @@ declare module 'launchdarkly-js-sdk-common' { inspectors?: LDInspection[]; } + /** + * Meta attributes are used to control behavioral aspects of the Context. + * They cannot be addressed in targeting rules. + */ + export interface LDContextMeta { + + /** + * + * Designate any number of Context attributes, or properties within them, as private: that is, + * their values will not be sent to LaunchDarkly in analytics events. + * + * Each parameter can be a simple attribute name, such as "email". Or, if the first character is + * a slash, the parameter is interpreted as a slash-delimited path to a property within a JSON + * object, where the first path component is a Context attribute name and each following + * component is a nested property name: for example, suppose the attribute "address" had the + * following JSON object value: + * + * ``` + * {"street": {"line1": "abc", "line2": "def"}} + * ``` + * + * Using ["/address/street/line1"] in this case would cause the "line1" property to be marked as + * private. This syntax deliberately resembles JSON Pointer, but other JSON Pointer features + * such as array indexing are not supported for Private. + * + * This action only affects analytics events that involve this particular Context. To mark some + * (or all) Context attributes as private for all users, use the overall configuration for the + * SDK. + * See {@link LDOptions.allAttributesPrivate} and {@link LDOptions.privateAttributes}. + * + * The attributes "kind" and "key", and the "_meta" attributes cannot be made private. + * + * In this example, firstName is marked as private, but lastName is not: + * + * ``` + * const context = { + * kind: 'org', + * key: 'my-key', + * firstName: 'Pierre', + * lastName: 'Menard', + * _meta: { + * privateAttributes: ['firstName'], + * } + * }; + * ``` + * + * This is a metadata property, rather than an attribute that can be addressed in evaluations: + * that is, a rule clause that references the attribute name "privateAttributes", will not use + * this value, but would use a "privateAttributes" attribute set on the context. + */ + privateAttributes?: string[]; + } + + /** + * Interface containing elements which are common to both single kind contexts as well as the + * parts that compose a multi context. For more information see {@link LDSingleKindContext} and + * {@link LDMultiKindContext}. + */ + interface LDContextCommon { + /** + * If true, the context will _not_ appear on the Contexts page in the LaunchDarkly dashboard. + */ + anonymous?: boolean; + + /** + * A unique string identifying a context. + */ + key: string; + + /** + * The context's name. + * + * You can search for contexts on the Contexts page by name. + */ + name?: string; + + /** + * Meta attributes are used to control behavioral aspects of the Context, such as private + * private attributes. See {@link LDContextMeta.privateAttributes} as an example. + * + * They cannot be addressed in targeting rules. + */ + _meta?: LDContextMeta; + + /** + * Any additional attributes associated with the context. + */ + [attribute: string]: any; + } + + + /** + * A context which represents a single kind. + * + * For a single kind context the 'kind' may not be 'multi'. + * + * ``` + * const myOrgContext = { + * kind: 'org', + * key: 'my-org-key', + * someAttribute: 'my-attribute-value' + * }; + * ``` + * + * The above context would be a single kind context representing an organization. It has a key + * for that organization, and a single attribute 'someAttribute'. + */ + interface LDSingleKindContext extends LDContextCommon { + /** + * The kind of the context. + */ + kind: string; + } + + /** + * A context which represents multiple kinds. Each kind having its own key and attributes. + * + * A multi-context must contain `kind: 'multi'` at the root. + * + * ``` + * const myMultiContext = { + * // Multi-contexts must be of kind 'multi'. + * kind: 'multi', + * // The context is namespaced by its kind. This is an 'org' kind context. + * org: { + * // Each component context has its own key and attributes. + * key: 'my-org-key', + * someAttribute: 'my-attribute-value', + * }, + * user: { + * key: 'my-user-key', + * firstName: 'Bob', + * lastName: 'Bobberson', + * _meta: { + * // Each component context has its own _meta attributes. This will only apply the this + * // 'user' context. + * privateAttributes: ['firstName'] + * } + * } + * }; + * ``` + * + * The above multi-context contains both an 'org' and a 'user'. Each with their own key, + * attributes, and _meta attributes. + */ + interface LDMultiKindContext { + /** + * The kind of the context. + */ + kind: "multi", + + /** + * The contexts which compose this multi-kind context. + * + * These should be of type LDContextCommon. "multi" is to allow + * for the top level "kind" attribute. + */ + [kind: string]: "multi" | LDContextCommon; + } + + /** + * A LaunchDarkly context object. + */ + export type LDContext = LDUser | LDSingleKindContext | LDMultiKindContext; + /** * A LaunchDarkly user object. + * + * @deprecated + * The LDUser object is currently supported for ease of upgrade. + * In order to convert an LDUser into a LDSingleKindContext the following changes should + * be made. + * + * 1.) Add a kind to the object. `kind: 'user'`. + * + * 2.) Move custom attributes to the top level of the object. + * + * 3.) Move `privateAttributeNames` to `_meta.privateAttributes`. + * + * ``` + * const LDUser: user = { + * key: '1234', + * privateAttributeNames: ['myAttr'] + * custom: { + * myAttr: 'value' + * } + * } + * + * const LDSingleKindContext: context = { + * kind: 'user', + * key: '1234', + * myAttr: 'value' + * _meta: { + * privateAttributes: ['myAttr'] + * } + * } + * ``` */ export interface LDUser { /** @@ -310,14 +490,6 @@ declare module 'launchdarkly-js-sdk-common' { */ key?: string; - /** - * An optional secondary key for a user. This affects - * [feature flag targeting](https://docs.launchdarkly.com/home/flags/targeting-users#targeting-rules-based-on-user-attributes) - * as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set) - * is used to further distinguish between users who are otherwise identical according to that attribute. - */ - secondary?: string; - /** * The user's name. * @@ -374,23 +546,23 @@ declare module 'launchdarkly-js-sdk-common' { * 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. This is in * addition to any private attributes designated in the global configuration - * with [[LDOptions.privateAttributeNames]] or [[LDOptions.allAttributesPrivate]]. + * with {@link LDOptions.privateAttributes} or {@link LDOptions.allAttributesPrivate}. */ privateAttributeNames?: Array; } /** * Describes the reason that a flag evaluation produced a particular value. This is - * part of the [[LDEvaluationDetail]] object returned by [[LDClient.variationDetail]]. + * part of the {@link LDEvaluationDetail} object returned by {@link LDClient.variationDetail]]. */ interface LDEvaluationReason { /** * The general category of the reason: * * - `'OFF'`: The flag was off and therefore returned its configured off value. - * - `'FALLTHROUGH'`: The flag was on but the user did not match any targets or rules. - * - `'TARGET_MATCH'`: The user key was specifically targeted for this flag. - * - `'RULE_MATCH'`: the user matched one of the flag's rules. + * - `'FALLTHROUGH'`: The flag was on but the context did not match any targets or rules. + * - `'TARGET_MATCH'`: The context key was specifically targeted for this flag. + * - `'RULE_MATCH'`: the context matched one of the flag's rules. * - `'PREREQUISITE_FAILED'`: The flag was considered off because it had at least one * prerequisite flag that either was off or did not return the desired variation. * - `'ERROR'`: The flag could not be evaluated, e.g. because it does not exist or due @@ -423,14 +595,14 @@ declare module 'launchdarkly-js-sdk-common' { * An object that combines the result of a feature flag evaluation with information about * how it was calculated. * - * This is the result of calling [[LDClient.variationDetail]]. + * This is the result of calling {@link LDClient.variationDetail}. * * For more information, see the [SDK reference guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#javascript). */ export interface LDEvaluationDetail { /** * 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 {@link LDClient.variationDetail}. */ value: LDFlagValue; @@ -475,9 +647,9 @@ declare module 'launchdarkly-js-sdk-common' { * ``` * * If you want to distinguish between these success and failure conditions, use - * [[waitForInitialization]] instead. + * {@link waitForInitialization} instead. * - * If you prefer to use event listeners ([[on]]) rather than Promises, you can listen on the + * 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 @@ -513,7 +685,7 @@ declare module 'launchdarkly-js-sdk-common' { * 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 ([[on]]) for the same purpose: the event `"initialized"` + * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` * indicates success, and `"failed"` indicates failure. * * @returns @@ -523,45 +695,45 @@ declare module 'launchdarkly-js-sdk-common' { waitForInitialization(): Promise; /** - * Identifies a user to LaunchDarkly. + * Identifies a context to LaunchDarkly. * - * Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current user state, - * which is set at initialization time. You only need to call `identify()` if the user has changed + * 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 user also causes all feature flag values to be reloaded. Until that has - * finished, calls to [[variation]] will still return flag values for the previous user. You can + * 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 user - * The user properties. Must contain at least the `key` property. + * @param context + * The context properties. Must contain at least the `key` property. * @param hash - * The signed user key if you are using [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). + * 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 user are available, - * with two parameters: an error value (if any), and an [[LDFlagSet]] containing the new values - * (which can also be obtained by calling [[variation]]). If the callback is omitted, you will + * 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 user are available, providing an [[LDFlagSet]] containing the new values - * (which can also be obtained by calling [[variation]]). + * 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(user: LDUser, hash?: string, onDone?: (err: Error | null, flags: LDFlagSet | null) => void): Promise; + identify(context: LDContext, hash?: string, onDone?: (err: Error | null, flags: LDFlagSet | null) => void): Promise; /** - * Returns the client's current user. + * Returns the client's current context. * - * This is the user that was most recently passed to [[identify]], or, if [[identify]] has never - * been called, the initial user specified when the client was created. + * 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. */ - getUser(): LDUser; + getContext(): LDContext; /** * Flushes all pending analytics events. * * Normally, batches of events are delivered in the background at intervals determined by the - * `flushInterval` property of [[LDOptions]]. Calling `flush()` triggers an immediate delivery. + * `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 @@ -575,10 +747,10 @@ declare module 'launchdarkly-js-sdk-common' { flush(onDone?: () => void): Promise; /** - * Determines the variation of a feature flag for the current user. + * 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 user have already been loaded into memory. + * the feature flag values for the current context have already been loaded into memory. * * @param key * The unique key of the feature flag. @@ -590,10 +762,10 @@ declare module 'launchdarkly-js-sdk-common' { variation(key: string, defaultValue?: LDFlagValue): LDFlagValue; /** - * Determines the variation of a feature flag for a user, along with information about how it was + * 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 [[LDOptions]]. + * 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 @@ -607,7 +779,7 @@ declare module 'launchdarkly-js-sdk-common' { * The default value of the flag, to be used if the value is not available from LaunchDarkly. * * @returns - * An [[LDEvaluationDetail]] object containing the value and explanation. + * An {@link LDEvaluationDetail} object containing the value and explanation. */ variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail; @@ -616,9 +788,9 @@ declare module 'launchdarkly-js-sdk-common' { * * 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 [[LDClient.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 [[LDOptions]]. + * This can also be set as the `streaming` property of {@link LDOptions}. */ setStreaming(value?: boolean): void; @@ -639,9 +811,9 @@ declare module 'launchdarkly-js-sdk-common' { * 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 users with [[identify]], or because the client has a + * 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 [[LDFlagChangeset]]. + * 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 @@ -650,27 +822,27 @@ declare module 'launchdarkly-js-sdk-common' { * 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 [[LDOptions]] or calling [[LDClient.setStreaming]]. + * setting `streaming` in {@link LDOptions} or calling {@link LDClient.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; see [[LDEventSignature]]. + * 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 [[on]] for the available event types. + * 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 [[on]]. + * The `this` context for the callback, if one was specified for {@link on}. */ off(key: string, callback: (...args: any[]) => void, context?: any): void; @@ -694,56 +866,40 @@ declare module 'launchdarkly-js-sdk-common' { track(key: string, data?: any, metricValue?: number): void; /** - * Associates two users for analytics purposes. - * - * This can be helpful in the situation where a person is represented by multiple - * LaunchDarkly users. This may happen, for example, when a person initially logs into - * an application-- the person might be represented by an anonymous user prior to logging - * in and a different user after logging in, as denoted by a different user key. - * - * @param user - * The newly identified user. - * @param previousUser - * The previously identified user. - */ - alias(user: LDUser, previousUser: LDUser): void; - - /** - * Returns a map of all available flags to the current user's values. This will send analytics - * events unless [[LDOptions.sendEventsOnlyForVariation]] is true. + * 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 - * [[variation]], so any flag that cannot be evaluated will have a null value. + * {@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 [[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; + /** + * 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; } /** - * Provides a simple [[LDLogger]] implementation. + * Provides a simple {@link LDLogger} implementation. * * This logging implementation uses a simple format that includes only the log level * and the message text. Output is written to the console unless otherwise specified. - * You can filter by log level as described in [[BasicLoggerOptions.level]]. + * You can filter by log level as described in {@link BasicLoggerOptions.level}. * - * To use the logger created by this function, put it into [[LDOptions.logger]]. If - * you do not set [[LDOptions.logger]] to anything, the SDK uses a default logger + * To use the logger created by this function, put it into {@link LDOptions.logger}. If + * you do not set {@link LDOptions.logger} to anything, the SDK uses a default logger * that is equivalent to `ld.basicLogger({ level: 'info' })`. * * @param options Configuration for the logger. If no options are specified, the @@ -774,19 +930,19 @@ declare module 'launchdarkly-js-sdk-common' { * @ignore (don't need to show this separately in TypeDoc output; each SDK should provide its own * basicLogger function that delegates to this and sets the formatter parameter) */ - export function commonBasicLogger( + export function commonBasicLogger( options?: BasicLoggerOptions, formatter?: (format: string, ...args: any[]) => void ): LDLogger; /** - * Configuration for [[basicLogger]]. + * Configuration for {@link basicLogger}. */ export interface BasicLoggerOptions { /** * The lowest level of log message to enable. * - * See [[LDLogLevel]] for a list of possible levels. Setting a level here causes + * See {@link LDLogLevel} for a list of possible levels. Setting a level here causes * all lower-importance levels to be disabled: for instance, if you specify * `'warn'`, then `'debug'` and `'info'` are disabled. * @@ -818,15 +974,20 @@ declare module 'launchdarkly-js-sdk-common' { } /** - * Logging levels that can be used with [[basicLogger]]. + * Logging levels that can be used with {@link basicLogger}. * - * Set [[BasicLoggerOptions.level]] to one of these values to control what levels + * Set {@link BasicLoggerOptions.level} to one of these values to control what levels * of log messages are enabled. Going from lowest importance (and most verbose) * to most importance, the levels are `'debug'`, `'info'`, `'warn'`, and `'error'`. * You can also specify `'none'` instead to disable all logging. */ export type LDLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none'; + export function getContextKeys( + context: LDContext, + logger?: LDLogger, + ): {[attribute: string]: string} | undefined; + /** * Callback interface for collecting information about the SDK at runtime. * @@ -848,9 +1009,9 @@ declare module 'launchdarkly-js-sdk-common' { * 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, user: LDUser) => void; + method: (flagKey: string, flagDetail: LDEvaluationDetail, context: LDContext) => void; } - + /** * Callback interface for collecting information about the SDK at runtime. * @@ -869,15 +1030,15 @@ declare module 'launchdarkly-js-sdk-common' { * 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. * @@ -895,7 +1056,7 @@ declare module 'launchdarkly-js-sdk-common' { * 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. @@ -918,11 +1079,11 @@ declare module 'launchdarkly-js-sdk-common' { * 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: (user: LDUser) => void; + method: (context: LDContext) => void; } type LDInspection = LDInspectionFlagUsedHandler | LDInspectionFlagDetailsChangedHandler