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
5 changes: 5 additions & 0 deletions .changeset/quiet-rails-numbers.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-numbers-travel.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions lib/posthog/message_batch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def initialize(max_message_count)
# @return [Array<Hash>, nil]
def <<(message)
begin
message = normalize_non_finite_numbers(message)
message_json = message.to_json
rescue StandardError => e
raise JSONGenerationError, "Serialization error: #{e}"
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions spec/isolated/json_example.rb
Original file line number Diff line number Diff line change
@@ -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' }
Expand Down
8 changes: 8 additions & 0 deletions spec/isolated/with_json.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

require 'posthog'
require 'isolated/json_example'

describe 'with json' do
include_examples 'message_batch_json'
end
4 changes: 2 additions & 2 deletions spec/isolated_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
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'),
"spec/isolated/#{configuration}.rb"
)

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
35 changes: 35 additions & 0 deletions spec/posthog/message_batch_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
53 changes: 53 additions & 0 deletions spec/posthog/non_finite_capture_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading