posthog-ruby 3.5.5 → 3.6.1

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: a707a12f37f1d97a91f179b0b6fa6695b6c6342957de137b38f66ebc0e008a5d
4
- data.tar.gz: ecace6615c9121841987baaed7178327e1a50db76f73b5b890f6e42da7c30656
3
+ metadata.gz: dc278624783a997ea124cc47e7d6724c2160b10509dde00293725e66ca4edceb
4
+ data.tar.gz: db194b891449029e91c8f20d82b873fe8aebe69182db77053ce966366af94ea9
5
5
  SHA512:
6
- metadata.gz: 3f584a56131c572124ab4c46b452ca96538b94c386934ee99b768de5dd84c5a34236e65edfefde9bed167fde431b311f70f08fd36786dd586b4173556029b6a2
7
- data.tar.gz: ddd803d8420443ea7ee1fb015a46dfad302a11eab98c02ef70a16820e5e8bea73a82bea644bfb6d1109b11e744739be82810ac4bdaf8375cd9cb2847d79df4eb
6
+ metadata.gz: 294eacce50eef253e5d6890c2c972c300e8caab9462889648f89cb2449a06357343515d54935b69b83ae0707e2c77adc2010bad8efc14f7e393c8427744422b3
7
+ data.tar.gz: ffa9da1ee5e32b462e83bc3a4f362bf855dd62931d3351dd41850909300c85921cd2e6b56851fd752f165c9a429cb02b28336a69155187451b4326ca65d6798d
@@ -8,6 +8,8 @@ require 'posthog/logging'
8
8
  require 'posthog/utils'
9
9
  require 'posthog/send_worker'
10
10
  require 'posthog/noop_worker'
11
+ require 'posthog/message_batch'
12
+ require 'posthog/transport'
11
13
  require 'posthog/feature_flags'
12
14
  require 'posthog/send_feature_flags_options'
13
15
  require 'posthog/exception_capture'
@@ -17,6 +19,35 @@ module PostHog
17
19
  include PostHog::Utils
18
20
  include PostHog::Logging
19
21
 
22
+ # Thread-safe tracking of client instances per API key for singleton warnings
23
+ @instances_by_api_key = {}
24
+ @instances_mutex = Mutex.new
25
+
26
+ class << self
27
+ # Resets instance tracking. Used primarily for testing.
28
+ # In production, instance counts persist for the lifetime of the process.
29
+ def reset_instance_tracking!
30
+ @instances_mutex.synchronize do
31
+ @instances_by_api_key = {}
32
+ end
33
+ end
34
+
35
+ def _increment_instance_count(api_key)
36
+ @instances_mutex.synchronize do
37
+ count = @instances_by_api_key[api_key] || 0
38
+ @instances_by_api_key[api_key] = count + 1
39
+ count
40
+ end
41
+ end
42
+
43
+ def _decrement_instance_count(api_key)
44
+ @instances_mutex.synchronize do
45
+ count = (@instances_by_api_key[api_key] || 1) - 1
46
+ @instances_by_api_key[api_key] = [count, 0].max
47
+ end
48
+ end
49
+ end
50
+
20
51
  # @param [Hash] opts
21
52
  # @option opts [String] :api_key Your project's api_key
22
53
  # @option opts [String] :personal_api_key Your personal API key
@@ -24,6 +55,9 @@ module PostHog
24
55
  # remain queued. Defaults to 10_000.
25
56
  # @option opts [Bool] :test_mode +true+ if messages should remain
26
57
  # queued for testing. Defaults to +false+.
58
+ # @option opts [Bool] :sync_mode +true+ to send events synchronously
59
+ # on the calling thread. Useful in forking environments like Sidekiq
60
+ # and Resque. Defaults to +false+.
27
61
  # @option opts [Proc] :on_error Handles error calls from the API.
28
62
  # @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://app.posthog.com`
29
63
  # @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes.
@@ -32,6 +66,11 @@ module PostHog
32
66
  # Measured in seconds, defaults to 3.
33
67
  # @option opts [Proc] :before_send A block that receives the event hash and should return either a modified hash
34
68
  # to be sent to PostHog or nil to prevent the event from being sent. e.g. `before_send: ->(event) { event }`
