From 88a471313c705703d415ab2f462e220672658250 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 13:41:09 +0200 Subject: [PATCH 1/2] fix: serialize non-finite event properties as null --- .changeset/tidy-numbers-travel.md | 5 +++ lib/posthog/message_batch.rb | 34 ++++++++++++++++ spec/isolated/json_example.rb | 21 ++++++++++ spec/isolated/with_json.rb | 8 ++++ spec/isolated_spec.rb | 4 +- spec/posthog/message_batch_spec.rb | 35 ++++++++++++++++ spec/posthog/non_finite_capture_spec.rb | 53 +++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-numbers-travel.md create mode 100644 spec/isolated/with_json.rb create mode 100644 spec/posthog/non_finite_capture_spec.rb diff --git a/.changeset/tidy-numbers-travel.md b/.changeset/tidy-numbers-travel.md new file mode 100644 index 00000000..2e47273e --- /dev/null +++ b/.changeset/tidy-numbers-travel.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': patch +--- + +Serialize numeric NaN and positive or negative Infinity in event properties as JSON null instead of losing the event. Preserve nested properties, array positions, and caller-owned values in synchronous and background delivery. diff --git a/lib/posthog/message_batch.rb b/lib/posthog/message_batch.rb index 0628ef89..f45f080d 100644 --- a/lib/posthog/message_batch.rb +++ b/lib/posthog/message_batch.rb @@ -26,6 +26,7 @@ def initialize(max_message_count) # @return [Array, nil] def <<(message) begin + message = normalize_non_finite_numbers(message) message_json = message.to_json rescue StandardError => e raise JSONGenerationError, "Serialization error: #{e}" @@ -57,6 +58,39 @@ def clear private + def normalize_non_finite_numbers(message) + copies = {}.compare_by_identity + pending = [] + normalized = copy_json_value(message, copies, pending) + + # Traverse iteratively so circular/deep containers still reach the JSON encoder's error handling. + until pending.empty? + container = pending.pop + if container.is_a?(Hash) + container.transform_values! { |value| copy_json_value(value, copies, pending) } + else + container.map! { |value| copy_json_value(value, copies, pending) } + end + end + normalized + end + + def copy_json_value(value, copies, pending) + case value + when Float + value.finite? ? value : nil + when Hash, Array + copies.fetch(value) do + copy = value.clone(freeze: false) + copies[value] = copy + pending << copy + copy + end + else + value + end + end + def item_count_exhausted? @messages.length >= @max_message_count end diff --git a/spec/isolated/json_example.rb b/spec/isolated/json_example.rb index 29780432..c8deb6fd 100644 --- a/spec/isolated/json_example.rb +++ b/spec/isolated/json_example.rb @@ -1,6 +1,27 @@ # frozen_string_literal: true RSpec.shared_examples 'message_batch_json' do + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |number| + it "MessageBatch serializes #{number} as null" do + batch = PostHog::MessageBatch.new(100) + message = { 'event' => 'non-finite', + 'properties' => { 'number' => number, 'items' => [1, number, { 'number' => number }] } } + batch << message + batch << { 'event' => 'neighbor', 'properties' => { 'finite' => 2.5, 'literal' => 'NaN' } } + + expect(JSON.parse(JSON.generate(batch))).to eq([ + { 'event' => 'non-finite', + 'properties' => { 'number' => nil, + 'items' => [1, nil, { 'number' => nil }] } }, + { 'event' => 'neighbor', + 'properties' => { 'finite' => 2.5, 'literal' => 'NaN' } } + ]) + expect(message['properties']['number']).to equal(number) + expect(message['properties']['items'][1]).to equal(number) + expect(message['properties']['items'][2]['number']).to equal(number) + end + end + it 'MessageBatch generates proper JSON' do batch = PostHog::MessageBatch.new(100) batch << { 'a' => 'b' } diff --git a/spec/isolated/with_json.rb b/spec/isolated/with_json.rb new file mode 100644 index 00000000..b7adc141 --- /dev/null +++ b/spec/isolated/with_json.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'posthog' +require 'isolated/json_example' + +describe 'with json' do + include_examples 'message_batch_json' +end diff --git a/spec/isolated_spec.rb b/spec/isolated_spec.rb index f4dd68c8..a11231b9 100644 --- a/spec/isolated_spec.rb +++ b/spec/isolated_spec.rb @@ -4,7 +4,7 @@ require 'open3' RSpec.describe 'JSON compatibility in isolated processes' do - %w[with_active_support with_oj with_active_support_and_oj].each do |configuration| + %w[with_json with_active_support with_oj with_active_support_and_oj].each do |configuration| it "serializes batches #{configuration}" do stdout, stderr, status = Open3.capture3( RbConfig.ruby, '-Ilib', '-Ispec', Gem.bin_path('rspec-core', 'rspec'), @@ -12,7 +12,7 @@ ) expect(status).to be_success, "#{stdout}\n#{stderr}" - expect(stdout).to include('1 example, 0 failures') + expect(stdout).to include('4 examples, 0 failures') end end end diff --git a/spec/posthog/message_batch_spec.rb b/spec/posthog/message_batch_spec.rb index 73bde3e8..ee5356bc 100644 --- a/spec/posthog/message_batch_spec.rb +++ b/spec/posthog/message_batch_spec.rb @@ -13,6 +13,41 @@ module PostHog expect(JSON.parse(subject.to_json)).to eq([{ 'a' => 'b' }]) end + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |number| + it "serializes #{number} as null without changing the input or losing neighboring messages" do + message = { properties: { number: number, items: [1, { number: number }, number, 3] } } + subject << message + subject << { event: 'neighbor', properties: { finite: 2.5 } } + + expect(JSON.parse(JSON.generate(api_key: 'test', batch: subject))).to eq( + 'api_key' => 'test', + 'batch' => [ + { 'properties' => { 'number' => nil, 'items' => [1, { 'number' => nil }, nil, 3] } }, + { 'event' => 'neighbor', 'properties' => { 'finite' => 2.5 } } + ] + ) + expect(message[:properties][:number]).to equal(number) + expect(message[:properties][:items][1][:number]).to equal(number) + expect(message[:properties][:items][2]).to equal(number) + end + end + + it 'still reports unsupported serialization errors without dropping valid neighbors' do + unsupported = Object.new + def unsupported.as_json(*) + raise TypeError, 'unsupported value' + end + + def unsupported.to_json(*) + raise TypeError, 'unsupported value' + end + subject << { event: 'neighbor' } + + expect { subject << { properties: { number: Float::NAN, unsupported: unsupported } } } + .to raise_error(MessageBatch::JSONGenerationError, /unsupported value/) + expect(JSON.parse(subject.to_json)).to eq([{ 'event' => 'neighbor' }]) + end + it 'rejects messages that exceed the maximum allowed size' do max_bytes = Defaults::Message::MAX_BYTES message = { 'a' => 'b' * max_bytes } diff --git a/spec/posthog/non_finite_capture_spec.rb b/spec/posthog/non_finite_capture_spec.rb new file mode 100644 index 00000000..041394b6 --- /dev/null +++ b/spec/posthog/non_finite_capture_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Capture with non-finite event properties' do + [true, false].each do |sync_mode| + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |number| + it "delivers #{number} as null with sync_mode: #{sync_mode}" do + payloads = [] + errors = [] + request = stub_request(:post, 'https://us.i.posthog.com/batch/').to_return do |req| + payloads << JSON.parse(req.body) + { status: 200, body: '{}' } + end + client = PostHog::Client.new( + api_key: API_KEY, sync_mode: sync_mode, batch_size: 2, flush_interval_seconds: 60, + compress_request: false, on_error: proc { |*error| errors << error } + ) + nested = { 'number' => number, 'finite' => 1.25 }.freeze + items = [0, number, { 'number' => number }.freeze, [number].freeze, false, nil].freeze + properties = { + 'number' => number, 'nested' => nested, 'items' => items, + 'finite' => 42, 'enabled' => false, 'null' => nil, + 'literals' => %w[NaN Infinity -Infinity], 'date' => Date.new(2026, 10, 22) + } + + expect(client.capture(distinct_id: 'user', event: 'non-finite', properties: properties)).to eq(true) + expect(client.capture(distinct_id: 'user', event: 'neighbor', properties: { 'valid' => true })).to eq(true) + expect(client.flush(timeout: 2)).to eq(true) + + events = payloads.flat_map { |payload| payload.fetch('batch') } + expect(events.map { |event| event.fetch('event') }).to eq(%w[non-finite neighbor]) + expect(events.first.fetch('properties')).to include( + 'number' => nil, 'nested' => { 'number' => nil, 'finite' => 1.25 }, + 'items' => [0, nil, { 'number' => nil }, [nil], false, nil], + 'finite' => 42, 'enabled' => false, 'null' => nil, + 'literals' => %w[NaN Infinity -Infinity], 'date' => '2026-10-22', + '$lib' => 'posthog-ruby', '$lib_version' => PostHog::VERSION + ) + expect(events.last.fetch('properties')).to include('valid' => true) + expect(properties['number']).to equal(number) + expect(nested['number']).to equal(number) + expect(items[1]).to equal(number) + expect(items[2]['number']).to equal(number) + expect(items[3][0]).to equal(number) + expect(errors).to eq([]) + expect(request).to have_been_requested.times(sync_mode ? 2 : 1) + ensure + client&.shutdown(timeout: 2) + end + end + end +end From 65e8f4e9c3f578eaa1351c39f0dc08f29dccd057 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 15:32:27 +0200 Subject: [PATCH 2/2] chore: include Rails in non-finite properties release --- .changeset/quiet-rails-numbers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-rails-numbers.md diff --git a/.changeset/quiet-rails-numbers.md b/.changeset/quiet-rails-numbers.md new file mode 100644 index 00000000..671037e7 --- /dev/null +++ b/.changeset/quiet-rails-numbers.md @@ -0,0 +1,5 @@ +--- +'posthog-rails': patch +--- + +Update the core SDK dependency so Rails events with numeric NaN or infinities serialize as JSON null instead of being lost.