From 5c8e606432eac327fb36932133b4c6f98e9487fd Mon Sep 17 00:00:00 2001 From: esrakartalOpt Date: Wed, 28 Oct 2026 09:19:27 -0500 Subject: [PATCH 1/2] [FSSDK-13349] Normalize ODP event custom_data keys to prevent duplicate-key JSON errors String-keyed custom_data (as received from parsed JSON callers) was being merged into the symbol-keyed default event data without normalization, producing a hash with duplicate-looking keys (e.g. both :data_source_type and "data_source_type"). Serializing that hash raises in newer json gem versions, silently dropping the ODP event instead of sending it. Co-Authored-By: Claude Sonnet 5 --- lib/optimizely/odp/odp_event.rb | 5 ++- spec/odp/odp_event_spec.rb | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 spec/odp/odp_event_spec.rb diff --git a/lib/optimizely/odp/odp_event.rb b/lib/optimizely/odp/odp_event.rb index eff559b6..4d5139b3 100644 --- a/lib/optimizely/odp/odp_event.rb +++ b/lib/optimizely/odp/odp_event.rb @@ -39,7 +39,10 @@ def add_common_event_data(custom_data) data_source: 'ruby-sdk', data_source_version: VERSION } - data.update(custom_data) + # custom_data may use string keys (e.g. parsed from JSON); normalize to symbols so it + # overrides the matching default key above instead of adding a duplicate-looking one, + # which later fails JSON generation (symbol/string key collision). + data.update(custom_data.transform_keys(&:to_sym)) data end diff --git a/spec/odp/odp_event_spec.rb b/spec/odp/odp_event_spec.rb new file mode 100644 index 00000000..5b0e019a --- /dev/null +++ b/spec/odp/odp_event_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright 2026, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http:#www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +require 'spec_helper' +require 'optimizely/odp/odp_event' + +describe Optimizely::OdpEvent do + describe 'custom data with string keys matching the default symbol-keyed fields' do + # String keys are what callers get back from parsed JSON (e.g. a testapp or any + # non-Ruby-native caller), which previously collided with the default symbol keys + # instead of overriding them, producing a hash with duplicate-looking keys that + # fails JSON generation (FSSDK-13349). + let(:custom_data) do + { + 'data_source_type' => 'sdk', + 'data_source' => 'test_sdk', + 'data_source_version' => '0.0.1' + } + end + + let(:event) { described_class.new(type: 'fullstack', action: 'test_action', identifiers: {'fs_user_id' => 'user1'}, data: custom_data) } + + it 'overrides the default fields instead of duplicating them' do + parsed = JSON.parse(event.to_json) + expect(parsed['data']['data_source_type']).to eq('sdk') + expect(parsed['data']['data_source']).to eq('test_sdk') + expect(parsed['data']['data_source_version']).to eq('0.0.1') + end + + it 'produces a hash with no duplicate symbol/string keys' do + data = event.instance_variable_get(:@data) + string_and_symbol_keys = data.keys.group_by(&:to_s) + expect(string_and_symbol_keys.values.map(&:size).uniq).to eq([1]) + end + + it 'serializes without raising' do + expect { event.to_json }.not_to raise_error + end + end + + describe 'custom data with keys not present in the defaults' do + let(:event) { described_class.new(type: 'fullstack', action: 'test_action', identifiers: {'fs_user_id' => 'user1'}, data: {'custom_key' => 'custom_value'}) } + + it 'keeps the defaults and adds the custom field' do + parsed = JSON.parse(event.to_json) + expect(parsed['data']['data_source_type']).to eq('sdk') + expect(parsed['data']['data_source']).to eq('ruby-sdk') + expect(parsed['data']['custom_key']).to eq('custom_value') + end + end +end From 8b8360868882d062665c074f1e91464dc209b720 Mon Sep 17 00:00:00 2001 From: esrakartalOpt Date: Wed, 28 Oct 2026 12:04:57 -0500 Subject: [PATCH 2/2] [FSSDK-13349] Only symbolize string keys when merging ODP custom data transform_keys(&:to_sym) raised NoMethodError for non-string keys (e.g. Integer, nil), which odp_data_types_valid? doesn't filter out since it only checks hash values. Only string keys can collide with the symbol-keyed defaults, so only those need normalizing; other key types are left as-is, matching prior behavior. Addresses review feedback from @FarhanAnjum-opti on PR #408. Co-Authored-By: Claude Sonnet 5 --- lib/optimizely/odp/odp_event.rb | 9 +++++---- spec/odp/odp_event_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/optimizely/odp/odp_event.rb b/lib/optimizely/odp/odp_event.rb index 4d5139b3..24e78ab9 100644 --- a/lib/optimizely/odp/odp_event.rb +++ b/lib/optimizely/odp/odp_event.rb @@ -39,10 +39,11 @@ def add_common_event_data(custom_data) data_source: 'ruby-sdk', data_source_version: VERSION } - # custom_data may use string keys (e.g. parsed from JSON); normalize to symbols so it - # overrides the matching default key above instead of adding a duplicate-looking one, - # which later fails JSON generation (symbol/string key collision). - data.update(custom_data.transform_keys(&:to_sym)) + # custom_data may use string keys (e.g. parsed from JSON); normalize those to symbols so + # they override the matching default key above instead of adding a duplicate-looking one, + # which later fails JSON generation (symbol/string key collision). Non-string keys are left + # as-is, since they can't collide with the symbol-keyed defaults and to_sym would raise. + data.update(custom_data.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }) data end diff --git a/spec/odp/odp_event_spec.rb b/spec/odp/odp_event_spec.rb index 5b0e019a..87c13b83 100644 --- a/spec/odp/odp_event_spec.rb +++ b/spec/odp/odp_event_spec.rb @@ -59,4 +59,24 @@ expect(parsed['data']['custom_key']).to eq('custom_value') end end + + describe 'custom data with non-string keys' do + # odp_data_types_valid? only checks hash values, not keys, so a caller can reach + # OdpEvent with non-string keys (e.g. Integer, nil). These can't collide with the + # symbol-keyed defaults, and calling to_sym on them would raise, so they must be + # left as-is rather than symbolized. + let(:event) { described_class.new(type: 'fullstack', action: 'test_action', identifiers: {'fs_user_id' => 'user1'}, data: {1 => 'x', nil => 'y'}) } + + it 'does not raise' do + expect { event.to_json }.not_to raise_error + end + + it 'keeps the defaults and the non-string-keyed fields' do + parsed = JSON.parse(event.to_json) + expect(parsed['data']['data_source_type']).to eq('sdk') + expect(parsed['data']['data_source']).to eq('ruby-sdk') + expect(parsed['data']['1']).to eq('x') + expect(parsed['data']['']).to eq('y') + end + end end