posthog-ruby 3.3.3 → 3.5.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: 9a8d4402eef3a161a1c76c7e7e2349356741011cc6ead4acde7bd31ef437e568
4
- data.tar.gz: b9ad05adb1b5e3aea9849bf0bbceceef0d22f9724561e8f3a4a3e39f4ed104e8
3
+ metadata.gz: 3d0dcf1963eb64bad7469885f038127408c5815a5cad8b5d338f220b2fac5263
4
+ data.tar.gz: 970b55467f0594423d57c36c9c546bb65ddcb68041ab11b4bea7ded9e25928bb
5
5
  SHA512:
6
- metadata.gz: 055501a8e3ea62cfd321adee64297023338deb455ed59746632c0051179e3b3f6e733f1f7126eb612e5c381aa8bf8653b51ae2d15a1be938d80d05f8e0b06e3f
7
- data.tar.gz: c4dd9ae85bafbec60209c8aff9329f79585b99deb8ffe1624397df73d930d54c40ab5fefaac2de9b75adee80c242f98d26428b61f4155206ce7632517303241b
6
+ metadata.gz: 5cfebda482c0b67e1c12fce9cae0081e182eb1d92577a7f07405ebff817635c31f70a780f4416b5de0d4bdecff7393852c9a8f8d44e34f5d8edf80f0db4434b3
7
+ data.tar.gz: 1ec51e9f018c98cdc8d00b86a0c11613f48544d6ebbeaebe5d98c5084861dcea0ba63be12f506d01a3493db87a9c9fe0f9c1c339d6ed286c7845f5da8bc4db26
@@ -279,38 +279,77 @@ module PostHog
279
279
  only_evaluate_locally: false,
280
280
  send_feature_flag_events: true
281
281
  )
282
- person_properties, group_properties = add_local_person_and_group_properties(
282
+ result = get_feature_flag_result(
283
+ key,
283
284
  distinct_id,
284
- groups,
285
- person_properties,
286
- group_properties
285
+ groups: groups,
286
+ person_properties: person_properties,
287
+ group_properties: group_properties,
288
+ only_evaluate_locally: only_evaluate_locally,
289
+ send_feature_flag_events: send_feature_flag_events
287
290
  )
288
- feature_flag_response, flag_was_locally_evaluated, request_id = @feature_flags_poller.get_feature_flag(
289
- key,
291
+ result&.value
292
+ end
293
+
294
+ # Returns both the feature flag value and payload in a single call.
295
+ # This method raises the $feature_flag_called event with the payload included.
296
+ #
297
+ # @param [String] key The key of the feature flag
298
+ # @param [String] distinct_id The distinct id of the user
299
+ # @param [Hash] groups
300
+ # @param [Hash] person_properties key-value pairs of properties to associate with the user.
301
+ # @param [Hash] group_properties
302
+ # @param [Boolean] only_evaluate_locally
303
+ # @param [Boolean] send_feature_flag_events
304
+ #
305
+ # @return [FeatureFlagResult, nil] A FeatureFlagResult object containing the flag value and payload,
306
+ # or nil if the flag evaluation returned nil
307
+ def get_feature_flag_result(
308
+ key,
309
+ distinct_id,
310
+ groups: {},
311
+ person_properties: {},
312
+ group_properties: {},
313
+ only_evaluate_locally: false,
314
+ send_feature_flag_events: true
315
+ )
316
+ person_properties, group_properties = add_local_person_and_group_properties(
290
317
  distinct_id,
291
318
  groups,
292
319
  person_properties,
293
- group_properties,
294
- only_evaluate_locally
320
+ group_properties
295
321
  )
296
-
322
+ feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
323
+ @feature_flags_poller.get_feature_flag(
324
+ key,
325
+ distinct_id,
326
+ groups,
327
+ person_properties,
328
+ group_properties,
329
+ only_evaluate_locally
330
+ )
297
331
  feature_flag_reported_key = "#{key}_#{feature_flag_response}"
332
+
298
333
  if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
334
+ properties = {
335
+ '$feature_flag' => key,
336
+ '$feature_flag_response' => feature_flag_response,
337
+ 'locally_evaluated' => flag_was_locally_evaluated
338
+ }
339
+ properties['$feature_flag_request_id'] = request_id if request_id
340
+ properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
341
+ properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
342
+
299
343
  capture(
300
- {
301
- distinct_id: distinct_id,
302
- 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 } : {}),
308
- groups: groups
309
- }
344
+ distinct_id: distinct_id,
345
+ event: '$feature_flag_called',
346
+ properties: properties,
347
+ groups: groups
310
348
  )
