posthog-ruby 3.3.2 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9df507c0722b4e1f6d12810813f6f8d37d24473a034ef067889d83edc3106dcc
4
- data.tar.gz: c576573a7eef9476b9d16905b7bf26f249ed10ef4cb12691746de161dec9277d
3
+ metadata.gz: 9e99287bd9fe6eb8969c2481c0380c2a6eba12709de5f5189a053cb9ba5ca255
4
+ data.tar.gz: 78b496c5feb9c8cb222393ababa36d0f6fd81c18e603469b0633ac3fcdbfd639
5
5
  SHA512:
6
- metadata.gz: a2b8b07133cef4a959d79db0ec354fa8437f72b7e312240a41af44820e6663f7595da661c6b3e172885b7766f8f5f07ba35ee21d1795c564c09838358647d1cc
7
- data.tar.gz: af0218271ebf8de7d45e289176e941d0e72e7871df5d3438f2c30d4f423170125d3b54e89ae4bf05ba63d80fbb9981b0e4a7acf02dfeb2fa17d7891dcf29d02e
6
+ metadata.gz: dc1c52ff0243af441d895c05199067347931f0bf50a21ba897a3fb75640cb954ec66c79dddb29fb92338479014f3a7f42543a1ef651ebdcbd7fe6845ff9e9002
7
+ data.tar.gz: e46932963b504e54fe4590db73e1956611190b2ab16a1f04ffff5877c01b1e71a2dafc5f9c373361b69bb5f63532d1400c244580ac3c229ced006b350880eef5
@@ -285,26 +285,31 @@ module PostHog
285
285
  person_properties,
286
286
  group_properties
287
287
  )
288
- feature_flag_response, flag_was_locally_evaluated, request_id = @feature_flags_poller.get_feature_flag(
289
- key,
290
- distinct_id,
291
- groups,
292
- person_properties,
293
- group_properties,
294
- only_evaluate_locally
295
- )
288
+ feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at =
289
+ @feature_flags_poller.get_feature_flag(
290
+ key,
291
+ distinct_id,
292
+ groups,
293
+ person_properties,
294
+ group_properties,
295
+ only_evaluate_locally
296
+ )
296
297
 
297
298
  feature_flag_reported_key = "#{key}_#{feature_flag_response}"
298
299
  if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
300
+ properties = {
301
+ '$feature_flag' => key,
302
+ '$feature_flag_response' => feature_flag_response,
303
+ 'locally_evaluated' => flag_was_locally_evaluated
304
+ }
305
+ properties['$feature_flag_request_id'] = request_id if request_id
306
+ properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
307
+
299
308
  capture(
300
309
  {
301
310
  distinct_id: distinct_id,
302
311
  event: '$feature_flag_called',
303
- properties: {
304
- '$feature_flag' => key,
305
- '$feature_flag_response' => feature_flag_response,
306
- 'locally_evaluated' => flag_was_locally_evaluated
307
- }.merge(request_id ? { '$feature_flag_request_id' => request_id } : {}),
312
+ properties: properties,
308
313
  groups: groups
309
314
  }
310
315
  )
@@ -385,7 +390,9 @@ module PostHog
385
390
  distinct_id, groups, person_properties, group_properties, only_evaluate_locally
386
391
  )
387
392
 
388
- response.delete(:requestId) # remove internal information.
393
+ # Remove internal information
394
+ response.delete(:requestId)
395
+ response.delete(:evaluatedAt)
389
396
  response
390
397
  end
391
398
 
@@ -12,6 +12,13 @@ module PostHog
12
12
  class InconclusiveMatchError < StandardError
13
13
  end
14
14
 
15
+ # RequiresServerEvaluation is raised when feature flag evaluation requires
16
+ # server-side data that is not available locally (e.g., static cohorts,
17
+ # experience continuity). This error should propagate immediately to trigger
18
+ # API fallback, unlike InconclusiveMatchError which allows trying other conditions.
19
+ class RequiresServerEvaluation < StandardError
20
+ end
21
+
15
22
  class FeatureFlagsPoller
16
23
  include PostHog::Logging
17
24
  include PostHog::Utils
@@ -36,6 +43,7 @@ module PostHog
36
43
  @feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
37
44
  @on_error = on_error || proc { |status, error| }
38
45
  @quota_limited = Concurrent::AtomicBoolean.new(false)
46
+ @flags_etag = Concurrent::AtomicReference.new(nil)
39
47
  @task =
