From 813e62a4bba5a59f79b44a52cd4a0c13ec3d2b97 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 2 Nov 2026 11:33:58 -0500 Subject: [PATCH] feat!: Return private attributes as a Set Config#private_attributes now returns a Set of attribute reference strings instead of an Array. The :private_attributes option still accepts any Enumerable, and the SDK converts it to a Set. LDContext#private_attributes now returns a frozen Set of Reference objects instead of an Array. The context already stored a Set internally and copied it to an Array on every call. Private attributes have no meaningful order or duplicates, so a Set is the correct type. Code that indexes into the result or calls Array-only methods on it must change. LDContext#to_h and #to_json still emit privateAttributes as an Array, so serialized contexts and event payloads do not change. --- lib/ldclient-rb/config.rb | 8 ++++---- lib/ldclient-rb/context.rb | 13 +++++-------- lib/ldclient-rb/impl/context_filter.rb | 10 +++++----- spec/config_spec.rb | 8 ++++++++ spec/context_spec.rb | 13 +++++++++++++ 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 1b77ac16..55344f86 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -34,7 +34,7 @@ class Config # @option opts [Float] :poll_interval (30) See {#poll_interval}. # @option opts [Boolean] :stream (true) See {#stream?}. # @option opts [Boolean] all_attributes_private (false) See {#all_attributes_private}. - # @option opts [Array] :private_attributes See {#private_attributes}. + # @option opts [Enumerable] :private_attributes See {#private_attributes}. # @option opts [Boolean] :send_events (true) See {#send_events}. # @option opts [Integer] :context_keys_capacity (1000) See {#context_keys_capacity}. # @option opts [Float] :context_keys_flush_interval (300) See {#context_keys_flush_interval}. @@ -69,7 +69,7 @@ def initialize(opts = {}) @offline = opts.has_key?(:offline) ? opts[:offline] : Config.default_offline @poll_interval = opts.has_key?(:poll_interval) && opts[:poll_interval] > Config.default_poll_interval ? opts[:poll_interval] : Config.default_poll_interval @all_attributes_private = opts[:all_attributes_private] || false - @private_attributes = opts[:private_attributes] || [] + @private_attributes = Set.new(opts[:private_attributes] || []) @send_events = opts.has_key?(:send_events) ? opts[:send_events] : Config.default_send_events @compress_events = opts.has_key?(:compress_events) ? opts[:compress_events] : Config.default_compress_events @context_keys_capacity = opts[:context_keys_capacity] || Config.default_context_keys_capacity @@ -255,7 +255,7 @@ def offline? attr_reader :all_attributes_private # - # A list of context attribute names that should always be considered private. This means that the + # A set of context attribute names that should always be considered private. This means that the # attribute values will not be sent to LaunchDarkly in analytics events and will not appear on # the LaunchDarkly dashboard. # @@ -268,7 +268,7 @@ def offline? # # @see https://docs.launchdarkly.com/sdk/features/user-context-config#using-private-attributes # - # @return [Array] + # @return [Set] # @see #all_attributes_private # attr_reader :private_attributes diff --git a/lib/ldclient-rb/context.rb b/lib/ldclient-rb/context.rb index 58e8c40a..fadffe68 100644 --- a/lib/ldclient-rb/context.rb +++ b/lib/ldclient-rb/context.rb @@ -82,6 +82,7 @@ def initialize(key, fully_qualified_key, kind, name = nil, anonymous = nil, attr reference = Reference.create(attribute) @private_attributes.add(reference) if reference.error.nil? end + @private_attributes.freeze @error = error @contexts = contexts @is_multi = !contexts.nil? @@ -91,12 +92,9 @@ def initialize(key, fully_qualified_key, kind, name = nil, anonymous = nil, attr protected attr_reader :name, :anonymous, :attributes # - # @return [Array] Returns the private attributes associated with this LDContext + # @return [Set] Returns the private attributes associated with this LDContext. The set is frozen. # - def private_attributes - # TODO(sc-227265): Return a set instead of an array. - @private_attributes.to_a - end + attr_reader :private_attributes # # @return [Boolean] Is this LDContext a multi-kind context? @@ -353,8 +351,7 @@ def ==(other) return false unless self.anonymous == other.anonymous return false unless self.attributes == other.attributes - # TODO(sc-227265): Calling .to_set is unnecessary once private_attributes are sets. - return false unless self.private_attributes.to_set == other.private_attributes.to_set + return false unless self.private_attributes == other.private_attributes true end @@ -413,7 +410,7 @@ def to_h hash[:name] = name unless name.nil? hash[:anonymous] = anonymous if anonymous - hash[:_meta] = {privateAttributes: private_attributes} unless private_attributes.empty? + hash[:_meta] = {privateAttributes: private_attributes.to_a} unless private_attributes.empty? hash end diff --git a/lib/ldclient-rb/impl/context_filter.rb b/lib/ldclient-rb/impl/context_filter.rb index 30c6ecdb..736611f0 100644 --- a/lib/ldclient-rb/impl/context_filter.rb +++ b/lib/ldclient-rb/impl/context_filter.rb @@ -5,7 +5,7 @@ module Impl class ContextFilter # # @param all_attributes_private [Boolean] - # @param private_attributes [Array] + # @param private_attributes [Enumerable] # @param logger [Logger, nil] # def initialize(all_attributes_private, private_attributes, logger = nil) @@ -13,7 +13,7 @@ def initialize(all_attributes_private, private_attributes, logger = nil) @logger = logger @non_symbol_name_logged = Concurrent::AtomicBoolean.new(false) - @private_attributes = [] + @private_attributes = Set.new private_attributes.each do |attribute| reference = LaunchDarkly::Reference.create(attribute) @private_attributes << reference if reference.error.nil? @@ -75,7 +75,7 @@ def filter_redact_anonymous(context) filtered[:anonymous] = true if anonymous redacted = [] - private_attributes = @private_attributes + context.private_attributes + private_attributes = @private_attributes | context.private_attributes name = context.get_value(:name) if !name.nil? && !check_whole_attribute_private(Reference.create_literal(:name), private_attributes, redacted, anonymous && redact_anonymous) @@ -103,7 +103,7 @@ def filter_redact_anonymous(context) # Check if an entire attribute should be redacted. # # @param attribute [Reference] - # @param private_attributes [Array] + # @param private_attributes [Set] # @param redacted [Array] # @param redact_all [Boolean] # @return [Boolean] @@ -130,7 +130,7 @@ def filter_redact_anonymous(context) # @param parent_path [Array, nil] # @param name [String] # @param value [any] - # @param private_attributes [Array] + # @param private_attributes [Set] # @param redacted [Array] # @param visited [Array] # @return [any] diff --git a/spec/config_spec.rb b/spec/config_spec.rb index 687b9b09..c234d744 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -135,5 +135,13 @@ module LaunchDarkly expect(subject.new(omit_anonymous_contexts: true).omit_anonymous_contexts).to eq true end end + describe ".private_attributes" do + it "defaults to an empty set" do + expect(subject.new.private_attributes).to eq Set.new + end + it "converts an array to a set" do + expect(subject.new(private_attributes: ["name", "email", "name"]).private_attributes).to eq Set["name", "email"] + end + end end end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index 44be8e4a..427ee0f2 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -394,6 +394,19 @@ module LaunchDarkly end end + describe "private attributes" do + it "returns a frozen set of references" do + context = subject.create({ key: 'context-key', kind: 'user', _meta: { privateAttributes: ['name', 'address/city', 'name'] } }) + expect(context.private_attributes).to be_a(Set) + expect(context.private_attributes).to be_frozen + expect(context.private_attributes).to eq(Set[LaunchDarkly::Reference.create('name'), LaunchDarkly::Reference.create('address/city')]) + end + + it "returns an empty set when none are given" do + expect(subject.create({ key: 'context-key', kind: 'user' }).private_attributes).to eq(Set.new) + end + end + describe "equality comparisons" do it "wrong types are not equal" do context = subject.create({ key: 'context-key', kind: 'user' })