From c38f465a1108d248f6cf99b5deff9352837b9cf1 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Wed, 23 Sep 2026 10:32:12 +0200 Subject: [PATCH] feat: send $release_id from POSTHOG_RELEASE_ID on every event Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/release-id-env.md | 5 ++ lib/posthog/client.rb | 21 ++++++++ spec/posthog/client_spec.rb | 82 +++++++++++++++++++++++++++++++ spec/posthog/feature_flag_spec.rb | 9 ++++ 4 files changed, 117 insertions(+) create mode 100644 .changeset/release-id-env.md diff --git a/.changeset/release-id-env.md b/.changeset/release-id-env.md new file mode 100644 index 0000000..1b94574 --- /dev/null +++ b/.changeset/release-id-env.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': minor +--- + +Read the release id from the `POSTHOG_RELEASE_ID` environment variable and send it as `$release_id` on every event, including `$exception`, `$identify`, `$groupidentify`, `$create_alias` and minimal `$feature_flag_called` events. On `$exception` events, error tracking uses it to link the exception to its release by a direct id lookup. Create the release and get its id with `posthog-cli release resolve`. The client reads the variable once, when it is created. An explicit `$release_id` in the event properties or the request context wins over the environment variable, and `before_send` can still change or remove it. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 1c665e1..73e21b7 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -44,9 +44,16 @@ class Client $is_server $lib $lib_version + $release_id ].flat_map { |key| [key, key.to_sym] }.freeze private_constant :MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + # Holds the id that `posthog-cli release resolve` prints. Error tracking + # links an exception to its release by this id, because a Ruby app has no + # build step that could inject it. + RELEASE_ID_ENV_VAR = 'POSTHOG_RELEASE_ID' + private_constant :RELEASE_ID_ENV_VAR + # Thread-safe tracking of client instances per API key for singleton warnings @instances_by_api_key = {} @instances_mutex = Mutex.new @@ -217,6 +224,7 @@ def initialize(opts = {}) @before_send = opts[:before_send] @is_server = opts.fetch(:is_server, true) != false + @release_id = normalize_string_option(ENV.fetch(RELEASE_ID_ENV_VAR, nil), blank_as_nil: true) @deprecation_emitted_for = Concurrent::Set.new end @@ -1137,6 +1145,8 @@ def process_before_send(action) def enqueue(action) return false if @disabled || shutdown? + # Every event type passes through here, and before_send still sees the id. + action = add_release_id(action) action = process_before_send(action) return false if action.nil? || action.empty? @@ -1168,6 +1178,17 @@ def enqueue(action) end end + # A `$release_id` the caller or the request context already set wins. + def add_release_id(action) + return action if @release_id.nil? + + properties = action[:properties] + return action unless properties.is_a?(Hash) + return action if properties.key?('$release_id') || properties.key?(:$release_id) + + action.merge(properties: properties.merge('$release_id' => @release_id)) + end + def normalize_string_option(value, blank_as_nil: false) return value unless value.is_a?(String) diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 1d75ae3..5c64cd4 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -2197,6 +2197,88 @@ def release end end + describe 'release id from POSTHOG_RELEASE_ID' do + let(:release_id) { '01928f3c-6f1d-7c5e-9a1b-2c3d4e5f6a7b' } + let(:release_id_env) { release_id } + let(:client) { Client.new(api_key: API_KEY, test_mode: true) } + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('POSTHOG_RELEASE_ID', nil).and_return(release_id_env) + end + + { + 'capture' => ->(c) { c.capture(distinct_id: 'user', event: 'Event') }, + 'capture_exception' => ->(c) { c.capture_exception(StandardError.new('boom'), 'user') }, + 'identify' => ->(c) { c.identify(distinct_id: 'user', properties: { 'plan' => 'pro' }) }, + 'group_identify' => ->(c) { c.group_identify(group_type: 'company', group_key: 'id:5') }, + 'alias' => ->(c) { c.alias(distinct_id: 'user', alias: 'anon') } + }.each do |method_name, call| + it "sends $release_id on #{method_name} events" do + call.call(client) + + expect(client.dequeue_last_message[:properties]['$release_id']).to eq(release_id) + end + end + + it 'keeps $release_id out of the person and group properties' do + client.identify(distinct_id: 'user', properties: { 'plan' => 'pro' }) + expect(client.dequeue_last_message[:$set]).to eq('plan' => 'pro') + + client.group_identify(group_type: 'company', group_key: 'id:5', properties: { 'name' => 'Acme' }) + expect(client.dequeue_last_message[:properties][:$group_set]).to eq('name' => 'Acme') + end + + [ + ['a string key', { '$release_id' => 'explicit' }, '$release_id'], + ['a symbol key', { '$release_id': 'explicit' }, :$release_id] + ].each do |description, properties, key| + it "keeps an explicit $release_id passed with #{description}" do + client.capture(distinct_id: 'user', event: 'Event', properties: properties) + + sent = client.dequeue_last_message[:properties] + expect(sent[key]).to eq('explicit') + expect(sent.keys.count { |k| k.to_s == '$release_id' }).to eq(1) + end + end + + it 'passes $release_id to before_send, which can remove it' do + client = Client.new( + api_key: API_KEY, + test_mode: true, + before_send: lambda do |event| + event[:properties].delete('$release_id') + event + end + ) + client.capture(distinct_id: 'user', event: 'Event') + + expect(client.dequeue_last_message[:properties]).not_to have_key('$release_id') + end + + context 'when the value has surrounding whitespace' do + let(:release_id_env) { " #{release_id}\n" } + + it 'trims it' do + client.capture(distinct_id: 'user', event: 'Event') + + expect(client.dequeue_last_message[:properties]['$release_id']).to eq(release_id) + end + end + + [nil, '', " \n"].each do |value| + context "when the variable is #{value.inspect}" do + let(:release_id_env) { value } + + it 'omits $release_id' do + client.capture(distinct_id: 'user', event: 'Event') + + expect(client.dequeue_last_message[:properties]).not_to have_key('$release_id') + end + end + end + end + context 'common' do let(:message_id) { '123e4567-e89b-12d3-a456-426614174000' } diff --git a/spec/posthog/feature_flag_spec.rb b/spec/posthog/feature_flag_spec.rb index 5d965c9..0993f9b 100644 --- a/spec/posthog/feature_flag_spec.rb +++ b/spec/posthog/feature_flag_spec.rb @@ -5549,6 +5549,15 @@ def flag_called_properties(client, key, groups: {}) expect(properties['$groups']).to eq(company: 'id_5') end + it 'keeps $release_id from POSTHOG_RELEASE_ID on minimal events' do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('POSTHOG_RELEASE_ID', nil).and_return('release-abc') + stub_definitions(local_definitions) + c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) + + expect(flag_called_properties(c, 'test-flag')['$release_id']).to eq('release-abc') + end + it 'sends the full event when the flag has an experiment' do stub_definitions(local_definitions(has_experiment: true)) c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)