311
349
  @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
312
350
  end
313
- feature_flag_response
351
+
352
+ FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload)
314
353
  end
315
354
 
316
355
  # Returns all flags for a given user
@@ -336,6 +375,9 @@ module PostHog
336
375
 
337
376
  # Returns payload for a given feature flag
338
377
  #
378
+ # @deprecated Use {#get_feature_flag_result} instead, which returns both the flag value and payload
379
+ # and properly raises the $feature_flag_called event.
380
+ #
339
381
  # @param [String] key The key of the feature flag
340
382
  # @param [String] distinct_id The distinct id of the user
341
383
  # @option [String or boolean] match_value The value of the feature flag to be matched
@@ -385,7 +427,9 @@ module PostHog
385
427
  distinct_id, groups, person_properties, group_properties, only_evaluate_locally
386
428
  )
387
429
 
388
- response.delete(:requestId) # remove internal information.
430
+ # Remove internal information
431
+ response.delete(:requestId)
432
+ response.delete(:evaluatedAt)
389
433
  response
390
434
  end
391
435
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ # Error type constants for the $feature_flag_error property.
5
+ #
6
+ # These values are sent in analytics events to track flag evaluation failures.
7
+ # They should not be changed without considering impact on existing dashboards
8
+ # and queries that filter on these values.
9
+ #
10
+ # Error values:
11
+ # ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
12
+ # FLAG_MISSING: Requested flag not in API response
13
+ # QUOTA_LIMITED: Rate/quota limit exceeded
14
+ # TIMEOUT: Request timed out
15
+ # CONNECTION_ERROR: Network connectivity issue
16
+ # UNKNOWN_ERROR: Unexpected exceptions
17
+ #
18
+ # For API errors with status codes, use the api_error() method which returns
19
+ # a string like "api_error_500".
20
+ class FeatureFlagError
21
+ ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags'
22
+ FLAG_MISSING = 'flag_missing'
23
+ QUOTA_LIMITED = 'quota_limited'
24
+ TIMEOUT = 'timeout'
25
+ CONNECTION_ERROR = 'connection_error'
26
+ UNKNOWN_ERROR = 'unknown_error'
27
+
28
+ # Generate API error string with status code.
29
+ #
30
+ # @param status [Integer, String] The HTTP status code
31
+ # @return [String] Error string in format "api_error_STATUS"
32
+ def self.api_error(status)
33
+ "api_error_#{status.to_s.downcase}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PostHog
6
+ # Represents the result of a feature flag evaluation
7
+ # containing both the flag value and payload
8
+ class FeatureFlagResult
9
+ attr_reader :key, :variant, :payload
10
+
11
+ def initialize(key:, enabled:, variant: nil, payload: nil)
12
+ @key = key
13
+ @enabled = enabled
14
+ @variant = variant
15
+ @payload = payload
16
+ end
17
+
18
+ # Returns the effective value of the feature flag
19
+ # variant if present, otherwise enabled status
20
+ def value
21
+ @variant || @enabled
22
+ end
23
+
24
+ # Returns whether or not the feature flag evaluated as enabled
25
+ def enabled?
26
+ @enabled
27
+ end
28
+
29
+ # Factory method to create from flag value and payload
30
+ def self.from_value_and_payload(key, value, payload)
31
+ return nil if value.nil?
32
+
33
+ parsed_payload = parse_payload(payload)
34
+
35
+ if value.is_a?(String)
36
+ new(key: key, enabled: true, variant: value, payload: parsed_payload)
37
+ else
38
+ new(key: key, enabled: value, payload: parsed_payload)
39
+ end
40
+ end
41
+
42
+ def self.parse_payload(payload)
43
+ return nil if payload.nil?
44
+ return payload unless payload.is_a?(String)
45
+ return nil if payload.empty?
46
+
47
+ begin
48
+ JSON.parse(payload)
49
+ rescue JSON::ParserError
50
+ payload
51
+ end
52
+ end
53
+
54
+ private_class_method :parse_payload
55
+ end
56
+ end
@@ -43,6 +43,7 @@ module PostHog
43
43
  @feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
