Skip to content

Commit f4642ea

Browse files
authored
Merge pull request #101 from launchdarkly/5.0
[sc-178324] Merge u2c changes for major release
2 parents 6f5b8b0 + 4e2dfc7 commit f4642ea

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2344
-969
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"scripts": {
1010
"lint": "eslint --format 'node_modules/eslint-formatter-pretty' --ignore-path .eslintignore",
1111
"lint:all": "eslint --format 'node_modules/eslint-formatter-pretty' --ignore-path .eslintignore src",
12+
"lint-fix:all": "eslint --fix --format 'node_modules/eslint-formatter-pretty' --ignore-path .eslintignore src",
1213
"format": "npm run format:md && npm run format:js",
1314
"format:md": "prettier --parser markdown --ignore-path .prettierignore --write '*.md'",
1415
"format:js": "prettier --ignore-path .prettierignore --write 'src/**/*.js'",
@@ -26,6 +27,7 @@
2627
"@babel/preset-env": "^7.6.3",
2728
"@babel/runtime": "7.6.3",
2829
"@rollup/plugin-replace": "^2.2.0",
30+
"@types/jest": "^27.4.1",
2931
"babel-eslint": "^10.1.0",
3032
"babel-jest": "^25.1.0",
3133
"cross-env": "^5.1.4",

src/AnonymousContextProcessor.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
const { v1: uuidv1 } = require('uuid');
2+
const { getContextKinds } = require('./context');
3+
4+
const errors = require('./errors');
5+
const messages = require('./messages');
6+
const utils = require('./utils');
7+
8+
const ldUserIdKey = 'ld:$anonUserId';
9+
10+
/**
11+
* Create an object which can process a context and populate any required keys
12+
* for anonymous objects.
13+
*
14+
* @param {Object} persistentStorage The persistent storage from which to store
15+
* and access persisted anonymous context keys.
16+
* @returns An AnonymousContextProcessor.
17+
*/
18+
function AnonymousContextProcessor(persistentStorage) {
19+
function getContextKeyIdString(kind) {
20+
if (kind === undefined || kind === null || kind === 'user') {
21+
return ldUserIdKey;
22+
}
23+
return `ld:$contextKey:${kind}`;
24+
}
25+
26+
function getCachedContextKey(kind) {
27+
return persistentStorage.get(getContextKeyIdString(kind));
28+
}
29+
30+
function setCachedContextKey(id, kind) {
31+
return persistentStorage.set(getContextKeyIdString(kind), id);
32+
}
33+
34+
/**
35+
* Process a single kind context, or a single context within a multi-kind context.
36+
* @param {string} kind The kind of the context. Independent because the kind is not prevent
37+
* within a context in a multi-kind context.
38+
* @param {Object} context
39+
* @returns {Promise} a promise that resolves to a processed contexts, or rejects
40+
* a context which cannot be processed.
41+
*/
42+
function processSingleKindContext(kind, context) {
43+
// We are working on a copy of an original context, so we want to re-assign
44+
// versus duplicating it again.
45+
46+
/* eslint-disable no-param-reassign */
47+
if (context.key !== null && context.key !== undefined) {
48+
context.key = context.key.toString();
49+
return Promise.resolve(context);
50+
}
51+
52+
if (context.anonymous) {
53+
// If the key doesn't exist, then the persistent storage will resolve
54+
// with undefined.
55+
return getCachedContextKey(kind).then(cachedId => {
56+
if (cachedId) {
57+
context.key = cachedId;
58+
return context;
59+
} else {
60+
const id = uuidv1();
61+
context.key = id;
62+
return setCachedContextKey(id, kind).then(() => context);
63+
}
64+
});
65+
} else {
66+
return Promise.reject(new errors.LDInvalidUserError(messages.invalidContext()));
67+
}
68+
/* eslint-enable no-param-reassign */
69+
}
70+
71+
/**
72+
* Process the context, returning a Promise that resolves to the processed context, or rejects if there is an error.
73+
* @param {Object} context
74+
* @returns {Promise} A promise which resolves to a processed context, or a rejection if the context cannot be
75+
* processed. The context should still be checked for overall validity after being processed.
76+
*/
77+
this.processContext = context => {
78+
if (!context) {
79+
return Promise.reject(new errors.LDInvalidUserError(messages.contextNotSpecified()));
80+
}
81+
82+
const processedContext = utils.clone(context);
83+
84+
if (context.kind === 'multi') {
85+
const kinds = getContextKinds(processedContext);
86+
87+
return Promise.all(kinds.map(kind => processSingleKindContext(kind, processedContext[kind]))).then(
88+
() => processedContext
89+
);
90+
}
91+
return processSingleKindContext(context.kind, processedContext);
92+
};
93+
}
94+
95+
module.exports = AnonymousContextProcessor;

src/ContextFilter.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
const AttributeReference = require('./attributeReference');
2+
3+
function ContextFilter(config) {
4+
const filter = {};
5+
6+
const allAttributesPrivate = config.allAttributesPrivate;
7+
const privateAttributes = config.privateAttributes || [];
8+
9+
// These attributes cannot be removed via a private attribute.
10+
const protectedAttributes = ['key', 'kind', '_meta', 'anonymous'];
11+
12+
const legacyTopLevelCopyAttributes = ['name', 'ip', 'firstName', 'lastName', 'email', 'avatar', 'country'];
13+
14+
/**
15+
* For the given context and configuration get a list of attributes to filter.
16+
* @param {Object} context
17+
* @returns {string[]} A list of the attributes to filter.
18+
*/
19+
const getAttributesToFilter = context =>
20+
(allAttributesPrivate
21+
? Object.keys(context)
22+
: [...privateAttributes, ...((context._meta && context._meta.privateAttributes) || [])]
23+
).filter(attr => !protectedAttributes.some(protectedAttr => AttributeReference.compare(attr, protectedAttr)));
24+
25+
/**
26+
* @param {Object} context
27+
* @returns {Object} A copy of the context with private attributes removed,
28+
* and the redactedAttributes meta populated.
29+
*/
30+
const filterSingleKind = context => {
31+
if (typeof context !== 'object' || context === null || Array.isArray(context)) {
32+
return undefined;
33+
}
34+
35+
const { cloned, excluded } = AttributeReference.cloneExcluding(context, getAttributesToFilter(context));
36+
cloned.key = String(cloned.key);
37+
if (excluded.length) {
38+
if (!cloned._meta) {
39+
cloned._meta = {};
40+
}
41+
cloned._meta.redactedAttributes = excluded;
42+
}
43+
if (cloned._meta) {
44+
delete cloned._meta['privateAttributes'];
45+
if (Object.keys(cloned._meta).length === 0) {
46+
delete cloned._meta;
47+
}
48+
}
49+
// Make sure anonymous is boolean if present.
50+
// Null counts as present, and would be falsy, which is the default.
51+
if (cloned.anonymous !== undefined) {
52+
cloned.anonymous = !!cloned.anonymous;
53+
}
54+
55+
return cloned;
56+
};
57+
58+
/**
59+
* @param {Object} context
60+
* @returns {Object} A copy of the context with the private attributes removed,
61+
* and the redactedAttributes meta populated for each sub-context.
62+
*/
63+
const filterMultiKind = context => {
64+
const filtered = {
65+
kind: context.kind,
66+
};
67+
const contextKeys = Object.keys(context);
68+
69+
for (const contextKey of contextKeys) {
70+
if (contextKey !== 'kind') {
71+
const filteredContext = filterSingleKind(context[contextKey]);
72+
if (filteredContext) {
73+
filtered[contextKey] = filteredContext;
74+
}
75+
}
76+
}
77+
return filtered;
78+
};
79+
80+
/**
81+
* Convert the LDUser object into an LDContext object.
82+
* @param {Object} user The LDUser to produce an LDContext for.
83+
* @returns {Object} A single kind context based on the provided user.
84+
*/
85+
const legacyToSingleKind = user => {
86+
const filtered = {
87+
/* Destructure custom items into the top level.
88+
Duplicate keys will be overridden by previously
89+
top level items.
90+
*/
91+
...(user.custom || {}),
92+
93+
// Implicity a user kind.
94+
kind: 'user',
95+
96+
key: user.key,
97+
};
98+
99+
if (user.anonymous !== undefined) {
100+
filtered.anonymous = !!user.anonymous;
101+
}
102+
103+
// Copy top level keys and convert them to strings.
104+
// Remove keys that may have been destructured from `custom`.
105+
for (const key of legacyTopLevelCopyAttributes) {
106+
delete filtered[key];
107+
if (user[key] !== undefined && user[key] !== null) {
108+
filtered[key] = String(user[key]);
109+
}
110+
}
111+
112+
if (user.privateAttributeNames !== undefined && user.privateAttributeNames !== null) {
113+
filtered._meta = filtered._meta || {};
114+
// If any private attributes started with '/' we need to convert them to references, otherwise the '/' will
115+
// cause the literal to incorrectly be treated as a reference.
116+
filtered._meta.privateAttributes = user.privateAttributeNames.map(
117+
literal => (literal.startsWith('/') ? AttributeReference.literalToReference(literal) : literal)
118+
);
119+
}
120+
121+
return filtered;
122+
};
123+
124+
filter.filter = context => {
125+
if (context.kind === undefined || context.kind === null) {
126+
return filterSingleKind(legacyToSingleKind(context));
127+
} else if (context.kind === 'multi') {
128+
return filterMultiKind(context);
129+
} else {
130+
return filterSingleKind(context);
131+
}
132+
};
133+
134+
return filter;
135+
}
136+
137+
module.exports = ContextFilter;

src/EventProcessor.js

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
const EventSender = require('./EventSender');
22
const EventSummarizer = require('./EventSummarizer');
3-
const UserFilter = require('./UserFilter');
3+
const ContextFilter = require('./ContextFilter');
44
const errors = require('./errors');
55
const messages = require('./messages');
66
const utils = require('./utils');
7+
const { getContextKeys } = require('./context');
78

89
function EventProcessor(
910
platform,
@@ -17,8 +18,7 @@ function EventProcessor(
1718
const eventSender = sender || EventSender(platform, environmentId, options);
1819
const mainEventsUrl = utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + environmentId);
1920
const summarizer = EventSummarizer();
20-
const userFilter = UserFilter(options);
21-
const inlineUsers = options.inlineUsersInEvents;
21+
const contextFilter = ContextFilter(options);
2222
const samplingInterval = options.samplingInterval;
2323
const eventCapacity = options.eventCapacity;
2424
const flushInterval = options.flushInterval;
@@ -47,16 +47,12 @@ function EventProcessor(
4747
// Transform an event from its internal format to the format we use when sending a payload.
4848
function makeOutputEvent(e) {
4949
const ret = utils.extend({}, e);
50-
if (e.kind === 'alias') {
51-
// alias events do not require any transformation
52-
return ret;
53-
}
54-
if (inlineUsers || e.kind === 'identify') {
55-
// identify events always have an inline user
56-
ret.user = userFilter.filterUser(e.user);
50+
if (e.kind === 'identify') {
51+
// identify events always have an inline context
52+
ret.context = contextFilter.filter(e.context);
5753
} else {
58-
ret.userKey = e.user.key;
59-
delete ret['user'];
54+
ret.contextKeys = getContextKeysFromEvent(e);
55+
delete ret['context'];
6056
}
6157
if (e.kind === 'feature') {
6258
delete ret['trackEvents'];
@@ -65,6 +61,10 @@ function EventProcessor(
6561
return ret;
6662
}
6763

64+
function getContextKeysFromEvent(event) {
65+
return getContextKeys(event.context, logger);
66+
}
67+
6868
function addToOutbox(event) {
6969
if (queue.length < eventCapacity) {
7070
queue.push(event);
@@ -107,7 +107,7 @@ function EventProcessor(
107107
}
108108
if (addDebugEvent) {
109109
const debugEvent = utils.extend({}, event, { kind: 'debug' });
110-
debugEvent.user = userFilter.filterUser(debugEvent.user);
110+
debugEvent.context = contextFilter.filter(debugEvent.context);
111111
delete debugEvent['trackEvents'];
112112
delete debugEvent['debugEventsUntilDate'];
113113
addToOutbox(debugEvent);
@@ -136,7 +136,8 @@ function EventProcessor(
136136
}
137137
queue = [];
138138
logger.debug(messages.debugPostingEvents(eventsToSend.length));
139-
return eventSender.sendEvents(eventsToSend, mainEventsUrl).then(responseInfo => {
139+
return eventSender.sendEvents(eventsToSend, mainEventsUrl).then(responses => {
140+
const responseInfo = responses && responses[0];
140141
if (responseInfo) {
141142
if (responseInfo.serverTime) {
142143
lastKnownPastTime = responseInfo.serverTime;

src/EventSender.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function EventSender(platform, environmentId, options) {
3131
const headers = isDiagnostic
3232
? baseHeaders
3333
: utils.extend({}, baseHeaders, {
34-
'X-LaunchDarkly-Event-Schema': '3',
34+
'X-LaunchDarkly-Event-Schema': '4',
3535
'X-LaunchDarkly-Payload-ID': payloadId,
3636
});
3737
return platform
@@ -73,7 +73,7 @@ function EventSender(platform, environmentId, options) {
7373
// no need to break up events into chunks if we can send a POST
7474
chunks = [events];
7575
} else {
76-
chunks = utils.chunkUserEventsForUrl(MAX_URL_LENGTH - url.length, events);
76+
chunks = utils.chunkEventsForUrl(MAX_URL_LENGTH - url.length, events);
7777
}
7878
const results = [];
7979
for (let i = 0; i < chunks.length; i++) {

0 commit comments

Comments
 (0)