69
+ # @option opts [Bool] :disable_singleton_warning +true+ to suppress the warning when multiple clients
70
+ # share the same API key. Use only when you intentionally need multiple clients. Defaults to +false+.
71
+ # @option opts [Object] :flag_definition_cache_provider An object implementing the
72
+ # {FlagDefinitionCacheProvider} interface for distributed flag definition caching.
73
+ # EXPERIMENTAL: This API may change in future minor version bumps.
35
74
  def initialize(opts = {})
36
75
  symbolize_keys!(opts)
37
76
 
@@ -41,17 +80,42 @@ module PostHog
41
80
  @api_key = opts[:api_key]
42
81
  @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
43
82
  @worker_mutex = Mutex.new
83
+ @sync_mode = opts[:sync_mode] == true && !opts[:test_mode]
84
+ @on_error = opts[:on_error] || proc { |status, error| }
44
85
  @worker = if opts[:test_mode]
45
86
  NoopWorker.new(@queue)
87
+ elsif @sync_mode
88
+ nil
46
89
  else
47
90
  SendWorker.new(@queue, @api_key, opts)
48
91
  end
92
+ if @sync_mode
93
+ @transport = Transport.new(
94
+ api_host: opts[:host],
95
+ skip_ssl_verification: opts[:skip_ssl_verification],
96
+ retries: 3
97
+ )
98
+ @sync_lock = Mutex.new
99
+ end
49
100
  @worker_thread = nil
50
101
  @feature_flags_poller = nil
51
102
  @personal_api_key = opts[:personal_api_key]
52
103
 
53
104
  check_api_key!
54
105
 
106
+ # Warn when multiple clients are created with the same API key (can cause dropped events)
107
+ unless opts[:test_mode] || opts[:disable_singleton_warning]
108
+ previous_count = self.class._increment_instance_count(@api_key)
109
+ if previous_count >= 1
110
+ logger.warn(
111
+ 'Multiple PostHog client instances detected for the same API key. ' \
112
+ 'This can cause dropped events and inconsistent behavior. ' \
113
+ 'Use a singleton pattern: instantiate once and reuse the client. ' \
114
+ 'See https://posthog.com/docs/libraries/ruby'
115
+ )
116
+ end
117
+ end
118
+
55
119
  @feature_flags_poller =
56
120
  FeatureFlagsPoller.new(
57
121
  opts[:feature_flags_polling_interval],
@@ -59,7 +123,8 @@ module PostHog
59
123
  @api_key,
60
124
  opts[:host],
61
125
  opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
62
- opts[:on_error]
126
+ opts[:on_error],
127
+ flag_definition_cache_provider: opts[:flag_definition_cache_provider]
63
128
  )
64
129
 
65
130
  @distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
@@ -74,6 +139,12 @@ module PostHog
74
139
  # Use only for scripts which are not long-running, and will specifically
75
140
  # exit
76
141
  def flush
142
+ if @sync_mode
143
+ # Wait for any in-flight sync send to complete
144
+ @sync_lock.synchronize {} # rubocop:disable Lint/EmptyBlock
145
+ return
146
+ end
147
+
77
148
  while !@queue.empty? || @worker.is_requesting?
78
149
  ensure_worker_running
79
150
  sleep(0.1)
@@ -444,8 +515,14 @@ module PostHog
444
515
  end
445
516
 
446
517
  def shutdown
518
+ self.class._decrement_instance_count(@api_key) if @api_key
447
519
  @feature_flags_poller.shutdown_poller
448
520
  flush
521
+ if @sync_mode
522
+ @sync_lock.synchronize { @transport&.shutdown }
523
+ else
524
+ @worker&.shutdown
525
+ end
449
526
  end
450
527
 
451
528
  private
@@ -483,6 +560,11 @@ module PostHog
483
560
  # add our request id for tracing purposes
484
561
  action[:messageId] ||= uid
485
562
 
563
+ if @sync_mode
564
+ send_sync(action)
565
+ return true
566
+ end
567
+
486
568
  if @queue.length < @max_queue_size
487
569
  @queue << action
488
570
  ensure_worker_running
