Skip to content

Commit 432a1c5

Browse files
[FSSDK-13349] Fix ODP event duplicate symbol/string key JSON error (#408)
* [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 <noreply@anthropic.com> * [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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e1c94f7 commit 432a1c5

2 files changed

Lines changed: 87 additions & 1 deletion

File tree

‎lib/optimizely/odp/odp_event.rb‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ def add_common_event_data(custom_data)
3939
data_source: 'ruby-sdk',
4040
data_source_version: VERSION
4141
}
42-
data.update(custom_data)
42+
# custom_data may use string keys (e.g. parsed from JSON); normalize those to symbols so
43+
# they override the matching default key above instead of adding a duplicate-looking one,
44+
# which later fails JSON generation (symbol/string key collision). Non-string keys are left
45+
# as-is, since they can't collide with the symbol-keyed defaults and to_sym would raise.
46+
data.update(custom_data.transform_keys { |key| key.is_a?(String) ? key.to_sym : key })
4347
data
4448
end
4549

‎spec/odp/odp_event_spec.rb‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2026, Optimizely
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http:#www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
require 'spec_helper'
16+
require 'optimizely/odp/odp_event'
17+
18+
describe Optimizely::OdpEvent do
19+
describe 'custom data with string keys matching the default symbol-keyed fields' do
20+
# String keys are what callers get back from parsed JSON (e.g. a testapp or any
21+
# non-Ruby-native caller), which previously collided with the default symbol keys
22+
# instead of overriding them, producing a hash with duplicate-looking keys that
23+
# fails JSON generation (FSSDK-13349).
24+
let(:custom_data) do
25+
{
26+
'data_source_type' => 'sdk',
27+
'data_source' => 'test_sdk',
28+
'data_source_version' => '0.0.1'
29+
}
30+
end
31+
32+
let(:event) { described_class.new(type: 'fullstack', action: 'test_action', identifiers: {'fs_user_id' => 'user1'}, data: custom_data) }
33+
34+
it 'overrides the default fields instead of duplicating them' do
35+
parsed = JSON.parse(event.to_json)
36+
expect(parsed['data']['data_source_type']).to eq('sdk')
37+
expect(parsed['data']['data_source']).to eq('test_sdk')
38+
expect(parsed['data']['data_source_version']).to eq('0.0.1')
39+
end
40+
41+
it 'produces a hash with no duplicate symbol/string keys' do
42+
data = event.instance_variable_get(:@data)
43+
string_and_symbol_keys = data.keys.group_by(&:to_s)
44+
expect(string_and_symbol_keys.values.map(&:size).uniq).to eq([1])
45+
end
46+
47+
it 'serializes without raising' do
48+
expect { event.to_json }.not_to raise_error
49+
end
50+
end
51+
52+
describe 'custom data with keys not present in the defaults' do
53+
let(:event) { described_class.new(type: 'fullstack', action: 'test_action', identifiers: {'fs_user_id' => 'user1'}, data: {'custom_key' => 'custom_value'}) }
54+
55+
it 'keeps the defaults and adds the custom field' do
56+
parsed = JSON.parse(event.to_json)
57+
expect(parsed['data']['data_source_type']).to eq('sdk')
58+
expect(parsed['data']['data_source']).to eq('ruby-sdk')
59+
expect(parsed['data']['custom_key']).to eq('custom_value')
60+
end
61+
end
62+
63+
describe 'custom data with non-string keys' do
64+
# odp_data_types_valid? only checks hash values, not keys, so a caller can reach
65+
# OdpEvent with non-string keys (e.g. Integer, nil). These can't collide with the
66+
# symbol-keyed defaults, and calling to_sym on them would raise, so they must be
67+
# left as-is rather than symbolized.
68+
let(:event) { described_class.new(type: 'fullstack', action: 'test_action', identifiers: {'fs_user_id' => 'user1'}, data: {1 => 'x', nil => 'y'}) }
69+
70+
it 'does not raise' do
71+
expect { event.to_json }.not_to raise_error
72+
end
73+
74+
it 'keeps the defaults and the non-string-keyed fields' do
75+
parsed = JSON.parse(event.to_json)
76+
expect(parsed['data']['data_source_type']).to eq('sdk')
77+
expect(parsed['data']['data_source']).to eq('ruby-sdk')
78+
expect(parsed['data']['1']).to eq('x')
79+
expect(parsed['data']['']).to eq('y')
80+
end
81+
end
82+
end

0 commit comments

Comments
 (0)