Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/optimizely/odp/odp_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ 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 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

Expand Down
82 changes: 82 additions & 0 deletions spec/odp/odp_event_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 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

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
Loading