@@ -513,6 +595,22 @@ module PostHog
513
595
  end
514
596
  end
515
597
 
598
+ def send_sync(action)
599
+ batch = MessageBatch.new(1)
600
+ begin
601
+ batch << action
602
+ rescue MessageBatch::JSONGenerationError => e
603
+ @on_error.call(-1, e.to_s)
604
+ return
605
+ end
606
+ return if batch.empty?
607
+
608
+ @sync_lock.synchronize do
609
+ res = @transport.send(@api_key, batch)
610
+ @on_error.call(res.status, res.error) unless res.status == 200
611
+ end
612
+ end
613
+
516
614
  def worker_running?
517
615
  @worker_thread&.alive?
518
616
  end
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'posthog/version'
7
7
  require 'posthog/logging'
8
8
  require 'posthog/feature_flag'
9
+ require 'posthog/flag_definition_cache'
9
10
  require 'digest'
10
11
 
11
12
  module PostHog
@@ -29,7 +30,8 @@ module PostHog
29
30
  project_api_key,
30
31
  host,
31
32
  feature_flag_request_timeout_seconds,
32
- on_error = nil
33
+ on_error = nil,
34
+ flag_definition_cache_provider: nil
33
35
  )
34
36
  @polling_interval = polling_interval || 30
35
37
  @personal_api_key = personal_api_key
@@ -44,6 +46,10 @@ module PostHog
44
46
  @on_error = on_error || proc { |status, error| }
45
47
  @quota_limited = Concurrent::AtomicBoolean.new(false)
46
48
  @flags_etag = Concurrent::AtomicReference.new(nil)
49
+
50
+ @flag_definition_cache_provider = flag_definition_cache_provider
51
+ FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
52
+
47
53
  @task =
