posthog-ruby 3.20.0 → 3.21.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 +4 -4
- data/lib/posthog/client.rb +55 -5
- data/lib/posthog/feature_flag_evaluations.rb +12 -1
- data/lib/posthog/feature_flags.rb +22 -5
- data/lib/posthog/flag_definition_cache.rb +8 -6
- data/lib/posthog/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ae3a78450df91e2e4cb88cd69a58e167cdb291dcc02f8ab446e3b0975c270eb
|
|
4
|
+
data.tar.gz: b912b023ed02a37004cfbe22511437ee4df2a5ec63799d6d47e6f6aa9fc4f164
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42b8b2587ee825e9a5446def45d11940b52a3a2203828b4506b2a4e1c28e2deefd61406df6ab33800bf1055c1475130554d6edef55a1498b478c43a30591f7b0
|
|
7
|
+
data.tar.gz: 4eec6bdd11abbeee85720d2a68872ad366f39b5c9c28ce72fd96aaf54f3098218578ccac954e35fe01099975d6f5a2afcfe5016b4460520a6341e465f026ed99
|
data/lib/posthog/client.rb
CHANGED
|
@@ -22,6 +22,31 @@ module PostHog
|
|
|
22
22
|
include PostHog::Utils
|
|
23
23
|
include PostHog::Logging
|
|
24
24
|
|
|
25
|
+
# Strict allowlist of properties kept on minimal `$feature_flag_called`
|
|
26
|
+
# events (server-gated, non-experiment flags only). Everything else —
|
|
27
|
+
# context properties, `$feature/<key>`, payloads, system metadata — is
|
|
28
|
+
# stripped. Includes symbol forms so allowlisted properties supplied
|
|
29
|
+
# through context with symbol keys survive.
|
|
30
|
+
MINIMAL_FLAG_CALLED_EVENT_PROPERTIES = %w[
|
|
31
|
+
$feature_flag
|
|
32
|
+
$feature_flag_response
|
|
33
|
+
$feature_flag_has_experiment
|
|
34
|
+
$feature_flag_id
|
|
35
|
+
$feature_flag_version
|
|
36
|
+
$feature_flag_reason
|
|
37
|
+
$feature_flag_request_id
|
|
38
|
+
$feature_flag_evaluated_at
|
|
39
|
+
$feature_flag_error
|
|
40
|
+
locally_evaluated
|
|
41
|
+
$groups
|
|
42
|
+
$process_person_profile
|
|
43
|
+
$session_id
|
|
44
|
+
$is_server
|
|
45
|
+
$lib
|
|
46
|
+
$lib_version
|
|
47
|
+
].flat_map { |key| [key, key.to_sym] }.freeze
|
|
48
|
+
private_constant :MINIMAL_FLAG_CALLED_EVENT_PROPERTIES
|
|
49
|
+
|
|
25
50
|
# Thread-safe tracking of client instances per API key for singleton warnings
|
|
26
51
|
@instances_by_api_key = {}
|
|
27
52
|
@instances_mutex = Mutex.new
|
|
@@ -253,6 +278,7 @@ module PostHog
|
|
|
253
278
|
return false if @disabled
|
|
254
279
|
|
|
255
280
|
symbolize_keys! attrs
|
|
281
|
+
minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
|
|
256
282
|
enrich_capture_attrs_with_context(attrs)
|
|
257
283
|
|
|
258
284
|
# Precedence: an explicit `flags` snapshot always wins, regardless of
|
|
@@ -321,7 +347,13 @@ module PostHog
|
|
|
321
347
|
end
|
|
322
348
|
|
|
323
349
|
attrs[:is_server] = @is_server
|
|
324
|
-
|
|
350
|
+
message = FieldParser.parse_for_capture(attrs)
|
|
351
|
+
# Minimal events are built from the allowlist after full assembly so
|
|
352
|
+
# context properties and parser-added metadata can never leak in.
|
|
353
|
+
if minimal_flag_called_event
|
|
354
|
+
message[:properties] = message[:properties].slice(*MINIMAL_FLAG_CALLED_EVENT_PROPERTIES)
|
|
355
|
+
end
|
|
356
|
+
enqueue(message)
|
|
325
357
|
end
|
|
326
358
|
|
|
327
359
|
# Captures an exception as an event
|
|
@@ -627,6 +659,11 @@ module PostHog
|
|
|
627
659
|
evaluated_at = nil
|
|
628
660
|
errors_while_computing = false
|
|
629
661
|
quota_limited = false
|
|
662
|
+
# Server-controlled gate for minimal `$feature_flag_called` events. When
|
|
663
|
+
# the snapshot uses a remote /flags response, the response's top-level
|
|
664
|
+
# `minimalFlagCalledEvents` field governs; a local-only snapshot reads
|
|
665
|
+
# the gate polled with the flag definitions.
|
|
666
|
+
minimal_flag_called_events = @feature_flags_poller.minimal_flag_called_events
|
|
630
667
|
|
|
631
668
|
# Skip the remote `/flags` round-trip when the caller scoped the request
|
|
632
669
|
# to a fixed set of `flag_keys` and we've already resolved every one of
|
|
@@ -635,10 +672,16 @@ module PostHog
|
|
|
635
672
|
all_requested_flags_resolved_locally = flag_keys_set && (flag_keys_set - locally_evaluated_keys).empty?
|
|
636
673
|
|
|
637
674
|
if !only_evaluate_locally && !all_requested_flags_resolved_locally
|
|
675
|
+
# The gate is team-level, so the /flags response gate supersedes the
|
|
676
|
+
# poller gate for mixed snapshots — both signals come from the same
|
|
677
|
+
# server and agree in steady state. When the response omits the gate,
|
|
678
|
+
# the whole snapshot fails safe to full events.
|
|
679
|
+
minimal_flag_called_events = false
|
|
638
680
|
begin
|
|
639
681
|
flags_response = @feature_flags_poller.get_flags(
|
|
640
682
|
distinct_id, groups, person_properties, group_properties, flag_keys, disable_geoip
|
|
641
683
|
)
|
|
684
|
+
minimal_flag_called_events = flags_response[:minimalFlagCalledEvents] == true
|
|
642
685
|
request_id = flags_response[:requestId]
|
|
643
686
|
evaluated_at = flags_response[:evaluatedAt]
|
|
644
687
|
errors_while_computing = flags_response[:errorsWhileComputingFlags] == true
|
|
@@ -677,7 +720,8 @@ module PostHog
|
|
|
677
720
|
evaluated_at: evaluated_at,
|
|
678
721
|
flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at,
|
|
679
722
|
errors_while_computing: errors_while_computing,
|
|
680
|
-
quota_limited: quota_limited
|
|
723
|
+
quota_limited: quota_limited,
|
|
724
|
+
minimal_flag_called_events: minimal_flag_called_events
|
|
681
725
|
)
|
|
682
726
|
end
|
|
683
727
|
|
|
@@ -777,6 +821,7 @@ module PostHog
|
|
|
777
821
|
response.delete(:requestId)
|
|
778
822
|
response.delete(:evaluatedAt)
|
|
779
823
|
response.delete(:flagDetails)
|
|
824
|
+
response.delete(:minimalFlagCalledEvents)
|
|
780
825
|
response
|
|
781
826
|
end
|
|
782
827
|
|
|
@@ -885,7 +930,7 @@ module PostHog
|
|
|
885
930
|
# separate event for each group a user is evaluated under.
|
|
886
931
|
def _capture_feature_flag_called_if_needed(
|
|
887
932
|
distinct_id: nil, key: nil, response: nil, properties: nil,
|
|
888
|
-
groups: nil, disable_geoip: nil
|
|
933
|
+
groups: nil, disable_geoip: nil, minimal: false
|
|
889
934
|
)
|
|
890
935
|
response_repr = response.nil? ? '::null::' : response
|
|
891
936
|
groups_repr =
|
|
@@ -915,6 +960,7 @@ module PostHog
|
|
|
915
960
|
}
|
|
916
961
|
msg[:groups] = groups if groups
|
|
917
962
|
msg[:disable_geoip] = disable_geoip unless disable_geoip.nil?
|
|
963
|
+
msg[:_minimal_flag_called_event] = true if minimal
|
|
918
964
|
|
|
919
965
|
capture(msg)
|
|
920
966
|
end
|
|
@@ -945,7 +991,7 @@ module PostHog
|
|
|
945
991
|
groups, person_properties, group_properties
|
|
946
992
|
)
|
|
947
993
|
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload,
|
|
948
|
-
has_experiment =
|
|
994
|
+
has_experiment, minimal_flag_called_events =
|
|
949
995
|
@feature_flags_poller.get_feature_flag(
|
|
950
996
|
key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally
|
|
951
997
|
)
|
|
@@ -960,9 +1006,13 @@ module PostHog
|
|
|
960
1006
|
properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
|
|
961
1007
|
properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
|
|
962
1008
|
|
|
1009
|
+
# Emit a minimal event only when the server gate is on and the flag is
|
|
1010
|
+
# known to have no linked experiment. Any missing signal fails safe to
|
|
1011
|
+
# the full event.
|
|
1012
|
+
minimal = minimal_flag_called_events == true && has_experiment == false
|
|
963
1013
|
_capture_feature_flag_called_if_needed(
|
|
964
1014
|
distinct_id: distinct_id, key: key, response: feature_flag_response,
|
|
965
|
-
properties: properties, groups: groups
|
|
1015
|
+
properties: properties, groups: groups, minimal: minimal
|
|
966
1016
|
)
|
|
967
1017
|
end
|
|
968
1018
|
|
|
@@ -44,6 +44,8 @@ module PostHog
|
|
|
44
44
|
# @param flag_definitions_loaded_at [Time, nil] When local flag definitions were loaded.
|
|
45
45
|
# @param errors_while_computing [Boolean] Whether the server reported errors while computing flags.
|
|
46
46
|
# @param quota_limited [Boolean] Whether feature flag evaluation was quota limited.
|
|
47
|
+
# @param minimal_flag_called_events [Boolean] Server-controlled gate for minimal
|
|
48
|
+
# `$feature_flag_called` events.
|
|
47
49
|
# @param accessed [Array<String>, Set<String>, nil] Flag keys already accessed by this snapshot.
|
|
48
50
|
def initialize(
|
|
49
51
|
host: nil,
|
|
@@ -56,6 +58,7 @@ module PostHog
|
|
|
56
58
|
flag_definitions_loaded_at: nil,
|
|
57
59
|
errors_while_computing: false,
|
|
58
60
|
quota_limited: false,
|
|
61
|
+
minimal_flag_called_events: false,
|
|
59
62
|
accessed: nil
|
|
60
63
|
)
|
|
61
64
|
@host = host
|
|
@@ -68,6 +71,7 @@ module PostHog
|
|
|
68
71
|
@flag_definitions_loaded_at = flag_definitions_loaded_at
|
|
69
72
|
@errors_while_computing = errors_while_computing
|
|
70
73
|
@quota_limited = quota_limited
|
|
74
|
+
@minimal_flag_called_events = minimal_flag_called_events == true
|
|
71
75
|
@accessed = Set.new(accessed || [])
|
|
72
76
|
end
|
|
73
77
|
|
|
@@ -188,13 +192,19 @@ module PostHog
|
|
|
188
192
|
errors << 'flag_missing' if flag.nil?
|
|
189
193
|
properties['$feature_flag_error'] = errors.join(',') unless errors.empty?
|
|
190
194
|
|
|
195
|
+
# Emit a minimal event only when the server gate is on and the flag is
|
|
196
|
+
# known to have no linked experiment. Any missing signal (unknown flag,
|
|
197
|
+
# has_experiment absent) fails safe to the full event.
|
|
198
|
+
minimal = @minimal_flag_called_events && !flag.nil? && flag.has_experiment == false
|
|
199
|
+
|
|
191
200
|
@host.capture_flag_called_event_if_needed.call(
|
|
192
201
|
distinct_id: @distinct_id,
|
|
193
202
|
key: key,
|
|
194
203
|
response: response,
|
|
195
204
|
properties: properties,
|
|
196
205
|
groups: @groups,
|
|
197
|
-
disable_geoip: @disable_geoip
|
|
206
|
+
disable_geoip: @disable_geoip,
|
|
207
|
+
minimal: minimal
|
|
198
208
|
)
|
|
199
209
|
end
|
|
200
210
|
|
|
@@ -210,6 +220,7 @@ module PostHog
|
|
|
210
220
|
flag_definitions_loaded_at: @flag_definitions_loaded_at,
|
|
211
221
|
errors_while_computing: @errors_while_computing,
|
|
212
222
|
quota_limited: @quota_limited,
|
|
223
|
+
minimal_flag_called_events: @minimal_flag_called_events,
|
|
213
224
|
accessed: @accessed.dup
|
|
214
225
|
)
|
|
215
226
|
end
|
|
@@ -72,7 +72,10 @@ module PostHog
|
|
|
72
72
|
@flags_etag = Concurrent::AtomicReference.new(nil)
|
|
73
73
|
@flag_definitions_loaded_at = Concurrent::AtomicReference.new(nil)
|
|
74
74
|
@async_load = async_load
|
|
75
|
-
|
|
75
|
+
# Server-controlled gate for minimal `$feature_flag_called` events, read
|
|
76
|
+
# from the top-level `minimal_flag_called_events` key of the local
|
|
77
|
+
# evaluation definitions payload. false when the server does not send it.
|
|
78
|
+
@minimal_flag_called_events = false
|
|
76
79
|
@flag_definition_cache_provider = flag_definition_cache_provider
|
|
77
80
|
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
|
|
78
81
|
|
|
@@ -111,7 +114,7 @@ module PostHog
|
|
|
111
114
|
@flag_definitions_loaded_at.value
|
|
112
115
|
end
|
|
113
116
|
|
|
114
|
-
attr_reader :feature_flags_by_key
|
|
117
|
+
attr_reader :feature_flags_by_key, :minimal_flag_called_events
|
|
115
118
|
|
|
116
119
|
def get_feature_variants(
|
|
117
120
|
distinct_id,
|
|
@@ -245,6 +248,11 @@ module PostHog
|
|
|
245
248
|
# evaluated flags carry it in the response metadata. nil when the server
|
|
246
249
|
# (an older deployment) does not report it.
|
|
247
250
|
has_experiment = feature_flag[:has_experiment] if flag_was_locally_evaluated
|
|
251
|
+
# Server-controlled gate for minimal `$feature_flag_called` events.
|
|
252
|
+
# Locally-evaluated flags read it from the definitions payload; remotely
|
|
253
|
+
# evaluated flags read it from the /flags response. nil when the signal
|
|
254
|
+
# is unavailable, which fails safe to the full event.
|
|
255
|
+
minimal_flag_called_events = @minimal_flag_called_events if flag_was_locally_evaluated
|
|
248
256
|
|
|
249
257
|
request_id = nil
|
|
250
258
|
evaluated_at = nil
|
|
@@ -279,6 +287,7 @@ module PostHog
|
|
|
279
287
|
|
|
280
288
|
flag_detail = flags_data[:flagDetails]&.[](key.to_sym)
|
|
281
289
|
has_experiment = flag_detail&.metadata&.has_experiment
|
|
290
|
+
minimal_flag_called_events = flags_data[:minimalFlagCalledEvents]
|
|
282
291
|
|
|
283
292
|
logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
|
|
284
293
|
rescue Timeout::Error => e
|
|
@@ -293,7 +302,8 @@ module PostHog
|
|
|
293
302
|
end
|
|
294
303
|
end
|
|
295
304
|
|
|
296
|
-
[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload, has_experiment
|
|
305
|
+
[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload, has_experiment,
|
|
306
|
+
minimal_flag_called_events]
|
|
297
307
|
end
|
|
298
308
|
|
|
299
309
|
def get_all_flags(
|
|
@@ -352,6 +362,7 @@ module PostHog
|
|
|
352
362
|
quota_limited = nil
|
|
353
363
|
status_code = nil
|
|
354
364
|
flag_details = nil
|
|
365
|
+
minimal_flag_called_events = nil
|
|
355
366
|
|
|
356
367
|
if fallback_to_server && !only_evaluate_locally
|
|
357
368
|
begin
|
|
@@ -367,6 +378,7 @@ module PostHog
|
|
|
367
378
|
|
|
368
379
|
request_id = flags_and_payloads[:requestId]
|
|
369
380
|
evaluated_at = flags_and_payloads[:evaluatedAt]
|
|
381
|
+
minimal_flag_called_events = flags_and_payloads[:minimalFlagCalledEvents]
|
|
370
382
|
|
|
371
383
|
# Check if feature_flags are quota limited
|
|
372
384
|
if quota_limited&.include?('feature_flags')
|
|
@@ -402,7 +414,8 @@ module PostHog
|
|
|
402
414
|
evaluatedAt: evaluated_at,
|
|
403
415
|
errorsWhileComputingFlags: errors_while_computing,
|
|
404
416
|
quotaLimited: quota_limited,
|
|
405
|
-
status: status_code
|
|
417
|
+
status: status_code,
|
|
418
|
+
minimalFlagCalledEvents: minimal_flag_called_events
|
|
406
419
|
}
|
|
407
420
|
end
|
|
408
421
|
|
|
@@ -1203,6 +1216,7 @@ module PostHog
|
|
|
1203
1216
|
@group_type_mapping = Concurrent::Hash.new
|
|
1204
1217
|
@cohorts = Concurrent::Hash.new
|
|
1205
1218
|
@flag_definitions_loaded_at.value = nil
|
|
1219
|
+
@minimal_flag_called_events = false
|
|
1206
1220
|
@loaded_flags_successfully_once.make_false
|
|
1207
1221
|
@quota_limited.make_true
|
|
1208
1222
|
return
|
|
@@ -1226,7 +1240,8 @@ module PostHog
|
|
|
1226
1240
|
data = {
|
|
1227
1241
|
flags: @feature_flags.to_a,
|
|
1228
1242
|
group_type_mapping: @group_type_mapping.to_h,
|
|
1229
|
-
cohorts: @cohorts.to_h
|
|
1243
|
+
cohorts: @cohorts.to_h,
|
|
1244
|
+
minimal_flag_called_events: @minimal_flag_called_events
|
|
1230
1245
|
}
|
|
1231
1246
|
@flag_definition_cache_provider.on_flag_definitions_received(data)
|
|
1232
1247
|
rescue StandardError => e
|
|
@@ -1238,6 +1253,7 @@ module PostHog
|
|
|
1238
1253
|
flags = get_by_symbol_or_string_key(data, 'flags') || []
|
|
1239
1254
|
group_type_mapping = get_by_symbol_or_string_key(data, 'group_type_mapping') || {}
|
|
1240
1255
|
cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
|
|
1256
|
+
minimal_flag_called_events = get_by_symbol_or_string_key(data, 'minimal_flag_called_events')
|
|
1241
1257
|
|
|
1242
1258
|
@feature_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
|
|
1243
1259
|
|
|
@@ -1249,6 +1265,7 @@ module PostHog
|
|
|
1249
1265
|
|
|
1250
1266
|
@group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
|
|
1251
1267
|
@cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
|
|
1268
|
+
@minimal_flag_called_events = minimal_flag_called_events == true
|
|
1252
1269
|
|
|
1253
1270
|
logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
|
|
1254
1271
|
@flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
|
|
@@ -14,9 +14,11 @@ module PostHog
|
|
|
14
14
|
#
|
|
15
15
|
# @!method flag_definitions
|
|
16
16
|
# Retrieve cached flag definitions. Return a Hash with +:flags+,
|
|
17
|
-
# +:group_type_mapping+,
|
|
18
|
-
# is empty. Returning +nil+ triggers an API
|
|
19
|
-
# loaded yet (emergency fallback).
|
|
17
|
+
# +:group_type_mapping+, +:cohorts+, and +:minimal_flag_called_events+
|
|
18
|
+
# keys, or +nil+ if the cache is empty. Returning +nil+ triggers an API
|
|
19
|
+
# fetch when no flags are loaded yet (emergency fallback). Providers
|
|
20
|
+
# written before +:minimal_flag_called_events+ existed continue to work;
|
|
21
|
+
# a missing key is treated as +false+.
|
|
20
22
|
# @return [Hash, nil]
|
|
21
23
|
#
|
|
22
24
|
# @!method should_fetch_flag_definitions?
|
|
@@ -27,9 +29,9 @@ module PostHog
|
|
|
27
29
|
#
|
|
28
30
|
# @!method on_flag_definitions_received(data)
|
|
29
31
|
# Called after successfully fetching new definitions from the API.
|
|
30
|
-
# +data+ is a Hash with +:flags+, +:group_type_mapping+,
|
|
31
|
-
# keys (plain Ruby types, not Concurrent::
|
|
32
|
-
# external cache.
|
|
32
|
+
# +data+ is a Hash with +:flags+, +:group_type_mapping+, +:cohorts+, and
|
|
33
|
+
# +:minimal_flag_called_events+ keys (plain Ruby types, not Concurrent::
|
|
34
|
+
# wrappers). Store it in your external cache.
|
|
33
35
|
# @param data [Hash]
|
|
34
36
|
# @return [void]
|
|
35
37
|
#
|
data/lib/posthog/version.rb
CHANGED