Skip to content

Commit 8f13ffe

Browse files
authored
feat: Add option to omit anonymous users from index and identify events (#306)
1 parent ee80f9a commit 8f13ffe

File tree

7 files changed

+176
-29
lines changed

7 files changed

+176
-29
lines changed

contract-tests/client_entity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def __init__(self, tag, config):
5050
opts["all_attributes_private"] = events.get("allAttributesPrivate", False)
5151
opts["private_attributes"] = events.get("globalPrivateAttributes", {})
5252
_set_optional_time_prop(events, "flushIntervalMs", opts, "flush_interval")
53+
opts["omit_anonymous_contexts"] = events.get("omitAnonymousContexts", False)
5354
else:
5455
opts["send_events"] = False
5556

contract-tests/service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def status():
7676
'polling-gzip',
7777
'inline-context',
7878
'anonymous-redaction',
79-
'evaluation-hooks'
79+
'evaluation-hooks',
80+
'omit-anonymous-contexts'
8081
]
8182
}
8283
return (json.dumps(body), 200, {'Content-type': 'application/json'})

ldclient/config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ def __init__(self,
176176
big_segments: Optional[BigSegmentsConfig]=None,
177177
application: Optional[dict]=None,
178178
hooks: Optional[List[Hook]]=None,
179-
enable_event_compression: bool=False):
179+
enable_event_compression: bool=False,
180+
omit_anonymous_contexts: bool=False):
180181
"""
181182
:param sdk_key: The SDK key for your LaunchDarkly account. This is always required.
182183
:param base_uri: The base URL for the LaunchDarkly server. Most users should use the default
@@ -243,6 +244,7 @@ def __init__(self,
243244
:param application: Optional properties for setting application metadata. See :py:attr:`~application`
244245
:param hooks: Hooks provide entrypoints which allow for observation of SDK functions.
245246
:param enable_event_compression: Whether or not to enable GZIP compression for outgoing events.
247+
:param omit_anonymous_contexts: Sets whether anonymous contexts should be omitted from index and identify events.
246248
"""
247249
self.__sdk_key = sdk_key
248250

