posthog-ruby 3.6.4 → 3.7.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: b48a174a03d0e38137942c28829ea24f7ff4ef88827dea2319b5f03b39a7401d
4
- data.tar.gz: ce8c7d2446f5356ba578d4b30dfbde67239cf36a634d36662fad21ec9c949045
3
+ metadata.gz: afcf66d4afc793e15c83b53e455ef605218def4581460279d45721b82c1f73f2
4
+ data.tar.gz: 96f13bf72ddc840302d71c21072db26bdd098b73fd862455dc65b745357dc7d2
5
5
  SHA512:
6
- metadata.gz: '02590918cb8cd087c997a866eafeb0174b52bb611e38fc541d5a31de58b6e16a52d4d49e8120a48862d28afd9e8a13f7fca108947417d96a0b4f837e55d1cf1c'
7
- data.tar.gz: ae2697f0031883102ec584629c1452d8b712096ccf0d0ebe6b0b1a518984b09751de82c981e90525c141e21e1923907186af8a89586e58632af08b308dc24613
6
+ metadata.gz: 8b3e3871c5be034db9928b012cc0a3417121dc6f1ab4e7c5ece24e1c637dc04959ef50e4f551de598e11c4922d4f54048a9438bb03c86eb1e0eac67eabefdf7d
7
+ data.tar.gz: 3bf3128f6333b9678aa9c3a616494fcbff369ece30a0b0c422468cdab8856902a4184a560da84d6dbadb3dc935555b7402fe63d9158ea1da62589115247e7357
@@ -11,6 +11,7 @@ require 'posthog/noop_worker'
11
11
  require 'posthog/message_batch'
12
12
  require 'posthog/transport'
13
13
  require 'posthog/feature_flags'
14
+ require 'posthog/feature_flag_evaluations'
14
15
  require 'posthog/send_feature_flags_options'
15
16
  require 'posthog/exception_capture'
16
17
 
@@ -59,7 +60,7 @@ module PostHog
59
60
  # on the calling thread. Useful in forking environments like Sidekiq
60
61
  # and Resque. Defaults to +false+.
61
62
  # @option opts [Proc] :on_error Handles error calls from the API.
62
- # @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://app.posthog.com`
63
+ # @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://us.i.posthog.com`
63
64
  # @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes.
64
65
  # Measured in seconds, defaults to 30.
65
66
  # @option opts [Integer] :feature_flag_request_timeout_seconds How long to wait for feature flag evaluation.
@@ -74,7 +75,9 @@ module PostHog
74
75
  def initialize(opts = {})
75
76
  symbolize_keys!(opts)
76
77
 
77
- opts[:host] ||= 'https://app.posthog.com'
78
+ opts[:api_key] = normalize_string_option(opts[:api_key])
79
+ opts[:personal_api_key] = normalize_string_option(opts[:personal_api_key], blank_as_nil: true)
80
+ opts[:host] = normalize_host_option(opts[:host])
78
81
 
79
82
  @queue = Queue.new
80
83
  @api_key = opts[:api_key]
@@ -102,6 +105,7 @@ module PostHog
102
105
  @personal_api_key = opts[:personal_api_key]
103
106
 
104
107
  check_api_key!
108
+ logger.error('api_key is empty after trimming whitespace; check your project API key') if @api_key == ''
105
109
 
106
110
  # Warn when multiple clients are created with the same API key (can cause dropped events)
107
111
  unless opts[:test_mode] || opts[:disable_singleton_warning]
@@ -132,6 +136,7 @@ module PostHog
132
136
  end
133
137
 
134
138
  @before_send = opts[:before_send]
139
+ @deprecation_emitted_for = Concurrent::Set.new
135
140
  end
136
141
 
137
142
  # Synchronously waits until the worker has cleared the queue.
@@ -172,6 +177,10 @@ module PostHog
172
177
  # @option attrs [Hash] :properties Event properties (optional)
173
178
  # @option attrs [Bool, Hash, SendFeatureFlagsOptions] :send_feature_flags
174
179
  # Whether to send feature flags with this event, or configuration for feature flag evaluation (optional)
180
+ # @option attrs [PostHog::FeatureFlagEvaluations] :flags A snapshot returned by
181
+ # {#evaluate_flags}. When present, `$feature/<key>` and `$active_feature_flags` are
182
+ # attached from the snapshot without making an additional /flags request, and this
183
+ # takes precedence over `:send_feature_flags`.
175
184
  # @option attrs [String] :uuid ID that uniquely identifies an event;
