Skip to content

Commit

Permalink
feat: adding option to omit anonymous contexts in identify and index …
Browse files Browse the repository at this point in the history
…events (#287)

From investigation, LaunchDarkly thinks we don't need these events to
support any existing LaunchDarkly features. However, customers may
depend on this behavior for data export, so instead of fully remove the
identify and index events for anonymous context, we added a new option
to the SDK to control this behavior.

---------

Co-authored-by: Matthew Keeler <[email protected]>
  • Loading branch information
louis-launchdarkly and keelerm84 authored Jun 26, 2024
1 parent 9f6c902 commit 554f965
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 12 deletions.
1 change: 1 addition & 0 deletions contract-tests/client_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def initialize(log, config)
opts[:all_attributes_private] = !!events[:allAttributesPrivate]
opts[:private_attributes] = events[:globalPrivateAttributes]
opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) unless events[:flushIntervalMs].nil?
opts[:omit_anonymous_contexts] = !!events[:omitAnonymousContexts]
else
opts[:send_events] = false
end
Expand Down
1 change: 1 addition & 0 deletions contract-tests/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
'inline-context',
'anonymous-redaction',
'evaluation-hooks',
'omit-anonymous-contexts',
],
}.to_json
end
Expand Down
11 changes: 11 additions & 0 deletions lib/ldclient-rb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Config
# @option opts [BigSegmentsConfig] :big_segments See {#big_segments}.
# @option opts [Hash] :application See {#application}
# @option opts [String] :payload_filter_key See {#payload_filter_key}
# @option opts [Boolean] :omit_anonymous_contexts See {#omit_anonymous_contexts}
# @option hooks [Array<Interfaces::Hooks::Hook]
#
def initialize(opts = {})
Expand Down Expand Up @@ -77,6 +78,7 @@ def initialize(opts = {})
@application = LaunchDarkly::Impl::Util.validate_application_info(opts[:application] || {}, @logger)
@payload_filter_key = opts[:payload_filter_key]
@hooks = (opts[:hooks] || []).keep_if { |hook| hook.is_a? Interfaces::Hooks::Hook }
@omit_anonymous_contexts = opts.has_key?(:omit_anonymous_contexts) && opts[:omit_anonymous_contexts]
@data_source_update_sink = nil
end

Expand Down Expand Up @@ -385,6 +387,15 @@ def diagnostic_opt_out?
#
attr_reader :hooks

#
# Sets whether anonymous contexts should be omitted from index and identify events.
#
# The default value is false. Anonymous contexts will be included in index and identify events.
# @return [Boolean]
#
attr_reader :omit_anonymous_contexts


#
# The default LaunchDarkly client configuration. This configuration sets
# reasonable defaults for most users.
Expand Down
20 changes: 20 additions & 0 deletions lib/ldclient-rb/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ def valid?
@error.nil?
end

#
# For a multi-kind context:
#
# A multi-kind context is made up of two or more single-kind contexts. This method will first discard any
# single-kind contexts which are anonymous. It will then create a new multi-kind context from the remaining
# single-kind contexts. This may result in an invalid context (e.g. all single-kind contexts are anonymous).
#
# For a single-kind context:
#
# If the context is not anonymous, this method will return the current context as is and unmodified.
#
# If the context is anonymous, this method will return an invalid context.
#
def without_anonymous_contexts
contexts = multi_kind? ? @contexts : [self]
contexts = contexts.reject { |c| c.anonymous }

LDContext.create_multi(contexts)
end

