From 813e62a4bba5a59f79b44a52cd4a0c13ec3d2b97 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 2 Nov 2026 11:33:58 -0500 Subject: [PATCH 1/5] 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' }) From 5558ea1ff2986e0b55a615436872ce26450450ee Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 3 Nov 2026 07:36:32 -0500 Subject: [PATCH 2/5] fix: Freeze the private attributes set on the config Config#private_attributes now returns a Set. Without the freeze, a caller can change the set after the client starts, and the event processor reads the changed set. LDContext#private_attributes is already frozen, so this makes the two match. --- lib/ldclient-rb/config.rb | 2 +- spec/config_spec.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 55344f86..bc116471 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -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 = Set.new(opts[:private_attributes] || []) + @private_attributes = Set.new(opts[:private_attributes] || []).freeze @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 diff --git a/spec/config_spec.rb b/spec/config_spec.rb index c234d744..eb11a3de 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -142,6 +142,9 @@ module LaunchDarkly it "converts an array to a set" do expect(subject.new(private_attributes: ["name", "email", "name"]).private_attributes).to eq Set["name", "email"] end + it "is frozen" do + expect(subject.new(private_attributes: ["name"]).private_attributes).to be_frozen + end end end end From 228d01e7769d32e121e1917076ec05ccaf7976f7 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 3 Nov 2026 07:37:38 -0500 Subject: [PATCH 3/5] fix: Accept a set of private attributes when creating a context Config#private_attributes now returns a Set, but LDContext.create rejected any privateAttributes value that was not an Array. Passing the config value into a context made an invalid context. LDContext.create now accepts an Array or a Set. --- lib/ldclient-rb/context.rb | 6 +++--- spec/context_spec.rb | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/ldclient-rb/context.rb b/lib/ldclient-rb/context.rb index fadffe68..616e4512 100644 --- a/lib/ldclient-rb/context.rb +++ b/lib/ldclient-rb/context.rb @@ -44,7 +44,7 @@ class LDContext private_constant :ERR_KIND_MULTI_DUPLICATES ERR_CUSTOM_NON_HASH = 'context custom must be a hash' private_constant :ERR_CUSTOM_NON_HASH - ERR_PRIVATE_NON_ARRAY = 'context private attributes must be an array' + ERR_PRIVATE_NON_ARRAY = 'context private attributes must be an array or a set' # @return [String, nil] Returns the key for this context attr_reader :key @@ -66,7 +66,7 @@ class LDContext # @param name [String, nil] # @param anonymous [Boolean, nil] # @param attributes [Hash, nil] - # @param private_attributes [Array, nil] + # @param private_attributes [Array, Set, nil] # @param error [String, nil] # @param contexts [Array, nil] # @@ -567,7 +567,7 @@ def self.create_multi(contexts) meta = data.fetch(:_meta, {}) private_attributes = meta[:privateAttributes] - if private_attributes && !private_attributes.is_a?(Array) + if private_attributes && !private_attributes.is_a?(Array) && !private_attributes.is_a?(Set) return create_invalid_context(ERR_PRIVATE_NON_ARRAY) end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index 427ee0f2..cf231148 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -77,6 +77,13 @@ module LaunchDarkly expect(subject.create(context).valid?).to be false end + it "accepts privateAttributes as a set" do + config = LaunchDarkly::Config.new(private_attributes: ["email"]) + context = subject.create({ key: "user-key", kind: "user", _meta: { privateAttributes: config.private_attributes } }) + expect(context.valid?).to be true + expect(context.private_attributes).to eq(Set[LaunchDarkly::Reference.create("email")]) + end + it "creates the correct fully qualified key" do expect(subject.create({ key: "user-key", kind: "user" }).fully_qualified_key).to eq("user-key") expect(subject.create({ key: "org-key", kind: "org" }).fully_qualified_key).to eq("org:org-key") From 90e191cc28048d1d2ac74f5e022b81f8c174b419 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 3 Nov 2026 07:41:23 -0500 Subject: [PATCH 4/5] fix: Accept references as private attributes LDContext#private_attributes returns a Set of Reference objects. Reference.create accepted only a String or a Symbol, so passing those references into a new context made each one an empty reference, and the SDK dropped them. Reference.create now returns a Reference argument unchanged. A Reference is immutable, so it is safe to share. This also lets Config accept references in private_attributes. --- lib/ldclient-rb/reference.rb | 6 +++++- spec/context_spec.rb | 7 +++++++ spec/reference_spec.rb | 5 +++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/ldclient-rb/reference.rb b/lib/ldclient-rb/reference.rb index d50b1e6d..3c93bdaf 100644 --- a/lib/ldclient-rb/reference.rb +++ b/lib/ldclient-rb/reference.rb @@ -125,10 +125,14 @@ def initialize(raw_path, components = [], error = nil) # validation fails, {#error} will return a non-nil error and any SDK method # that takes this Reference as a parameter will consider it invalid. # - # @param value [String, Symbol] + # If the value is a Reference, this method returns the same Reference. + # + # @param value [String, Symbol, Reference] # @return [Reference] # def self.create(value) + return value if value.is_a?(Reference) + unless value.is_a?(String) || value.is_a?(Symbol) return new(value, [], ERR_EMPTY) end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index cf231148..f3bdc4bd 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -84,6 +84,13 @@ module LaunchDarkly expect(context.private_attributes).to eq(Set[LaunchDarkly::Reference.create("email")]) end + it "accepts privateAttributes from another context" do + original = subject.create({ key: "user-key", kind: "user", _meta: { privateAttributes: ["email", "/address/city"] } }) + context = subject.create({ key: "user-key", kind: "user", _meta: { privateAttributes: original.private_attributes } }) + expect(context.valid?).to be true + expect(context.private_attributes).to eq(original.private_attributes) + end + it "creates the correct fully qualified key" do expect(subject.create({ key: "user-key", kind: "user" }).fully_qualified_key).to eq("user-key") expect(subject.create({ key: "org-key", kind: "org" }).fully_qualified_key).to eq("org:org-key") diff --git a/spec/reference_spec.rb b/spec/reference_spec.rb index 4c0d0a51..a4a661f7 100644 --- a/spec/reference_spec.rb +++ b/spec/reference_spec.rb @@ -90,6 +90,11 @@ module LaunchDarkly end end + it "returns the same reference when given a reference" do + reference = Reference.create("/a/b") + expect(Reference.create(reference)).to be(reference) + end + describe "creating literal references" do it "can create valid references" do [ From 4de0ad615aabfc8f6322baf89091eff51ede8a2b Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 3 Nov 2026 07:44:18 -0500 Subject: [PATCH 5/5] test: Remove the Config dependency from the context set spec context_spec.rb loads only ldclient-rb/context, so the spec failed with NameError when the file ran alone. A Set literal tests the same path. --- spec/context_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/context_spec.rb b/spec/context_spec.rb index f3bdc4bd..f4a65006 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -78,8 +78,7 @@ module LaunchDarkly end it "accepts privateAttributes as a set" do - config = LaunchDarkly::Config.new(private_attributes: ["email"]) - context = subject.create({ key: "user-key", kind: "user", _meta: { privateAttributes: config.private_attributes } }) + context = subject.create({ key: "user-key", kind: "user", _meta: { privateAttributes: Set["email"] } }) expect(context.valid?).to be true expect(context.private_attributes).to eq(Set[LaunchDarkly::Reference.create("email")]) end