176
185
  # events in PostHog are deduplicated by the
177
186
  # combination of teamId, timestamp date,
@@ -180,8 +189,39 @@ module PostHog
180
189
  def capture(attrs)
181
190
  symbolize_keys! attrs
182
191
 
192
+ # Precedence: an explicit `flags` snapshot always wins, regardless of
193
+ # `send_feature_flags`. The snapshot guarantees the event carries the same
194
+ # values the developer branched on with no additional network call.
195
+ if attrs[:flags]
196
+ if attrs[:flags].is_a?(FeatureFlagEvaluations)
197
+ if attrs[:send_feature_flags]
198
+ logger.warn(
199
+ '[FEATURE FLAGS] Both `flags` and `send_feature_flags` were passed to ' \
200
+ 'capture(); using `flags` and ignoring `send_feature_flags`.'
201
+ )
202
+ end
203
+ snapshot_props = attrs[:flags]._get_event_properties
204
+ attrs[:properties] = snapshot_props.merge(attrs[:properties] || {})
205
+ attrs.delete(:flags)
206
+ attrs.delete(:send_feature_flags)
207
+ else
208
+ logger.warn(
209
+ '[FEATURE FLAGS] capture(flags:) expects a PostHog::FeatureFlagEvaluations snapshot ' \
210
+ "from `client.evaluate_flags(...)`; got #{attrs[:flags].class}. Ignoring."
211
+ )
212
+ attrs.delete(:flags)
213
+ end
214
+ end
215
+
183
216
  send_feature_flags_param = attrs[:send_feature_flags]
184
217
  if send_feature_flags_param
218
+ _emit_deprecation(
219
+ :capture_send_feature_flags,
220
+ '`send_feature_flags` on `capture` is deprecated and will be removed in a future major ' \
221
+ 'version. Pass a `flags` snapshot from `client.evaluate_flags(...)` instead — it ' \
222
+ 'avoids a second `/flags` request per capture and guarantees the event carries the ' \
223
+ 'exact flag values your code branched on.'
224
+ )
185
225
  # Handle different types of send_feature_flags parameter
186
226
  case send_feature_flags_param
187
227
  when true
@@ -222,7 +262,10 @@ module PostHog
222
262
  # @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object
223
263
  # @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID)
224
264
  # @param [Hash] additional_properties Additional properties to include with the exception event (optional)
225
- def capture_exception(exception, distinct_id = nil, additional_properties = {})
265
+ # @param [PostHog::FeatureFlagEvaluations] flags A snapshot returned by {#evaluate_flags}.
266
+ # Forwarded to the inner {#capture} call so the captured `$exception` event carries the
267
+ # same `$feature/<key>` and `$active_feature_flags` properties as the snapshot.
268
+ def capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil)
226
269
  exception_info = ExceptionCapture.build_parsed_exception(exception)
227
270
 
228
271
  return if exception_info.nil?
@@ -240,6 +283,7 @@ module PostHog
240
283
  properties: properties,
241
284
  timestamp: Time.now
242
285
  }
286
+ event_data[:flags] = flags if flags
243
287
 
244
288
  capture(event_data)
245
289
  end
@@ -290,6 +334,7 @@ module PostHog
290
334
  @queue.length
291
335
  end
292
336
 
337
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#is_enabled} instead.
293
338
  # TODO: In future version, rename to `feature_flag_enabled?`
294
339
  def is_feature_enabled( # rubocop:disable Naming/PredicateName
295
340
  flag_key,
@@ -300,15 +345,20 @@ module PostHog
300
345
  only_evaluate_locally: false,
301
346
  send_feature_flag_events: true
302
347
  )