40
48
  Concurrent::TimerTask.new(
41
49
  execution_interval: polling_interval
@@ -133,6 +141,7 @@ module PostHog
133
141
  [key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
134
142
  end
135
143
  end
144
+
136
145
  flags_response
137
146
  end
138
147
 
@@ -173,7 +182,7 @@ module PostHog
173
182
  begin
174
183
  response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
175
184
  logger.debug "Successfully computed flag locally: #{key} -> #{response}"
176
- rescue InconclusiveMatchError => e
185
+ rescue RequiresServerEvaluation, InconclusiveMatchError => e
177
186
  logger.debug "Failed to compute flag #{key} locally: #{e}"
178
187
  rescue StandardError => e
179
188
  @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}")
@@ -183,6 +192,7 @@ module PostHog
183
192
  flag_was_locally_evaluated = !response.nil?
184
193
 
185
194
  request_id = nil
195
+ evaluated_at = nil
186
196
 
187
197
  if !flag_was_locally_evaluated && !only_evaluate_locally
188
198
  begin
@@ -191,6 +201,7 @@ module PostHog
191
201
  if flags_data.key?(:featureFlags)
192
202
  flags = stringify_keys(flags_data[:featureFlags] || {})
193
203
  request_id = flags_data[:requestId]
204
+ evaluated_at = flags_data[:evaluatedAt]
194
205
  else
195
206
  logger.debug "Missing feature flags key: #{flags_data.to_json}"
196
207
  flags = {}
@@ -204,7 +215,7 @@ module PostHog
204
215
  end
205
216
  end
206
217
 
207
- [response, flag_was_locally_evaluated, request_id]
218
+ [response, flag_was_locally_evaluated, request_id, evaluated_at]
208
219
  end
209
220
 
210
221
  def get_all_flags(
@@ -245,13 +256,14 @@ module PostHog
245
256
  payloads = {}
246
257
  fallback_to_server = @feature_flags.empty?
247
258
  request_id = nil # Only for /flags requests
259
+ evaluated_at = nil # Only for /flags requests
248
260
 
249
261
  @feature_flags.each do |flag|
250
262
  match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
251
263
  flags[flag[:key]] = match_value
252
264
  match_payload = _compute_flag_payload_locally(flag[:key], match_value)
253
265
  payloads[flag[:key]] = match_payload if match_payload
254
- rescue InconclusiveMatchError
266
+ rescue RequiresServerEvaluation, InconclusiveMatchError
255
267
  fallback_to_server = true
256
268
  rescue StandardError => e
257
269
  @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
@@ -275,6 +287,7 @@ module PostHog
275
287
  flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
276
288
  payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
277
289
  request_id = flags_and_payloads[:requestId]
290
+ evaluated_at = flags_and_payloads[:evaluatedAt]
278
291
  end
279
292
  rescue StandardError => e
280
293
  @on_error.call(-1, "Error computing flag remotely: #{e}")
@@ -285,7 +298,8 @@ module PostHog
285
298
  {
286
299
  featureFlags: flags,
287
300
  featureFlagPayloads: payloads,
288
- requestId: request_id
301
+ requestId: request_id,
302
+ evaluatedAt: evaluated_at
289
303
  }
290
304
  end
291
305
 
@@ -461,7 +475,10 @@ module PostHog
461
475
  cohort_id = extract_value(property, :value).to_s
462
476
  property_group = find_cohort_property(cohort_properties, cohort_id)
463
477
 
464
- raise InconclusiveMatchError, "can't match cohort without a given cohort property value" unless property_group
478
+ unless property_group
479
+ raise RequiresServerEvaluation,
480
+ "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
481
+ end
465
482
 
466
483
  match_property_group(property_group, property_values, cohort_properties)
467
484
  end
@@ -539,6 +556,9 @@ module PostHog
539
556
  elsif final_result # group_type == 'OR'
540
557
  return true
541
558
  end
559
+ rescue RequiresServerEvaluation
560
+ # Immediately propagate - this condition requires server-side data
561
+ raise
542
562
  rescue InconclusiveMatchError => e
543
563
  PostHog::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
544
564
  error_matching_locally = true
@@ -676,7 +696,7 @@ module PostHog
676
696
  :match_nested_property_group, :match_regular_property_group
677
697
 
678
698
  def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
679
- raise InconclusiveMatchError, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
699
+ raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
680
700
 
681
701
  return false unless flag[:active]
682
702
 
@@ -747,7 +767,12 @@ module PostHog
747
767
  result = variant || true
748
768
  break
749
769
  end
770
+ rescue RequiresServerEvaluation
771
+ # Static cohort or other missing server-side data - must fallback to API
772
+ raise
750
773
  rescue InconclusiveMatchError
774
+ # Evaluation error (bad regex, invalid date, missing property, etc.)
775
+ # Track that we had an inconclusive match, but try other conditions
751
776
  is_inconclusive = true
752
777
  end
753
778
 
@@ -816,12 +841,20 @@ module PostHog
816
841
 
817
842
  def _load_feature_flags
818
843
  begin
819
- res = _request_feature_flag_definitions
844
+ res = _request_feature_flag_definitions(etag: @flags_etag.value)
820
845
  rescue StandardError => e
821
846
  @on_error.call(-1, e.to_s)
822
847
  return
823
848
  end
824
849
 
850
+ # Handle 304 Not Modified - flags haven't changed, skip processing
851
+ # Only update ETag if the 304 response includes one
852
+ if res[:not_modified]
853
+ @flags_etag.value = res[:etag] if res[:etag]
854
+ logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
855
+ return
856
+ end
857
+
825
858
  # Handle quota limits with 402 status
826
859
  if res.is_a?(Hash) && res[:status] == 402
827
860
  logger.warn(
@@ -838,6 +871,9 @@ module PostHog
838
871
  end
839
872
 
840
873
  if res.key?(:flags)
874
+ # Only update ETag on successful responses with flag data
875
+ @flags_etag.value = res[:etag]
876
+
841
877
  @feature_flags = res[:flags] || []
842
878
  @feature_flags_by_key = {}
843
879
  @feature_flags.each do |flag|
@@ -853,13 +889,14 @@ module PostHog
853
889
  end
854
890
  end
855
891
 
856
- def _request_feature_flag_definitions
892
+ def _request_feature_flag_definitions(etag: nil)
857
893
  uri = URI("#{@host}/api/feature_flag/local_evaluation")
858
894
  uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
859
895
  req = Net::HTTP::Get.new(uri)
860
896
  req['Authorization'] = "Bearer #{@personal_api_key}"
897
+ req['If-None-Match'] = etag if etag
861
898
 
862
- _request(uri, req)
899
+ _request(uri, req, nil, include_etag: true)
863
900
  end
864
901
 
865
902
  def _request_feature_flag_evaluation(data = {})
@@ -883,7 +920,7 @@ module PostHog
883
920
  end
884
921
 
885
922
  # rubocop:disable Lint/ShadowedException
886
- def _request(uri, request_object, timeout = nil)
923
+ def _request(uri, request_object, timeout = nil, include_etag: false)
887
924
  request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
888
925
  request_timeout = timeout || 10
889
926
 
@@ -895,16 +932,28 @@ module PostHog
895
932
  read_timeout: request_timeout
896
933
  ) do |http|
897
934
  res = http.request(request_object)
935
+ status_code = res.code.to_i
936
+ etag = include_etag ? res['ETag'] : nil
937
+
938
+ # Handle 304 Not Modified - return special response indicating no change
939
+ if status_code == 304
940
+ logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
941
+ return { not_modified: true, etag: etag, status: status_code }
942
+ end
898
943
 
899
944
  # Parse response body to hash
900
945
  begin
901
946
  response = JSON.parse(res.body, { symbolize_names: true })
902
- # Only add status if response is a hash
903
- response = response.merge({ status: res.code.to_i }) if response.is_a?(Hash)
947
+ # Only add status (and etag if requested) if response is a hash
948
+ extra_fields = { status: status_code }
949
+ extra_fields[:etag] = etag if include_etag
950
+ response = response.merge(extra_fields) if response.is_a?(Hash)
904
951
  return response
905
952
  rescue JSON::ParserError
906
953
  # Handle case when response isn't valid JSON
907
- return { error: 'Invalid JSON response', body: res.body, status: res.code.to_i }
954
+ error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
955
+ error_response[:etag] = etag if include_etag
956
+ return error_response
908
957
  end
909
958
  end
910
959
  rescue Timeout::Error,
@@ -921,5 +970,9 @@ module PostHog
921
970
  end
922
971
  end
923
972
  # rubocop:enable Lint/ShadowedException
973
+
974
+ def _mask_tokens_in_url(url)
975
+ url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
976
+ end
924
977
  end
925
978
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.3.2'
4
+ VERSION = '3.4.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.2
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-09-26 00:00:00.000000000 Z
10
+ date: 2025-12-04 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: concurrent-ruby
@@ -57,7 +56,6 @@ licenses:
57
56
  - MIT
58
57
  metadata:
59
58
  rubygems_mfa_required: 'true'
60
- post_install_message:
61
59
  rdoc_options: []
62
60
  require_paths:
63
61
  - lib
@@ -72,8 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
70
  - !ruby/object:Gem::Version
73
71
  version: '0'
74
72
  requirements: []
75
- rubygems_version: 3.0.3.1
76
- signing_key:
73
+ rubygems_version: 3.6.6
77
74
  specification_version: 4
78
75
  summary: PostHog library
79
76
  test_files: []