#
# Returns a hash mapping each context's kind to its key.
#
Expand Down
23 changes: 18 additions & 5 deletions lib/ldclient-rb/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def initialize(sdk_key, config, client = nil, diagnostic_accumulator = nil, test
Impl::EventSender.new(sdk_key, config, client || Util.new_http_client(config.events_uri, config))

@timestamp_fn = (test_properties || {})[:timestamp_fn] || proc { Impl::Util.current_time_millis }
@omit_anonymous_contexts = config.omit_anonymous_contexts

EventDispatcher.new(@inbox, sdk_key, config, diagnostic_accumulator, event_sender)
end
Expand All @@ -167,7 +168,8 @@ def record_eval_event(
end

def record_identify_event(context)
post_to_inbox(LaunchDarkly::Impl::IdentifyEvent.new(timestamp, context))
target_context = !@omit_anonymous_contexts ? context : context.without_anonymous_contexts
post_to_inbox(LaunchDarkly::Impl::IdentifyEvent.new(timestamp, target_context)) if target_context.valid?
end

def record_custom_event(context, key, data = nil, metric_value = nil)
Expand Down Expand Up @@ -319,16 +321,27 @@ def dispatch_event(event, outbox)
will_add_full_event = true
end

# For each context we haven't seen before, we add an index event - unless this is already
# an identify event for that context.
if !event.context.nil? && !notice_context(event.context) && !event.is_a?(LaunchDarkly::Impl::IdentifyEvent) && !event.is_a?(LaunchDarkly::Impl::MigrationOpEvent)
outbox.add_event(LaunchDarkly::Impl::IndexEvent.new(event.timestamp, event.context))
get_indexable_context(event) do |ctx|
outbox.add_event(LaunchDarkly::Impl::IndexEvent.new(event.timestamp, ctx))
end

outbox.add_event(event) if will_add_full_event && @sampler.sample(event.sampling_ratio.nil? ? 1 : event.sampling_ratio)
outbox.add_event(debug_event) if !debug_event.nil? && @sampler.sample(event.sampling_ratio.nil? ? 1 : event.sampling_ratio)
end

private def get_indexable_context(event, &block)
return if event.context.nil?

context = !@config.omit_anonymous_contexts ? event.context : event.context.without_anonymous_contexts
return unless context.valid?

return if notice_context(context)
return if event.is_a?(LaunchDarkly::Impl::IdentifyEvent)
return if event.is_a?(LaunchDarkly::Impl::MigrationOpEvent)

yield context unless block.nil?
end

#
# Add to the set of contexts we've noticed, and return true if the context
# was already known to us.
Expand Down
8 changes: 8 additions & 0 deletions spec/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,13 @@ module LaunchDarkly
end
end
end
describe ".omit_anonymous_contexts" do
it "defaults to false" do
expect(subject.new.omit_anonymous_contexts).to eq false
end
it "can be set to true" do
expect(subject.new(omit_anonymous_contexts: true).omit_anonymous_contexts).to eq true
end
end
end
end
151 changes: 144 additions & 7 deletions spec/events_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,151 @@ module LaunchDarkly
let(:starting_timestamp) { 1000 }
let(:default_config_opts) { { diagnostic_opt_out: true, logger: $null_log } }
let(:default_config) { Config.new(default_config_opts) }
let(:omit_anonymous_contexts_config) { Config.new(default_config_opts.merge(omit_anonymous_contexts: true))}
let(:context) { LDContext.create({ kind: "user", key: "userkey", name: "Red" }) }
let(:anon_context) { LDContext.create({ kind: "org", key: "orgkey", name: "Organization", anonymous: true }) }

it "queues identify event" do
with_processor_and_sender(default_config, starting_timestamp) do |ep, sender|
ep.record_identify_event(context)
describe "identify events" do
it "can be queued" do
with_processor_and_sender(default_config, starting_timestamp) do |ep, sender|
ep.record_identify_event(context)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(eq(identify_event(default_config, context)))
output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(eq(identify_event(default_config, context)))
end
end

it "does queue if anonymous" do
with_processor_and_sender(default_config, starting_timestamp) do |ep, sender|
ep.record_identify_event(anon_context)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(eq(identify_event(default_config, anon_context)))
end
end

it "does not queue if anonymous and omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
ep.record_identify_event(anon_context)

output = flush_and_get_events(ep, sender)
expect(output).to be_nil
end
end

it "strips anonymous contexts from multi kind contexts if omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
user = LDContext.create({ kind: "user", key: "userkey", name: "Example User", anonymous: true })
org = LDContext.create({ kind: "org", key: "orgkey", name: "Big Organization" })
device = LDContext.create({ kind: "device", key: "devicekey", name: "IoT Device", anonymous: true })

ep.record_identify_event(LDContext.create_multi([user, org, device]))

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(eq(identify_event(omit_anonymous_contexts_config, org)))
end
end

it "does not queue if all are anonymous and omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
user = LDContext.create({ kind: "user", key: "userkey", name: "Example User", anonymous: true })
org = LDContext.create({ kind: "org", key: "orgkey", name: "Big Organization", anonymous: true })
device = LDContext.create({ kind: "device", key: "devicekey", name: "IoT Device", anonymous: true })

ep.record_identify_event(LDContext.create_multi([user, org, device]))

output = flush_and_get_events(ep, sender)
expect(output).to be_nil
end
end
end

describe "index events" do
it "does not ignore single-kind anonymous context" do
with_processor_and_sender(default_config, starting_timestamp) do |ep, sender|
flag = { key: "flagkey", version: 11 }
ep.record_eval_event(anon_context, 'flagkey', 11, 1, 'value', nil, nil, true)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(
eq(index_event(default_config, anon_context)),
eq(feature_event(default_config, flag, anon_context, 1, 'value')),
include(:kind => "summary")
)

summary = output.detect { |e| e[:kind] == "summary" }
expect(summary[:features][:flagkey][:contextKinds]).to contain_exactly("org")
end
end

it "ignore single-kind anonymous context if omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
flag = { key: "flagkey", version: 11 }
ep.record_eval_event(anon_context, 'flagkey', 11, 1, 'value', nil, nil, true)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(
eq(feature_event(omit_anonymous_contexts_config, flag, anon_context, 1, 'value')),
include(:kind => "summary")
)

summary = output.detect { |e| e[:kind] == "summary" }
expect(summary[:features][:flagkey][:contextKinds]).to contain_exactly("org")
end
end

it "ignore anonymous contexts from multi-kind if omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
flag = { key: "flagkey", version: 11 }
multi = LDContext.create_multi([context, anon_context])
ep.record_eval_event(multi, 'flagkey', 11, 1, 'value', nil, nil, true)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(
eq(index_event(omit_anonymous_contexts_config, context)),
eq(feature_event(omit_anonymous_contexts_config, flag, multi, 1, 'value')),
include(:kind => "summary")
)

summary = output.detect { |e| e[:kind] == "summary" }
expect(summary[:features][:flagkey][:contextKinds]).to contain_exactly("user", "org")
end
end

it "handles mult-kind context being completely anonymous if omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
flag = { key: "flagkey", version: 11 }
anon_user = LDContext.create({ kind: "user", key: "userkey", name: "User name", anonymous: true })
multi = LDContext.create_multi([anon_user, anon_context])
ep.record_eval_event(multi, 'flagkey', 11, 1, 'value', nil, nil, true)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(
eq(feature_event(omit_anonymous_contexts_config, flag, multi, 1, 'value')),
include(:kind => "summary")
)

summary = output.detect { |e| e[:kind] == "summary" }
expect(summary[:features][:flagkey][:contextKinds]).to contain_exactly("user", "org")
end
end

it "anonymous context does not prevent subsequent index events if omit_anonymous_contexts" do
with_processor_and_sender(omit_anonymous_contexts_config, starting_timestamp) do |ep, sender|
flag = { key: "flagkey", version: 11 }
ep.record_eval_event(anon_context, 'flagkey', 11, 1, 'value', nil, nil, false)
non_anon_context = LDContext.create({ kind: "org", key: "orgkey", name: "Organization", anonymous: false })
ep.record_eval_event(non_anon_context, 'flagkey', 11, 1, 'value', nil, nil, false)

output = flush_and_get_events(ep, sender)
expect(output).to contain_exactly(
eq(index_event(omit_anonymous_contexts_config, non_anon_context, starting_timestamp + 1)),
include(:kind => "summary")
)

summary = output.detect { |e| e[:kind] == "summary" }
expect(summary[:features][:flagkey][:contextKinds]).to contain_exactly("org")
end
end
end

Expand Down Expand Up @@ -274,7 +411,7 @@ module LaunchDarkly

it "treats nil value for custom the same as an empty hash" do
with_processor_and_sender(default_config, starting_timestamp) do |ep, sender|
user_with_nil_custom = LDContext.create({ key: "userkey", custom: nil })
user_with_nil_custom = LDContext.create({ key: "userkey", kind: "user", custom: nil })
ep.record_identify_event(user_with_nil_custom)

output = flush_and_get_events(ep, sender)
Expand Down Expand Up @@ -721,7 +858,7 @@ def custom_event(context, key, data, metric_value)
def flush_and_get_events(ep, sender)
ep.flush
ep.wait_until_inactive
sender.analytics_payloads.pop
sender.analytics_payloads.pop unless sender.analytics_payloads.empty?
end
end
end

0 comments on commit 554f965

Please sign in to comment.