303
- response = get_feature_flag(
304
- flag_key,
305
- distinct_id,
306
- groups: groups,
307
- person_properties: person_properties,
308
- group_properties: group_properties,
309
- only_evaluate_locally: only_evaluate_locally,
310
- send_feature_flag_events: send_feature_flag_events
348
+ _emit_deprecation(
349
+ :is_feature_enabled,
350
+ '`is_feature_enabled` is deprecated and will be removed in a future major version. ' \
351
+ 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.enabled?(key)` instead — ' \
352
+ 'this consolidates flag evaluation into a single `/flags` request per incoming request.'
353
+ )
354
+ # Bypass the public `get_feature_flag` so the user only sees a single deprecation
355
+ # warning per call, not a cascade.
356
+ result = _get_feature_flag_result(
357
+ flag_key, distinct_id,
358
+ groups: groups, person_properties: person_properties, group_properties: group_properties,
359
+ only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events
311
360
  )
361
+ response = result&.value
312
362
  return nil if response.nil?
313
363
 
314
364
  !!response
@@ -341,6 +391,7 @@ module PostHog
341
391
  # ```ruby
342
392
  # group_properties: {"organization": {"name": "PostHog", "employees": 11}}
343
393
  # ```
394
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} instead.
344
395
  def get_feature_flag(
345
396
  key,
346
397
  distinct_id,
@@ -350,77 +401,175 @@ module PostHog
350
401
  only_evaluate_locally: false,
351
402
  send_feature_flag_events: true
352
403
  )
353
- result = get_feature_flag_result(
354
- key,
355
- distinct_id,
356
- groups: groups,
357
- person_properties: person_properties,
358
- group_properties: group_properties,
359
- only_evaluate_locally: only_evaluate_locally,
360
- send_feature_flag_events: send_feature_flag_events
404
+ _emit_deprecation(
405
+ :get_feature_flag,
406
+ '`get_feature_flag` is deprecated and will be removed in a future major version. ' \
407
+ 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` instead — ' \
408
+ 'this consolidates flag evaluation into a single `/flags` request per incoming request.'
409
+ )
410
+ # Bypass the public `get_feature_flag_result` so the user only sees one deprecation warning.
411
+ result = _get_feature_flag_result(
412
+ key, distinct_id,
413
+ groups: groups, person_properties: person_properties, group_properties: group_properties,
414
+ only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events
361
415
  )
362
416
  result&.value
363
417
  end
364
418
 
365
- # Returns both the feature flag value and payload in a single call.
366
- # This method raises the $feature_flag_called event with the payload included.
419
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} /
420
+ # {FeatureFlagEvaluations#get_flag_payload} instead.
421
+ def get_feature_flag_result(
422
+ key,
423
+ distinct_id,
424
+ groups: {},
425
+ person_properties: {},
426
+ group_properties: {},
427
+ only_evaluate_locally: false,
428
+ send_feature_flag_events: true
429
+ )
430
+ _emit_deprecation(
431
+ :get_feature_flag_result,
432
+ '`get_feature_flag_result` is deprecated and will be removed in a future major version. ' \
433
+ 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` / ' \
434
+ '`flags.get_flag_payload(key)` instead — this consolidates flag evaluation into a single ' \
435
+ '`/flags` request per incoming request.'
436
+ )
437
+ _get_feature_flag_result(
438
+ key, distinct_id,
439
+ groups: groups, person_properties: person_properties, group_properties: group_properties,
440
+ only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events
441
+ )
442
+ end
443
+
444
+ # Evaluate feature flags for a distinct id and return a snapshot.
445
+ #
446
+ # The returned {PostHog::FeatureFlagEvaluations} can be queried with
447
+ # `is_enabled` / `get_flag` / `get_flag_payload`, narrowed with
448
+ # `only_accessed` / `only`, and passed to {#capture} via the `flags:` option
449
+ # to attach `$feature/<key>` and `$active_feature_flags` without an extra
450
+ # /flags request.
367
451
  #
368
- # @param [String] key The key of the feature flag
369
452
  # @param [String] distinct_id The distinct id of the user
370
453
  # @param [Hash] groups
371
- # @param [Hash] person_properties key-value pairs of properties to associate with the user.
454
+ # @param [Hash] person_properties key-value pairs of properties to associate with the user
372
455
  # @param [Hash] group_properties
373
- # @param [Boolean] only_evaluate_locally
374
- # @param [Boolean] send_feature_flag_events
375
- #
376
- # @return [FeatureFlagResult, nil] A FeatureFlagResult object containing the flag value and payload,
377
- # or nil if the flag evaluation returned nil
378
- def get_feature_flag_result(
379
- key,
456
+ # @param [Boolean] only_evaluate_locally Skip the remote /flags call entirely
457
+ # @param [Boolean] disable_geoip Stamped on captured access events
458
+ # @param [Array<String>] flag_keys When set, scopes the underlying /flags
459
+ # request to only these flag keys (sent as `flag_keys_to_evaluate`).
460
+ # Distinct from {FeatureFlagEvaluations#only}, which filters the
461
+ # already-fetched snapshot in memory.
462
+ # @return [PostHog::FeatureFlagEvaluations]
463
+ def evaluate_flags(
380
464
  distinct_id,
381
465
  groups: {},
382
466
  person_properties: {},
383
467
  group_properties: {},
384
468
  only_evaluate_locally: false,
385
- send_feature_flag_events: true
469
+ disable_geoip: nil,
470
+ flag_keys: nil
386
471
  )
472
+ host = _feature_flag_evaluations_host
473
+
474
+ if distinct_id.nil? || distinct_id.to_s.empty?
475
+ return FeatureFlagEvaluations.new(host: host, distinct_id: '', flags: {})
476
+ end
477
+
387
478
  person_properties, group_properties = add_local_person_and_group_properties(
388
- distinct_id,
389
- groups,
390
- person_properties,
391
- group_properties
479
+ distinct_id, groups, person_properties, group_properties
392
480
  )
393
- feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
394
- @feature_flags_poller.get_feature_flag(
395
- key,
396
- distinct_id,
397
- groups,
398
- person_properties,
399
- group_properties,
400
- only_evaluate_locally
401
- )
402
- feature_flag_reported_key = "#{key}_#{feature_flag_response}"
403
481
 
404
- if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
405
- properties = {
406
- '$feature_flag' => key,
407
- '$feature_flag_response' => feature_flag_response,
408
- 'locally_evaluated' => flag_was_locally_evaluated
409
- }
410
- properties['$feature_flag_request_id'] = request_id if request_id
411
- properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
412
- properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
482
+ records = {}
483
+ locally_evaluated_keys = Set.new
484
+ flag_keys_set = flag_keys&.to_set(&:to_s)
413
485
 
414
- capture(
415
- distinct_id: distinct_id,
416
- event: '$feature_flag_called',
417
- properties: properties,
418
- groups: groups
486
+ @feature_flags_poller.load_feature_flags
487
+ poller_flags_by_key = @feature_flags_poller.feature_flags_by_key || {}
488
+
489
+ poller_flags_by_key.each do |key, definition|
490
+ next if flag_keys_set && !flag_keys_set.include?(key.to_s)
491
+
492
+ begin
493
+ match = @feature_flags_poller.send(
494
+ :_compute_flag_locally,
495
+ definition, distinct_id, groups, person_properties, group_properties
496
+ )
497
+ rescue PostHog::RequiresServerEvaluation, PostHog::InconclusiveMatchError, StandardError
498
+ next
499
+ end
500
+
501
+ next if match.nil?
502
+
503
+ records[key.to_s] = FeatureFlagEvaluations::EvaluatedFlagRecord.new(
504
+ key: key.to_s,
505
+ enabled: match.is_a?(String) || (match ? true : false),
506
+ variant: match.is_a?(String) ? match : nil,
507
+ payload: FeatureFlagResult.parse_payload(
508
+ @feature_flags_poller.send(:_compute_flag_payload_locally, key, match)
509
+ ),
510
+ id: definition[:id],
511
+ version: nil,
512
+ reason: FeatureFlagEvaluations::EVALUATED_LOCALLY_REASON,
513
+ locally_evaluated: true
419
514
  )
420
- @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
515
+ locally_evaluated_keys << key.to_s
421
516
  end
422
517
 
423
- FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload)
518
+ request_id = nil
519
+ evaluated_at = nil
520
+ errors_while_computing = false
521
+ quota_limited = false
522
+
523
+ # Skip the remote `/flags` round-trip when the caller scoped the request
524
+ # to a fixed set of `flag_keys` and we've already resolved every one of
525
+ # them locally. Without `flag_keys` set, we can't know whether the server
526
+ # has flags we don't have definitions for, so we still hit `/flags`.
527
+ all_requested_flags_resolved_locally = flag_keys_set && (flag_keys_set - locally_evaluated_keys).empty?
528
+
529
+ if !only_evaluate_locally && !all_requested_flags_resolved_locally
530
+ begin
531
+ flags_response = @feature_flags_poller.get_flags(
532
+ distinct_id, groups, person_properties, group_properties, flag_keys, disable_geoip
533
+ )
534
+ request_id = flags_response[:requestId]
535
+ evaluated_at = flags_response[:evaluatedAt]
536
+ errors_while_computing = flags_response[:errorsWhileComputingFlags] == true
537
+ quota_limited = (flags_response[:quotaLimited] || []).include?('feature_flags')
538
+ remote_flags = flags_response[:flags] || {}
539
+ remote_flags.each do |key, ff|
540
+ key_str = key.to_s
541
+ next if locally_evaluated_keys.include?(key_str)
542
+
543
+ metadata = ff.metadata
544
+ reason = ff.reason
545
+ records[key_str] = FeatureFlagEvaluations::EvaluatedFlagRecord.new(
546
+ key: key_str,
547
+ enabled: ff.enabled ? true : false,
548
+ variant: ff.variant,
549
+ payload: FeatureFlagResult.parse_payload(ff.payload),
550
+ id: metadata ? metadata.id : nil,
551
+ version: metadata ? metadata.version : nil,
552
+ reason: reason ? (reason.description || reason.code) : nil,
553
+ locally_evaluated: false
554
+ )
555
+ end
556
+ rescue StandardError => e
557
+ @on_error&.call(-1, "Error evaluating flags remotely: #{e}")
558
+ end
559
+ end
560
+
561
+ FeatureFlagEvaluations.new(
562
+ host: host,
563
+ distinct_id: distinct_id,
564
+ flags: records,
565
+ groups: groups,
566
+ disable_geoip: disable_geoip,
567
+ request_id: request_id,
568
+ evaluated_at: evaluated_at,
569
+ flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at,
570
+ errors_while_computing: errors_while_computing,
571
+ quota_limited: quota_limited
572
+ )
424
573
  end
425
574
 
426
575
  # Returns all flags for a given user
@@ -457,6 +606,7 @@ module PostHog
457
606
  # @option [Hash] group_properties
458
607
  # @option [Boolean] only_evaluate_locally
459
608
  #
609
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag_payload} instead.
460
610
  def get_feature_flag_payload(
461
611
  key,
462
612
  distinct_id,
@@ -466,6 +616,13 @@ module PostHog
466
616
  group_properties: {},
467
617
  only_evaluate_locally: false
468
618
  )
619
+ _emit_deprecation(
620
+ :get_feature_flag_payload,
621
+ '`get_feature_flag_payload` is deprecated and will be removed in a future major version. ' \
622
+ 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag_payload(key)` ' \
623
+ 'instead — this consolidates flag evaluation into a single `/flags` request per ' \
624
+ 'incoming request.'
625
+ )
469
626
  person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups,
470
627
  person_properties, group_properties)
471
628
  @feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties,
@@ -527,6 +684,84 @@ module PostHog
527
684
 
528
685
  private
529
686
 
687
+ # Shared by the legacy single-flag path ({#get_feature_flag_result}) and the
688
+ # snapshot's access-recording. Owns dedup-key construction, the
689
+ # per-distinct_id sent-flags cache, and the `$feature_flag_called` capture call.
690
+ def _capture_feature_flag_called_if_needed(
691
+ distinct_id: nil, key: nil, response: nil, properties: nil,
692
+ groups: nil, disable_geoip: nil
693
+ )
694
+ reported_key = "#{key}_#{response.nil? ? '::null::' : response}"
695
+ return if @distinct_id_has_sent_flag_calls[distinct_id].include?(reported_key)
696
+
697
+ msg = {
698
+ distinct_id: distinct_id,
699
+ event: '$feature_flag_called',
700
+ properties: properties
701
+ }
702
+ msg[:groups] = groups if groups
703
+ msg[:disable_geoip] = disable_geoip unless disable_geoip.nil?
704
+
705
+ capture(msg)
706
+ @distinct_id_has_sent_flag_calls[distinct_id] << reported_key
707
+ end
708
+
709
+ def _feature_flag_evaluations_host
710
+ @feature_flag_evaluations_host ||= FeatureFlagEvaluations::Host.new(
711
+ capture_flag_called_event_if_needed: method(:_capture_feature_flag_called_if_needed),
712
+ log_warning: ->(message) { logger.warn(message) }
713
+ )
714
+ end
715
+
716
+ # Implementation of {#get_feature_flag_result}, called by both the public
717
+ # method and the legacy `is_feature_enabled` / `get_feature_flag` paths so
718
+ # a single user-level call emits exactly one deprecation warning.
719
+ def _get_feature_flag_result(
720
+ key,
721
+ distinct_id,
722
+ groups: {},
723
+ person_properties: {},
724
+ group_properties: {},
725
+ only_evaluate_locally: false,
726
+ send_feature_flag_events: true
727
+ )
728
+ person_properties, group_properties = add_local_person_and_group_properties(
729
+ distinct_id, groups, person_properties, group_properties
730
+ )
731
+ feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
732
+ @feature_flags_poller.get_feature_flag(
733
+ key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally
734
+ )
735
+ if send_feature_flag_events
736
+ properties = {
737
+ '$feature_flag' => key,
738
+ '$feature_flag_response' => feature_flag_response,
739
+ 'locally_evaluated' => flag_was_locally_evaluated
740
+ }
741
+ properties['$feature_flag_request_id'] = request_id if request_id
742
+ properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
743
+ properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
744
+
745
+ _capture_feature_flag_called_if_needed(
746
+ distinct_id: distinct_id, key: key, response: feature_flag_response,
747
+ properties: properties, groups: groups
748
+ )
749
+ end
750
+
751
+ FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload)
752
+ end
753
+
754
+ # Emits a deprecation warning at most once per `(method_name, process)` pair.
755
+ # Ruby's `Kernel.warn(..., category: :deprecated)` is suppressed by default
756
+ # since 2.7.2; we emit without the category so messages reach users on a
757
+ # default Ruby setup. Standard logger configuration / `$VERBOSE = nil` / IO
758
+ # redirection still silences as expected.
759
+ def _emit_deprecation(method_name, message)
760
+ return unless @deprecation_emitted_for.add?(method_name)
761
+
762
+ Kernel.warn("[posthog-ruby] DEPRECATION: #{message}", uplevel: 2)
763
+ end
764
+
530
765
  # before_send should run immediately before the event is sent to the queue.
531
766
  # @param [Object] action The event to be sent to PostHog
532
767
  # @return [null, Object, nil] The processed event or nil if the event should not be sent
@@ -585,6 +820,22 @@ module PostHog
585
820
  raise ArgumentError, 'API key must be initialized' if @api_key.nil?
586
821
  end
587
822
 
823
+ def normalize_string_option(value, blank_as_nil: false)
824
+ return value unless value.is_a?(String)
825
+
826
+ normalized = value.strip
827
+ return nil if blank_as_nil && normalized.empty?
828
+
829
+ normalized
830
+ end
831
+
832
+ def normalize_host_option(host)
833
+ normalized = normalize_string_option(host)
834
+ return 'https://us.i.posthog.com' if normalized.nil? || normalized.empty?
835
+
836
+ normalized
837
+ end
838
+
588
839
  def ensure_worker_running
589
840
  return if worker_running?
590
841
 
@@ -5,7 +5,7 @@ module PostHog
5
5
  MAX_HASH_SIZE = 50_000
6
6
 
7
7
  module Request
8
- HOST = 'app.posthog.com'
8
+ HOST = 'us.i.posthog.com'
9
9
  PORT = 443
10
10
  PATH = '/batch/'
11
11
  SSL = true
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module PostHog
6
+ # A snapshot of feature flag evaluations for one distinct_id, returned by
7
+ # PostHog::Client#evaluate_flags. Calls to {#is_enabled} / {#get_flag} fire the
8
+ # `$feature_flag_called` event (deduped through the existing per-distinct_id
9
+ # cache); {#get_flag_payload} does not. Pass the snapshot to `capture(flags:)`
10
+ # to attach `$feature/<key>` and `$active_feature_flags` without a second
11
+ # /flags request.
12
+ class FeatureFlagEvaluations
13
+ EVALUATED_LOCALLY_REASON = 'Evaluated locally'
14
+
15
+ EvaluatedFlagRecord = Struct.new(
16
+ :key, :enabled, :variant, :payload, :id, :version, :reason, :locally_evaluated,
17
+ keyword_init: true
18
+ )
19
+
20
+ Host = Struct.new(:capture_flag_called_event_if_needed, :log_warning, keyword_init: true)
21
+
22
+ attr_reader :distinct_id, :groups, :request_id, :evaluated_at, :flag_definitions_loaded_at
23
+
24
+ def initialize(
25
+ host: nil,
26
+ distinct_id: nil,
27
+ flags: {},
28
+ groups: nil,
29
+ disable_geoip: nil,
30
+ request_id: nil,
31
+ evaluated_at: nil,
32
+ flag_definitions_loaded_at: nil,
33
+ errors_while_computing: false,
34
+ quota_limited: false,
35
+ accessed: nil
36
+ )
37
+ @host = host
38
+ @distinct_id = distinct_id || ''
39
+ @flags = flags || {}
40
+ @groups = groups
41
+ @disable_geoip = disable_geoip
42
+ @request_id = request_id
43
+ @evaluated_at = evaluated_at
44
+ @flag_definitions_loaded_at = flag_definitions_loaded_at
45
+ @errors_while_computing = errors_while_computing
46
+ @quota_limited = quota_limited
47
+ @accessed = Set.new(accessed || [])
48
+ end
49
+
50
+ def keys
51
+ @flags.keys
52
+ end
53
+
54
+ def enabled?(key)
55
+ key = key.to_s
56
+ flag = @flags[key]
57
+ _record_access(key, flag)
58
+ flag&.enabled ? true : false
59
+ end
60
+
61
+ def get_flag(key)
62
+ key = key.to_s
63
+ flag = @flags[key]
64
+ _record_access(key, flag)
65
+ _flag_value(flag)
66
+ end
67
+
68
+ def get_flag_payload(key)
69
+ flag = @flags[key.to_s]
70
+ flag&.payload
71
+ end
72
+
73
+ # Order-dependent: if nothing has been accessed yet, the returned snapshot is
74
+ # empty. The method honors its name — pre-access flags before calling this if
75
+ # you want a populated result.
76
+ def only_accessed
77
+ _clone_with(@flags.slice(*@accessed))
78
+ end
79
+
80
+ def only(keys)
81
+ keys = Array(keys).map(&:to_s)
82
+ missing = keys.reject { |k| @flags.key?(k) }
83
+ unless missing.empty?
84
+ @host.log_warning.call(
85
+ 'FeatureFlagEvaluations#only was called with flag keys that are not in the ' \
86
+ "evaluation set and will be dropped: #{missing.join(', ')}"
87
+ )
88
+ end
89
+ filtered = @flags.slice(*keys)
90
+ _clone_with(filtered)
91
+ end
92
+
93
+ # Builds the `$feature/<key>` and `$active_feature_flags` properties for a
94
+ # captured event. Called from PostHog::Client#capture when `flags:` is set.
95
+ def _get_event_properties
96
+ properties = {}
97
+ active = []
98
+ @flags.each do |key, flag|
99
+ properties["$feature/#{key}"] = flag.enabled ? (flag.variant || true) : false
100
+ active << key if flag.enabled
101
+ end
102
+ properties['$active_feature_flags'] = active.sort unless active.empty?
103
+ properties
104
+ end
105
+
106
+ private
107
+
108
+ # Canonical "stored" value for a flag — used for both the
109
+ # `$feature_flag_response` event property and the dedup cache key, so
110
+ # `enabled?` and `get_flag` collapse to a single exposure per flag.
111
+ # Variant string when present, else boolean enabled status; `nil` for
112
+ # unknown flags.
113
+ def _flag_value(flag)
114
+ return nil if flag.nil?
115
+ return flag.variant if flag.variant
116
+
117
+ flag.enabled ? true : false
118
+ end
119
+
120
+ def _record_access(key, flag)
121
+ @accessed.add(key)
122
+ return if @distinct_id.nil? || @distinct_id.to_s.empty?
123
+
124
+ response = _flag_value(flag)
125
+ properties = {
126
+ '$feature_flag' => key,
127
+ '$feature_flag_response' => response,
128
+ 'locally_evaluated' => flag&.locally_evaluated ? true : false,
129
+ "$feature/#{key}" => response
130
+ }
131
+
132
+ if flag
133
+ properties['$feature_flag_payload'] = flag.payload unless flag.payload.nil?
134
+ properties['$feature_flag_id'] = flag.id if flag.id
135
+ properties['$feature_flag_version'] = flag.version if flag.version
136
+ properties['$feature_flag_reason'] = flag.reason if flag.reason
137
+ if flag.locally_evaluated && @flag_definitions_loaded_at
138
+ properties['$feature_flag_definitions_loaded_at'] = @flag_definitions_loaded_at
139
+ end
140
+ end
141
+
142
+ properties['$feature_flag_request_id'] = @request_id if @request_id
143
+ properties['$feature_flag_evaluated_at'] = @evaluated_at if @evaluated_at && !(flag && flag.locally_evaluated)
144
+
145
+ errors = []
146
+ errors << 'errors_while_computing_flags' if @errors_while_computing
147
+ errors << 'quota_limited' if @quota_limited
148
+ errors << 'flag_missing' if flag.nil?
149
+ properties['$feature_flag_error'] = errors.join(',') unless errors.empty?
150
+
151
+ @host.capture_flag_called_event_if_needed.call(
152
+ distinct_id: @distinct_id,
153
+ key: key,
154
+ response: response,
155
+ properties: properties,
156
+ groups: @groups,
157
+ disable_geoip: @disable_geoip
158
+ )
159
+ end
160
+
161
+ def _clone_with(flags)
162
+ self.class.new(
163
+ host: @host,
164
+ distinct_id: @distinct_id,
165
+ flags: flags,
166
+ groups: @groups,
167
+ disable_geoip: @disable_geoip,
168
+ request_id: @request_id,
169
+ evaluated_at: @evaluated_at,
170
+ flag_definitions_loaded_at: @flag_definitions_loaded_at,
171
+ errors_while_computing: @errors_while_computing,
172
+ quota_limited: @quota_limited,
173
+ accessed: @accessed.dup
174
+ )
175
+ end
176
+ end
177
+ end
@@ -39,6 +39,10 @@ module PostHog
39
39
  end
40
40
  end
41
41
 
42
+ # Deserialize a flag payload. Strings are JSON-parsed (with the raw string
43
+ # returned when the body is not valid JSON); already-deserialized values
44
+ # pass through. Public so {FeatureFlagEvaluations} can normalize payloads
45
+ # the same way {FeatureFlagResult} does.
42
46
  def self.parse_payload(payload)
43
47
  return nil if payload.nil?
44
48
  return payload unless payload.is_a?(String)
@@ -50,7 +54,5 @@ module PostHog
50
54
  payload
51
55
  end
52
56
  end
53
-
54
- private_class_method :parse_payload
55
57
  end
56
58
  end
@@ -46,6 +46,7 @@ module PostHog
46
46
  @on_error = on_error || proc { |status, error| }
47
47
  @quota_limited = Concurrent::AtomicBoolean.new(false)
48
48
  @flags_etag = Concurrent::AtomicReference.new(nil)
49
+ @flag_definitions_loaded_at = nil
49
50
 
50
51
  @flag_definition_cache_provider = flag_definition_cache_provider
51
52
  FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
@@ -72,6 +73,8 @@ module PostHog
72
73
  _load_feature_flags
73
74
  end
74
75
 
76
+ attr_reader :flag_definitions_loaded_at, :feature_flags_by_key
77
+
75
78
  def get_feature_variants(
76
79
  distinct_id,
77
80
  groups = {},
@@ -120,13 +123,16 @@ module PostHog
120
123
  end
121
124
  end
122
125
 
123
- def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
126
+ def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys = nil,
127
+ disable_geoip = nil)
124
128
  request_data = {
125
129
  distinct_id: distinct_id,
126
130
  groups: groups,
127
131
  person_properties: person_properties,
128
132
  group_properties: group_properties
129
133
  }
134
+ request_data[:flag_keys_to_evaluate] = flag_keys if flag_keys && !flag_keys.empty?
135
+ request_data[:geoip_disable] = true if disable_geoip
130
136
 
131
137
  flags_response = _request_feature_flag_evaluation(request_data)
132
138
 
@@ -1124,11 +1130,12 @@ module PostHog
1124
1130
  @cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
1125
1131
 
1126
1132
  logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
1133
+ @flag_definitions_loaded_at = (Time.now.to_f * 1000).to_i
1127
1134
  @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
1128
1135
  end
1129
1136
 
1130
1137
  def _request_feature_flag_definitions(etag: nil)
1131
- uri = URI("#{@host}/api/feature_flag/local_evaluation")
1138
+ uri = URI("#{@host}/flags/definitions")
1132
1139
  uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
1133
1140
  req = Net::HTTP::Get.new(uri)
1134
1141
  req['Authorization'] = "Bearer #{@personal_api_key}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.6.4'
4
+ VERSION = '3.7.0'
5
5
  end
data/lib/posthog.rb CHANGED
@@ -12,4 +12,5 @@ require 'posthog/logging'
12
12
  require 'posthog/exception_capture'
13
13
  require 'posthog/feature_flag_error'
14
14
  require 'posthog/feature_flag_result'
15
+ require 'posthog/feature_flag_evaluations'
15
16
  require 'posthog/flag_definition_cache'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.6.4
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -41,6 +41,7 @@ files:
41
41
  - lib/posthog/exception_capture.rb
42
42
  - lib/posthog/feature_flag.rb
43
43
  - lib/posthog/feature_flag_error.rb
44
+ - lib/posthog/feature_flag_evaluations.rb
44
45
  - lib/posthog/feature_flag_result.rb
45
46
  - lib/posthog/feature_flags.rb
46
47
  - lib/posthog/field_parser.rb