@@ -277,6 +279,7 @@ def __init__(self,
277279
self.__application = validate_application_info(application or {}, log)
278280
self.__hooks = [hook for hook in hooks if isinstance(hook, Hook)] if hooks else []
279281
self.__enable_event_compression = enable_event_compression
282+
self.__omit_anonymous_contexts = omit_anonymous_contexts
280283
self._data_source_update_sink: Optional[DataSourceUpdateSink] = None
281284

282285
def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config':
@@ -466,6 +469,13 @@ def hooks(self) -> List[Hook]:
466469
def enable_event_compression(self) -> bool:
467470
return self.__enable_event_compression
468471

472+
@property
473+
def omit_anonymous_contexts(self) -> bool:
474+
"""
475+
Determines whether or not anonymous contexts will be omitted from index and identify events.
476+
"""
477+
return self.__omit_anonymous_contexts
478+
469479
@property
470480
def data_source_update_sink(self) -> Optional[DataSourceUpdateSink]:
471481
"""

ldclient/context.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,28 @@ def anonymous(self) -> bool:
381381
"""
382382
return self.__anonymous
383383

384+
def without_anonymous_contexts(self) -> Context:
385+
"""
386+
For a multi-kind context:
387+
388+
A multi-kind context is made up of two or more single-kind contexts.
389+
This method will first discard any single-kind contexts which are
390+
anonymous. It will then create a new multi-kind context from the
391+
remaining single-kind contexts. This may result in an invalid context
392+
(e.g. all single-kind contexts are anonymous).
393+
394+
For a single-kind context:
395+
396+
If the context is not anonymous, this method will return the current
397+
context as is and unmodified.
398+
399+
If the context is anonymous, this method will return an invalid context.
400+
"""
401+
contexts = self.__multi if self.__multi is not None else [self]
402+
contexts = [c for c in contexts if not c.anonymous]
403+
404+
return Context.create_multi(*contexts)
405+
384406
def get(self, attribute: str) -> Any:
385407
"""
386408
Looks up the value of any attribute of the context by name.

ldclient/impl/events/event_processor.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@
77
from email.utils import parsedate
88
import json
99
from threading import Event, Lock, Thread
10-
from typing import Any, List, Optional, Dict
10+
from typing import Any, List, Optional, Dict, Callable
1111
import time
1212
import uuid
1313
import queue
1414
import urllib3
1515
import gzip
1616
from ldclient.config import Config
17-
from datetime import timedelta
1817
from random import Random
1918

2019
from ldclient.context import Context
@@ -341,6 +340,7 @@ def __init__(self, inbox, config, http_client, diagnostic_accumulator=None):
341340
self._deduplicated_contexts = 0
342341
self._diagnostic_accumulator = None if config.diagnostic_opt_out else diagnostic_accumulator
343342
self._sampler = Sampler(Random())
343+
self._omit_anonymous_contexts = config.omit_anonymous_contexts
344344

345345
self._flush_workers = FixedThreadPool(__MAX_FLUSH_THREADS__, "ldclient.flush")
346346
self._diagnostic_flush_workers = None if self._diagnostic_accumulator is None else FixedThreadPool(1, "ldclient.diag_flush")
@@ -387,7 +387,6 @@ def _process_event(self, event: EventInput):
387387
# Decide whether to add the event to the payload. Feature events may be added twice, once for
388388
# the event (if tracked) and once for debugging.
389389
context = None # type: Optional[Context]
390-
can_add_index = True
391390
full_event = None # type: Any
392391
debug_event = None # type: Optional[DebugEvent]
393392
sampling_ratio = 1 if event.sampling_ratio is None else event.sampling_ratio
@@ -401,31 +400,50 @@ def _process_event(self, event: EventInput):
401400
if self._should_debug_event(event):
402401
debug_event = DebugEvent(event)
403402
elif isinstance(event, EventInputIdentify):
404-
context = event.context
403+
if self._omit_anonymous_contexts:
404+
context = event.context.without_anonymous_contexts()
405+
if not context.valid:
406+
return
407+
408+
event = EventInputIdentify(event.timestamp, context, event.sampling_ratio)
409+
405410
full_event = event
406-
can_add_index = False # an index event would be redundant if there's an identify event
407411
elif isinstance(event, EventInputCustom):
408412
context = event.context
409413
full_event = event
410414
elif isinstance(event, MigrationOpEvent):
411415
full_event = event
412416

413-
# For each context we haven't seen before, we add an index event - unless this is already
414-
# an identify event.
415-
if context is not None:
416-
already_seen = self._context_keys.put(context.fully_qualified_key, True)
417-
if can_add_index:
418-
if already_seen:
419-
self._deduplicated_contexts += 1
420-
else:
421-
self._outbox.add_event(IndexEvent(event.timestamp, context))
417+
self._get_indexable_context(event, lambda c: self._outbox.add_event(IndexEvent(event.timestamp, c)))
422418

423419
if full_event and self._sampler.sample(sampling_ratio):
424420
self._outbox.add_event(full_event)
425421

426422
if debug_event and self._sampler.sample(sampling_ratio):
427423
self._outbox.add_event(debug_event)
428424

425+
def _get_indexable_context(self, event: EventInput, block: Callable[[Context], None]):
426+
if event.context is None:
427+
return
428+
429+
context = event.context
430+
if self._omit_anonymous_contexts:
431+
context = context.without_anonymous_contexts()
432+
433+
if not context.valid:
434+
return
435+
436+
already_seen = self._context_keys.put(context.fully_qualified_key, True)
437+
if already_seen:
438+
self._deduplicated_contexts += 1
439+
return
440+
elif isinstance(event, EventInputIdentify) or isinstance(event, MigrationOpEvent):
441+
return
442+
443+
block(context)
444+
445+
446+
429447
def _should_debug_event(self, event: EventInputEvaluation):
430448
if event.flag is None:
431449
return False

ldclient/testing/impl/events/test_event_processor.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,37 @@ def test_context_is_filtered_in_identify_event():
236236
assert len(output) == 1
237237
check_identify_event(output[0], e, formatter.format_context(context))
238238

239+
240+
def test_omit_anonymous_contexts_suppresses_identify_event():
241+
with DefaultTestProcessor(omit_anonymous_contexts=True) as ep:
242+
anon_context = Context.builder('userkey').name('Red').anonymous(True).build()
243+
e = EventInputIdentify(timestamp, anon_context)
244+
ep.send_event(e)
245+
246+
try:
247+
flush_and_get_events(ep)
248+
pytest.fail("Expected no events")
249+
except AssertionError:
250+
pass
251+
252+
253+
def test_omit_anonymous_contexts_strips_anonymous_contexts_correctly():
254+
with DefaultTestProcessor(omit_anonymous_contexts=True) as ep:
255+
a = Context.builder('a').kind('a').anonymous(True).build()
256+
b = Context.builder('b').kind('b').anonymous(True).build()
257+
c = Context.builder('c').kind('c').anonymous(False).build()
258+
mc = Context.multi_builder().add(a).add(b).add(c).build()
259+
260+
e = EventInputIdentify(timestamp, mc)
261+
ep.send_event(e)
262+
263+
output = flush_and_get_events(ep)
264+
assert len(output) == 1
265+
266+
formatter = EventContextFormatter(True, [])
267+
check_identify_event(output[0], e, formatter.format_context(c))
268+
269+
239270
def test_individual_feature_event_is_queued_with_index_event():
240271
with DefaultTestProcessor() as ep:
241272
e = EventInputEvaluation(timestamp, context, flag.key, flag, 1, 'value', None, 'default', None, True)
@@ -248,6 +279,34 @@ def test_individual_feature_event_is_queued_with_index_event():
248279
check_summary_event(output[2])
249280

250281

282+
def test_omit_anonymous_context_emits_feature_event_without_index():
283+
with DefaultTestProcessor(omit_anonymous_contexts=True) as ep:
284+
anon = Context.builder('a').anonymous(True).build()
285+
e = EventInputEvaluation(timestamp, anon, flag.key, flag, 1, 'value', None, 'default', None, True)
286+
ep.send_event(e)
287+
288+
output = flush_and_get_events(ep)
289+
assert len(output) == 2
290+
check_feature_event(output[0], e)
291+
check_summary_event(output[1])
292+
293+
294+
def test_omit_anonymous_context_strips_anonymous_from_index_event():
295+
with DefaultTestProcessor(omit_anonymous_contexts=True) as ep:
296+
a = Context.builder('a').kind('a').anonymous(True).build()
297+
b = Context.builder('b').kind('b').anonymous(True).build()
298+
c = Context.builder('c').kind('c').anonymous(False).build()
299+
mc = Context.multi_builder().add(a).add(b).add(c).build()
300+
e = EventInputEvaluation(timestamp, mc, flag.key, flag, 1, 'value', None, 'default', None, True)
301+
ep.send_event(e)
302+
303+
output = flush_and_get_events(ep)
304+
assert len(output) == 3
305+
check_index_event(output[0], e, c.to_dict()) # Should only contain non-anon context
306+
check_feature_event(output[1], e)
307+
check_summary_event(output[2])
308+
309+
251310
def test_individual_feature_event_is_ignored_for_0_sampling_ratio():
252311
with DefaultTestProcessor() as ep:
253312
e = EventInputEvaluation(timestamp, context, flag_with_0_sampling_ratio.key, flag_with_0_sampling_ratio, 1, 'value', None, 'default', None, True)

ldclient/testing/test_context.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,22 @@ def test_get_built_in_attribute_by_name(self):
110110
assert c.get('kind') == 'b'
111111
assert c.get('name') == 'c'
112112
assert c.get('anonymous') is True
113-
113+
114114
def test_get_unknown_attribute(self):
115115
c = Context.create('a')
116116
assert c.get('b') is None
117-
117+
118118
def test_private_attributes(self):
119119
assert list(Context.create('a').private_attributes) == []
120120

121121
c = Context.builder('a').private('b', '/c/d').private('e').build()
122122
assert list(c.private_attributes) == ['b', '/c/d', 'e']
123-
123+
124124
def test_fully_qualified_key(self):
125125
assert Context.create('key1').fully_qualified_key == 'key1'
126126
assert Context.create('key1', 'kind1').fully_qualified_key == 'kind1:key1'
127127
assert Context.create('key%with:things', 'kind1').fully_qualified_key == 'kind1:key%25with%3Athings'
128-
128+
129129
def test_builder_from_context(self):
130130
c1 = Context.builder('a').kind('kind1').name('b').set('c', True).private('d').build()
131131
b = Context.builder_from_context(c1)
@@ -167,7 +167,7 @@ def _assert_contexts_from_factory_equal(fn):
167167
Context.create_multi(Context.create('a', 'kind1'), Context.create('b', 'kind2'))
168168
assert Context.create_multi(Context.create('a', 'kind1'), Context.create('b', 'kind2')) != \
169169
Context.create('a', 'kind1')
170-
170+
171171
_assert_contexts_from_factory_equal(lambda: Context.create('invalid', 'kind'))
172172
assert Context.create('invalid', 'kind') != Context.create_multi() # different errors
173173

@@ -195,10 +195,10 @@ def test_json_decoding(self):
195195
Context.builder('key1').kind('kind1').anonymous(True).build()
196196
assert Context.from_dict({'kind': 'kind1', 'key': 'key1', '_meta': {'privateAttributes': ['b']}}) == \
197197
Context.builder('key1').kind('kind1').private('b').build()
198-
198+
199199
assert Context.from_dict({'kind': 'multi', 'kind1': {'key': 'key1'}, 'kind2': {'key': 'key2'}}) == \
200200
Context.create_multi(Context.create('key1', 'kind1'), Context.create('key2', 'kind2'))
201-
201+
202202
assert_context_invalid(Context.from_dict({'kind': 'kind1'}))
203203
assert_context_invalid(Context.from_dict({'kind': 'kind1', 'key': 3}))
204204
assert_context_invalid(Context.from_dict({'kind': 'multi'}))
@@ -256,34 +256,70 @@ class TestContextErrors:
256256
def test_key_empty_string(self):
257257
assert_context_invalid(Context.create(''))
258258
assert_context_invalid(Context.builder('').build())
259-
259+
260260
@pytest.mark.parametrize('kind', ['kind', 'multi', 'b$c', ''])
261261
def test_kind_invalid_strings(self, kind):
262262
assert_context_invalid(Context.create('a', kind))
263263
assert_context_invalid(Context.builder('a').kind(kind).build())
264-
264+
265265
def test_create_multi_with_no_contexts(self):
266266
assert_context_invalid(Context.create_multi())
267-
267+
268268
def test_multi_builder_with_no_contexts(self):
269269
assert_context_invalid(Context.multi_builder().build())
270270

271271
def test_create_multi_with_duplicate_kind(self):
272272
c1 = Context.create('a', 'kind1')
273273
c2 = Context.create('b', 'kind1')
274274
assert_context_invalid(Context.create_multi(c1, c2))
275-
275+
276276
def test_multi_builder_with_duplicate_kind(self):
277277
c1 = Context.create('a', 'kind1')
278278
c2 = Context.create('b', 'kind1')
279279
assert_context_invalid(Context.multi_builder().add(c1).add(c2).build())
280-
280+
281281
def test_create_multi_with_invalid_context(self):
282282
c1 = Context.create('a', 'kind1')
283283
c2 = Context.create('')
284284
assert_context_invalid(Context.create_multi(c1, c2))
285-
285+
286286
def test_multi_builder_with_invalid_context(self):
287287
c1 = Context.create('a', 'kind1')
288288
c2 = Context.create('')
289289
assert_context_invalid(Context.multi_builder().add(c1).add(c2).build())
290+
291+
292+
class TestAnonymousRedaction:
293+
def test_redacting_anonoymous_leads_to_invalid_context(self):
294+
original = Context.builder('a').anonymous(True).build()
295+
c = original.without_anonymous_contexts()
296+
297+
assert_context_invalid(c)
298+
299+
def test_redacting_non_anonymous_does_not_change_context(self):
300+
original = Context.builder('a').anonymous(False).build()
301+
c = original.without_anonymous_contexts()
302+
303+
assert_context_valid(c)
304+
assert c == original
305+
306+
def test_can_find_non_anonymous_contexts_from_multi(self):
307+
anon = Context.builder('a').anonymous(True).build()
308+
nonanon = Context.create('b', 'kind2')
309+
mc = Context.create_multi(anon, nonanon)
310+
311+
filtered = mc.without_anonymous_contexts()
312+
313+
assert_context_valid(filtered)
314+
assert filtered.individual_context_count == 1
315+
assert filtered.key == 'b'
316+
assert filtered.kind == 'kind2'
317+
318+
def test_can_filter_all_from_multi(self):
319+
a = Context.builder('a').anonymous(True).build()
320+
b = Context.builder('b').anonymous(True).build()
321+
mc = Context.create_multi(a, b)
322+
323+
filtered = mc.without_anonymous_contexts()
324+
325+
assert_context_invalid(filtered)

0 commit comments

Comments
 (0)