posthog-ruby 3.6.5 → 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: 69348dbe265c2d43c2bf8531dacd7faf30e02dc8b6621ad99e634a762465374b
4
- data.tar.gz: 2303defa785affa2c78a24608529b36582a29dbc69133bf17908665262edafe0
3
+ metadata.gz: afcf66d4afc793e15c83b53e455ef605218def4581460279d45721b82c1f73f2
4
+ data.tar.gz: 96f13bf72ddc840302d71c21072db26bdd098b73fd862455dc65b745357dc7d2
5
5
  SHA512:
6
- metadata.gz: 68ad8367f240c9b78cfa7eecc40296e5f24bfcd60dd7db4498b2fddb7acf9f279a2e90e6bf06c25b965b568e8039bfd553bb2dc7ebc6001d145def99f0e63464
7
- data.tar.gz: eaa6abbcf83d2f8f1f390dd3c244dbe10939fdbced0d4f1502395d036e49a7124554181dc466299d34aa5d6756569a662bf532cbac61afaef1e7aa99d54e6738
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
 
@@ -135,6 +136,7 @@ module PostHog
135
136
  end
136
137
 
137
138
  @before_send = opts[:before_send]
139
+ @deprecation_emitted_for = Concurrent::Set.new
138
140
  end
139
141
 
140
142
  # Synchronously waits until the worker has cleared the queue.
@@ -175,6 +177,10 @@ module PostHog
175
177
  # @option attrs [Hash] :properties Event properties (optional)
176
178
  # @option attrs [Bool, Hash, SendFeatureFlagsOptions] :send_feature_flags
177
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`.
178
184
  # @option attrs [String] :uuid ID that uniquely identifies an event;
179
185
  # events in PostHog are deduplicated by the
180
186
  # combination of teamId, timestamp date,
@@ -183,8 +189,39 @@ module PostHog
183
189
  def capture(attrs)
184
190
  symbolize_keys! attrs
185
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
+
186
216
  send_feature_flags_param = attrs[:send_feature_flags]
187
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
+ )
188
225
  # Handle different types of send_feature_flags parameter
189
226
  case send_feature_flags_param
190
227
  when true
@@ -225,7 +262,10 @@ module PostHog
225
262
  # @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object
226
263
  # @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID)
227
264
  # @param [Hash] additional_properties Additional properties to include with the exception event (optional)
228
- 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)
229
269
  exception_info = ExceptionCapture.build_parsed_exception(exception)
230
270
 
231
271
  return if exception_info.nil?
@@ -243,6 +283,7 @@ module PostHog
243
283
  properties: properties,
244
284
  timestamp: Time.now
245
285
  }
286
+ event_data[:flags] = flags if flags
246
287
 
247
288
  capture(event_data)
248
289
  end
@@ -293,6 +334,7 @@ module PostHog
293
334
  @queue.length
294
335
  end
295
336
 
337
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#is_enabled} instead.
296
338
  # TODO: In future version, rename to `feature_flag_enabled?`
297
339
  def is_feature_enabled( # rubocop:disable Naming/PredicateName
298
340
  flag_key,
@@ -303,15 +345,20 @@ module PostHog
303
345
  only_evaluate_locally: false,
304
346
  send_feature_flag_events: true
305
347
  )
306
- response = get_feature_flag(
307
- flag_key,
308
- distinct_id,
309
- groups: groups,
310
- person_properties: person_properties,
311
- group_properties: group_properties,
312
- only_evaluate_locally: only_evaluate_locally,
313
- 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
314
360
  )
361
+ response = result&.value
315
362
  return nil if response.nil?
316
363
 
317
364
  !!response
@@ -344,6 +391,7 @@ module PostHog
344
391
  # ```ruby
345
392
  # group_properties: {"organization": {"name": "PostHog", "employees": 11}}
346
393
  # ```
394
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} instead.
347
395
  def get_feature_flag(
348
396
  key,
349
397
  distinct_id,
@@ -353,77 +401,175 @@ module PostHog
353
401
  only_evaluate_locally: false,
354
402
  send_feature_flag_events: true
355
403
  )
356
- result = get_feature_flag_result(
357
- key,
358
- distinct_id,
359
- groups: groups,
360
- person_properties: person_properties,
361
- group_properties: group_properties,
362
- only_evaluate_locally: only_evaluate_locally,
363
- 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
364
415
  )
365
416
  result&.value
366
417
  end
367
418
 
368
- # Returns both the feature flag value and payload in a single call.
369
- # 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.
370
451
  #
371
- # @param [String] key The key of the feature flag
372
452
  # @param [String] distinct_id The distinct id of the user
373
453
  # @param [Hash] groups
374
- # @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
375
455
  # @param [Hash] group_properties
376
- # @param [Boolean] only_evaluate_locally
377
- # @param [Boolean] send_feature_flag_events
378
- #
379
- # @return [FeatureFlagResult, nil] A FeatureFlagResult object containing the flag value and payload,
380
- # or nil if the flag evaluation returned nil
381
- def get_feature_flag_result(
382
- 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(
383
464
  distinct_id,
384
465
  groups: {},
385
466
  person_properties: {},
386
467
  group_properties: {},
387
468
  only_evaluate_locally: false,
388
- send_feature_flag_events: true
469
+ disable_geoip: nil,
470
+ flag_keys: nil
389
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
+
390
478
  person_properties, group_properties = add_local_person_and_group_properties(
391
- distinct_id,
392
- groups,
393
- person_properties,
394
- group_properties
479
+ distinct_id, groups, person_properties, group_properties
395
480
  )
396
- feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
397
- @feature_flags_poller.get_feature_flag(
398
- key,
399
- distinct_id,
400
- groups,
401
- person_properties,
402
- group_properties,
403
- only_evaluate_locally
404
- )
405
- feature_flag_reported_key = "#{key}_#{feature_flag_response}"
406
481
 
407
- if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
408
- properties = {
409
- '$feature_flag' => key,
410
- '$feature_flag_response' => feature_flag_response,
411
- 'locally_evaluated' => flag_was_locally_evaluated
412
- }
413
- properties['$feature_flag_request_id'] = request_id if request_id
414
- properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
415
- 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)
485
+
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
416
500
 
417
- capture(
418
- distinct_id: distinct_id,
419
- event: '$feature_flag_called',
420
- properties: properties,
421
- groups: groups
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
422
514
  )
423
- @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
515
+ locally_evaluated_keys << key.to_s
424
516
  end
425
517
 
426
- 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
+ )
427
573
  end
428
574
 
429
575
  # Returns all flags for a given user
@@ -460,6 +606,7 @@ module PostHog
460
606
  # @option [Hash] group_properties
461
607
  # @option [Boolean] only_evaluate_locally
462
608
  #
609
+ # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag_payload} instead.
463
610
  def get_feature_flag_payload(
464
611
  key,
465
612
  distinct_id,
@@ -469,6 +616,13 @@ module PostHog
469
616
  group_properties: {},
470
617
  only_evaluate_locally: false
471
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
+ )
472
626
  person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups,
473
627
  person_properties, group_properties)
474
628
  @feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties,
@@ -530,6 +684,84 @@ module PostHog
530
684
 
531
685
  private
532
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
+
533
765
  # before_send should run immediately before the event is sent to the queue.
534
766
  # @param [Object] action The event to be sent to PostHog
535
767
  # @return [null, Object, nil] The processed event or nil if the event should not be sent
@@ -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.5'
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.5
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