44
44
  @on_error = on_error || proc { |status, error| }
45
45
  @quota_limited = Concurrent::AtomicBoolean.new(false)
46
+ @flags_etag = Concurrent::AtomicReference.new(nil)
46
47
  @task =
47
48
  Concurrent::TimerTask.new(
48
49
  execution_interval: polling_interval
@@ -140,6 +141,7 @@ module PostHog
140
141
  [key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
141
142
  end
142
143
  end
144
+
143
145
  flags_response
144
146
  end
145
147
 
@@ -167,18 +169,13 @@ module PostHog
167
169
  end
168
170
 
169
171
  response = nil
170
- feature_flag = nil
171
-
172
- @feature_flags.each do |flag|
173
- if key == flag[:key]
174
- feature_flag = flag
175
- break
176
- end
177
- end
172
+ payload = nil
173
+ feature_flag = @feature_flags_by_key&.[](key)
178
174
 
179
175
  unless feature_flag.nil?
180
176
  begin
181
177
  response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
178
+ payload = _compute_flag_payload_locally(key, response) unless response.nil?
182
179
  logger.debug "Successfully computed flag locally: #{key} -> #{response}"
183
180
  rescue RequiresServerEvaluation, InconclusiveMatchError => e
184
181
  logger.debug "Failed to compute flag #{key} locally: #{e}"
@@ -190,28 +187,50 @@ module PostHog
190
187
  flag_was_locally_evaluated = !response.nil?
191
188
 
192
189
  request_id = nil
190
+ evaluated_at = nil
191
+ feature_flag_error = nil
193
192
 
194
193
  if !flag_was_locally_evaluated && !only_evaluate_locally
195
194
  begin
195
+ errors = []
196
196
  flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
197
197
  only_evaluate_locally, true)
198
198
  if flags_data.key?(:featureFlags)
199
199
  flags = stringify_keys(flags_data[:featureFlags] || {})
200
+ payloads = stringify_keys(flags_data[:featureFlagPayloads] || {})
200
201
  request_id = flags_data[:requestId]
202
+ evaluated_at = flags_data[:evaluatedAt]
201
203
  else
202
204
  logger.debug "Missing feature flags key: #{flags_data.to_json}"
203
205
  flags = {}
206
+ payloads = {}
204
207
  end
205
208
 
209
+ status = flags_data[:status]
210
+ errors << FeatureFlagError.api_error(status) if status && status >= 400
211
+ errors << FeatureFlagError::ERRORS_WHILE_COMPUTING if flags_data[:errorsWhileComputingFlags]
212
+ errors << FeatureFlagError::QUOTA_LIMITED if flags_data[:quotaLimited]&.include?('feature_flags')
213
+ errors << FeatureFlagError::FLAG_MISSING unless flags.key?(key.to_s)
214
+
206
215
  response = flags[key]
207
216
  response = false if response.nil?
217
+ payload = payloads[key]
218
+ feature_flag_error = errors.join(',') unless errors.empty?
219
+
208
220
  logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
221
+ rescue Timeout::Error => e
222
+ @on_error.call(-1, "Timeout while fetching flags remotely: #{e}")
223
+ feature_flag_error = FeatureFlagError::TIMEOUT
224
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, SocketError => e
225
+ @on_error.call(-1, "Connection error while fetching flags remotely: #{e}")
226
+ feature_flag_error = FeatureFlagError::CONNECTION_ERROR
209
227
  rescue StandardError => e
210
228
  @on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
229
+ feature_flag_error = FeatureFlagError::UNKNOWN_ERROR
211
230
  end
212
231
  end
213
232
 
214
- [response, flag_was_locally_evaluated, request_id]
233
+ [response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload]
215
234
  end
216
235
 
217
236
  def get_all_flags(
@@ -252,6 +271,7 @@ module PostHog
252
271
  payloads = {}
253
272
  fallback_to_server = @feature_flags.empty?
254
273
  request_id = nil # Only for /flags requests
274
+ evaluated_at = nil # Only for /flags requests
255
275
 
256
276
  @feature_flags.each do |flag|
257
277
  match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
@@ -265,23 +285,32 @@ module PostHog
265
285
  fallback_to_server = true
266
286
  end
267
287
 
288
+ errors_while_computing = false
289
+ quota_limited = nil
290
+ status_code = nil
291
+
268
292
  if fallback_to_server && !only_evaluate_locally
269
293
  begin
270
294
  flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
295
+ errors_while_computing = flags_and_payloads[:errorsWhileComputingFlags] || false
296
+ quota_limited = flags_and_payloads[:quotaLimited]
297
+ status_code = flags_and_payloads[:status]
271
298
 
272
299
  unless flags_and_payloads.key?(:featureFlags)
273
300
  raise StandardError, "Error flags response: #{flags_and_payloads}"
274
301
  end
275
302
 
303
+ request_id = flags_and_payloads[:requestId]
304
+ evaluated_at = flags_and_payloads[:evaluatedAt]
305
+
276
306
  # Check if feature_flags are quota limited
277
- if flags_and_payloads[:quotaLimited]&.include?('feature_flags')
307
+ if quota_limited&.include?('feature_flags')
278
308
  logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
279
309
  flags = {}
280
310
  payloads = {}
281
311
  else
282
312
  flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
283
313
  payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
284
- request_id = flags_and_payloads[:requestId]
285
314
  end
286
315
  rescue StandardError => e
287
316
  @on_error.call(-1, "Error computing flag remotely: #{e}")
@@ -292,7 +321,11 @@ module PostHog
292
321
  {
293
322
  featureFlags: flags,
294
323
  featureFlagPayloads: payloads,
295
- requestId: request_id
324
+ requestId: request_id,
325
+ evaluatedAt: evaluated_at,
326
+ errorsWhileComputingFlags: errors_while_computing,
327
+ quotaLimited: quota_limited,
328
+ status: status_code
296
329
  }
297
330
  end
298
331
 
@@ -834,12 +867,20 @@ module PostHog
834
867
 
835
868
  def _load_feature_flags
836
869
  begin
837
- res = _request_feature_flag_definitions
870
+ res = _request_feature_flag_definitions(etag: @flags_etag.value)
838
871
  rescue StandardError => e
839
872
  @on_error.call(-1, e.to_s)
840
873
  return
841
874
  end
842
875
 
876
+ # Handle 304 Not Modified - flags haven't changed, skip processing
877
+ # Only update ETag if the 304 response includes one
878
+ if res[:not_modified]
879
+ @flags_etag.value = res[:etag] if res[:etag]
880
+ logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
881
+ return
882
+ end
883
+
843
884
  # Handle quota limits with 402 status
844
885
  if res.is_a?(Hash) && res[:status] == 402
845
886
  logger.warn(
@@ -856,6 +897,9 @@ module PostHog
856
897
  end
857
898
 
858
899
  if res.key?(:flags)
900
+ # Only update ETag on successful responses with flag data
901
+ @flags_etag.value = res[:etag]
902
+
859
903
  @feature_flags = res[:flags] || []
860
904
  @feature_flags_by_key = {}
861
905
  @feature_flags.each do |flag|
@@ -871,13 +915,14 @@ module PostHog
871
915
  end
872
916
  end
873
917
 
874
- def _request_feature_flag_definitions
918
+ def _request_feature_flag_definitions(etag: nil)
875
919
  uri = URI("#{@host}/api/feature_flag/local_evaluation")
876
920
  uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
877
921
  req = Net::HTTP::Get.new(uri)
878
922
  req['Authorization'] = "Bearer #{@personal_api_key}"
923
+ req['If-None-Match'] = etag if etag
879
924
 
880
- _request(uri, req)
925
+ _request(uri, req, nil, include_etag: true)
881
926
  end
882
927
 
883
928
  def _request_feature_flag_evaluation(data = {})
@@ -901,7 +946,7 @@ module PostHog
901
946
  end
902
947
 
903
948
  # rubocop:disable Lint/ShadowedException
904
- def _request(uri, request_object, timeout = nil)
949
+ def _request(uri, request_object, timeout = nil, include_etag: false)
905
950
  request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
906
951
  request_timeout = timeout || 10
907
952
 
@@ -913,16 +958,28 @@ module PostHog
913
958
  read_timeout: request_timeout
914
959
  ) do |http|
915
960
  res = http.request(request_object)
961
+ status_code = res.code.to_i
962
+ etag = include_etag ? res['ETag'] : nil
963
+
964
+ # Handle 304 Not Modified - return special response indicating no change
965
+ if status_code == 304
966
+ logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
967
+ return { not_modified: true, etag: etag, status: status_code }
968
+ end
916
969
 
917
970
  # Parse response body to hash
918
971
  begin
919
972
  response = JSON.parse(res.body, { symbolize_names: true })
920
- # Only add status if response is a hash
921
- response = response.merge({ status: res.code.to_i }) if response.is_a?(Hash)
973
+ # Only add status (and etag if requested) if response is a hash
974
+ extra_fields = { status: status_code }
975
+ extra_fields[:etag] = etag if include_etag
976
+ response = response.merge(extra_fields) if response.is_a?(Hash)
922
977
  return response
923
978
  rescue JSON::ParserError
924
979
  # Handle case when response isn't valid JSON
925
- return { error: 'Invalid JSON response', body: res.body, status: res.code.to_i }
980
+ error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
981
+ error_response[:etag] = etag if include_etag
982
+ return error_response
926
983
  end
927
984
  end
928
985
  rescue Timeout::Error,
@@ -939,5 +996,9 @@ module PostHog
939
996
  end
940
997
  end
941
998
  # rubocop:enable Lint/ShadowedException
999
+
1000
+ def _mask_tokens_in_url(url)
1001
+ url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
1002
+ end
942
1003
  end
943
1004
  end
@@ -41,8 +41,8 @@ module PostHog
41
41
  return @logger if @logger
42
42
 
43
43
  base_logger =
44
- if defined?(Rails)
45
- Rails.logger
44
+ if defined?(::Rails)
45
+ ::Rails.logger
46
46
  else
47
47
  logger = Logger.new $stdout
48
48
  logger.progname = 'PostHog'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.3.3'
4
+ VERSION = '3.5.0'
5
5
  end
data/lib/posthog.rb CHANGED
@@ -10,3 +10,5 @@ require 'posthog/transport'
10
10
  require 'posthog/response'
11
11
  require 'posthog/logging'
12
12
  require 'posthog/exception_capture'
13
+ require 'posthog/feature_flag_error'
14
+ require 'posthog/feature_flag_result'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.3
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: concurrent-ruby
@@ -40,6 +40,8 @@ files:
40
40
  - lib/posthog/defaults.rb
41
41
  - lib/posthog/exception_capture.rb
42
42
  - lib/posthog/feature_flag.rb
43
+ - lib/posthog/feature_flag_error.rb
44
+ - lib/posthog/feature_flag_result.rb
43
45
  - lib/posthog/feature_flags.rb
44
46
  - lib/posthog/field_parser.rb
45
47
  - lib/posthog/logging.rb
@@ -70,7 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
72
  - !ruby/object:Gem::Version
71
73
  version: '0'
72
74
  requirements: []
73
- rubygems_version: 3.6.6
75
+ rubygems_version: 4.0.3
74
76
  specification_version: 4
75
77
  summary: PostHog library
76
78
  test_files: []