48
54
  Concurrent::TimerTask.new(
49
55
  execution_interval: polling_interval
@@ -372,6 +378,13 @@ module PostHog
372
378
 
373
379
  def shutdown_poller
374
380
  @task.shutdown
381
+ return unless @flag_definition_cache_provider
382
+
383
+ begin
384
+ @flag_definition_cache_provider.shutdown
385
+ rescue StandardError => e
386
+ logger.error("[FEATURE FLAGS] Cache provider shutdown error: #{e}")
387
+ end
375
388
  end
376
389
 
377
390
  # Class methods
@@ -1006,6 +1019,38 @@ module PostHog
1006
1019
  end
1007
1020
 
1008
1021
  def _load_feature_flags
1022
+ should_fetch = true
1023
+
1024
+ if @flag_definition_cache_provider
1025
+ begin
1026
+ should_fetch = @flag_definition_cache_provider.should_fetch_flag_definitions?
1027
+ rescue StandardError => e
1028
+ logger.error("[FEATURE FLAGS] Cache provider should_fetch error: #{e}")
1029
+ should_fetch = true
1030
+ end
1031
+ end
1032
+
1033
+ unless should_fetch
1034
+ begin
1035
+ cached_data = @flag_definition_cache_provider.flag_definitions
1036
+ if cached_data
1037
+ logger.debug '[FEATURE FLAGS] Using cached flag definitions from external cache'
1038
+ _apply_flag_definitions(cached_data)
1039
+ return
1040
+ elsif @feature_flags.empty?
1041
+ # Emergency fallback: cache empty and no flags loaded -> fetch from API
1042
+ should_fetch = true
1043
+ end
1044
+ rescue StandardError => e
1045
+ logger.error("[FEATURE FLAGS] Cache provider get error: #{e}")
1046
+ should_fetch = true
1047
+ end
1048
+ end
1049
+
1050
+ _fetch_and_apply_flag_definitions if should_fetch
1051
+ end
1052
+
1053
+ def _fetch_and_apply_flag_definitions
1009
1054
  begin
1010
1055
  res = _request_feature_flag_definitions(etag: @flags_etag.value)
1011
1056
  rescue StandardError => e
@@ -1040,21 +1085,48 @@ module PostHog
1040
1085
  # Only update ETag on successful responses with flag data
1041
1086
  @flags_etag.value = res[:etag]
1042
1087
 
1043
- @feature_flags = res[:flags] || []
1044
- @feature_flags_by_key = {}
1045
- @feature_flags.each do |flag|
1046
- @feature_flags_by_key[flag[:key]] = flag unless flag[:key].nil?
1047
- end
1048
- @group_type_mapping = res[:group_type_mapping] || {}
1049
- @cohorts = res[:cohorts] || {}
1050
-
1051
- logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
1052
- @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
1088
+ _apply_flag_definitions(res)
1089
+ _store_in_cache_provider
1053
1090
  else
1054
1091
  logger.debug "Failed to load feature flags: #{res}"
1055
1092
  end
1056
1093
  end
1057
1094
 
1095
+ def _store_in_cache_provider
1096
+ return unless @flag_definition_cache_provider
1097
+
1098
+ begin
1099
+ data = {
1100
+ flags: @feature_flags.to_a,
1101
+ group_type_mapping: @group_type_mapping.to_h,
1102
+ cohorts: @cohorts.to_h
1103
+ }
1104
+ @flag_definition_cache_provider.on_flag_definitions_received(data)
1105
+ rescue StandardError => e
1106
+ logger.error("[FEATURE FLAGS] Cache provider store error: #{e}")
1107
+ end
1108
+ end
1109
+
1110
+ def _apply_flag_definitions(data)
1111
+ flags = get_by_symbol_or_string_key(data, 'flags') || []
1112
+ group_type_mapping = get_by_symbol_or_string_key(data, 'group_type_mapping') || {}
1113
+ cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
1114
+
1115
+ @feature_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
1116
+
1117
+ new_by_key = {}
1118
+ @feature_flags.each do |flag|
1119
+ new_by_key[flag[:key]] = flag unless flag[:key].nil?
1120
+ end
1121
+ @feature_flags_by_key = new_by_key
1122
+
1123
+ @group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
1124
+ @cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
1125
+
1126
+ logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
1127
+ @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
1128
+ end
1129
+
1058
1130
  def _request_feature_flag_definitions(etag: nil)
1059
1131
  uri = URI("#{@host}/api/feature_flag/local_evaluation")
1060
1132
  uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ # Interface for external caching of feature flag definitions.
5
+ #
6
+ # EXPERIMENTAL: This API may change in future minor version bumps.
7
+ #
8
+ # Enables multi-worker environments (Kubernetes, load-balanced servers,
9
+ # serverless functions) to share flag definitions via an external cache,
10
+ # reducing redundant API calls.
11
+ #
12
+ # Implement the four required methods on any object and pass it as the
13
+ # +:flag_definition_cache_provider+ option when creating a {Client}.
14
+ #
15
+ # == Required Methods
16
+ #
17
+ # [+flag_definitions+]
18
+ # Retrieve cached flag definitions. Return a Hash with +:flags+,
19
+ # +:group_type_mapping+, and +:cohorts+ keys, or +nil+ if the cache
20
+ # is empty. Returning +nil+ triggers an API fetch when no flags are
21
+ # loaded yet (emergency fallback).
22
+ #
23
+ # [+should_fetch_flag_definitions?+]
24
+ # Return +true+ if this instance should fetch new definitions from the
25
+ # API, +false+ to read from cache instead. Use for distributed lock
26
+ # coordination so only one worker fetches at a time.
27
+ #
28
+ # [+on_flag_definitions_received(data)+]
29
+ # Called after successfully fetching new definitions from the API.
30
+ # +data+ is a Hash with +:flags+, +:group_type_mapping+, and +:cohorts+
31
+ # keys (plain Ruby types, not Concurrent:: wrappers). Store it in your
32
+ # external cache.
33
+ #
34
+ # [+shutdown+]
35
+ # Called when the PostHog client shuts down. Release any distributed
36
+ # locks and clean up resources.
37
+ #
38
+ # == Error Handling
39
+ #
40
+ # All methods are wrapped in +begin/rescue+. Errors are logged but never
41
+ # break flag evaluation:
42
+ # - +should_fetch_flag_definitions?+ errors default to fetching (fail-safe)
43
+ # - +flag_definitions+ errors fall back to API fetch
44
+ # - +on_flag_definitions_received+ errors are logged; flags remain in memory
45
+ # - +shutdown+ errors are logged; shutdown continues
46
+ #
47
+ # == Example
48
+ #
49
+ # cache = RedisFlagCache.new(redis, service_key: 'my-service')
50
+ # client = PostHog::Client.new(
51
+ # api_key: '<project_api_key>',
52
+ # personal_api_key: '<personal_api_key>',
53
+ # flag_definition_cache_provider: cache
54
+ # )
55
+ #
56
+ module FlagDefinitionCacheProvider
57
+ REQUIRED_METHODS = %i[
58
+ flag_definitions
59
+ should_fetch_flag_definitions?
60
+ on_flag_definitions_received
61
+ shutdown
62
+ ].freeze
63
+
64
+ # Validates that +provider+ implements all required methods.
65
+ # Raises +ArgumentError+ listing any missing methods.
66
+ #
67
+ # @param provider [Object] the cache provider to validate
68
+ # @raise [ArgumentError] if any required methods are missing
69
+ def self.validate!(provider)
70
+ missing = REQUIRED_METHODS.reject { |m| provider.respond_to?(m) }
71
+ return if missing.empty?
72
+
73
+ raise ArgumentError,
74
+ "Flag definition cache provider is missing required methods: #{missing.join(', ')}. " \
75
+ 'See PostHog::FlagDefinitionCacheProvider for the required interface.'
76
+ end
77
+ end
78
+ end
@@ -15,5 +15,9 @@ module PostHog
15
15
  def is_requesting? # rubocop:disable Naming/PredicateName
16
16
  false
17
17
  end
18
+
19
+ def shutdown
20
+ # Does nothing
21
+ end
18
22
  end
19
23
  end
@@ -54,6 +54,10 @@ module PostHog
54
54
  @transport.shutdown
55
55
  end
56
56
 
57
+ def shutdown
58
+ @transport.shutdown
59
+ end
60
+
57
61
  # public: Check whether we have outstanding requests.
58
62
  #
59
63
  # TODO: Rename to `requesting?` in future version
data/lib/posthog/utils.rb CHANGED
@@ -27,6 +27,21 @@ module PostHog
27
27
  hash.transform_keys(&:to_s)
28
28
  end
29
29
 
30
+ # public: Recursively convert all keys to symbols in a Hash/Array tree
31
+ #
32
+ def deep_symbolize_keys(obj)
33
+ case obj
34
+ when Hash
35
+ obj.each_with_object({}) do |(key, value), result|
36
+ result[key.to_sym] = deep_symbolize_keys(value)
37
+ end
38
+ when Array
39
+ obj.map { |item| deep_symbolize_keys(item) }
40
+ else
41
+ obj
42
+ end
43
+ end
44
+
30
45
  # public: Returns a new hash with all the date values in the into iso8601
31
46
  # strings
32
47
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.5.5'
4
+ VERSION = '3.6.1'
5
5
  end
data/lib/posthog.rb CHANGED
@@ -12,3 +12,4 @@ 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/flag_definition_cache'
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.5.5
4
+ version: 3.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-03-05 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: concurrent-ruby
@@ -45,6 +44,7 @@ files:
45
44
  - lib/posthog/feature_flag_result.rb
46
45
  - lib/posthog/feature_flags.rb
47
46
  - lib/posthog/field_parser.rb
47
+ - lib/posthog/flag_definition_cache.rb
48
48
  - lib/posthog/logging.rb
49
49
  - lib/posthog/message_batch.rb
50
50
  - lib/posthog/noop_worker.rb
@@ -59,7 +59,6 @@ licenses:
59
59
  - MIT
60
60
  metadata:
61
61
  rubygems_mfa_required: 'true'
62
- post_install_message:
63
62
  rdoc_options: []
64
63
  require_paths:
65
64
  - lib
@@ -74,8 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
73
  - !ruby/object:Gem::Version
75
74
  version: '0'
76
75
  requirements: []
77
- rubygems_version: 3.5.10
78
- signing_key:
76
+ rubygems_version: 4.0.6
79
77
  specification_version: 4
80
78
  summary: PostHog library
81
79
  test_files: []