posthog-ruby 3.6.0 → 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: f9cb5856b81b9b86c58818eb7063f4e8ca950fb1e9f9b23d998b16a43c21f9dc
4
- data.tar.gz: e0d5f9a42f919ec8924b096f2c0bb3f32a5052f6b09d868ccf194dab6d5f4980
3
+ metadata.gz: dc278624783a997ea124cc47e7d6724c2160b10509dde00293725e66ca4edceb
4
+ data.tar.gz: db194b891449029e91c8f20d82b873fe8aebe69182db77053ce966366af94ea9
5
5
  SHA512:
6
- metadata.gz: b2736eedb29171b83370b7be1eea115d015a907f2c27ed656a4ab8c708050867464a82608b1520e84d2bf045031625ae0645191395bb9992622e24ddc0dbb5e4
7
- data.tar.gz: 89f33534310162d51b7edd63f99f87ef41e2b803fad8cbec9930e7c382143ae63297ac273425468b91bfbfbe863a68a06a8659364ca881ba48245a907d97bd2e
6
+ metadata.gz: 294eacce50eef253e5d6890c2c972c300e8caab9462889648f89cb2449a06357343515d54935b69b83ae0707e2c77adc2010bad8efc14f7e393c8427744422b3
7
+ data.tar.gz: ffa9da1ee5e32b462e83bc3a4f362bf855dd62931d3351dd41850909300c85921cd2e6b56851fd752f165c9a429cb02b28336a69155187451b4326ca65d6798d
@@ -68,6 +68,9 @@ module PostHog
68
68
  # to be sent to PostHog or nil to prevent the event from being sent. e.g. `before_send: ->(event) { event }`
69
69
  # @option opts [Bool] :disable_singleton_warning +true+ to suppress the warning when multiple clients
70
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.
71
74
  def initialize(opts = {})
72
75
  symbolize_keys!(opts)
73
76
 
@@ -120,7 +123,8 @@ module PostHog
120
123
  @api_key,
121
124
  opts[:host],
122
125
  opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
123
- opts[:on_error]
126
+ opts[:on_error],
127
+ flag_definition_cache_provider: opts[:flag_definition_cache_provider]
124
128
  )
125
129
 
126
130
  @distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
@@ -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
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.6.0'
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,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.0
4
+ version: 3.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -44,6 +44,7 @@ files:
44
44
  - lib/posthog/feature_flag_result.rb
45
45
  - lib/posthog/feature_flags.rb
46
46
  - lib/posthog/field_parser.rb
47
+ - lib/posthog/flag_definition_cache.rb
47
48
  - lib/posthog/logging.rb
48
49
  - lib/posthog/message_batch.rb
49
50
  - lib/posthog/noop_worker.rb