quonfig 1.4.1 → 1.6.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/CHANGELOG.md +19 -0
- data/README.md +63 -1
- data/lib/quonfig/client.rb +8 -34
- data/lib/quonfig/config_envelope.rb +16 -1
- data/lib/quonfig/config_loader.rb +61 -19
- data/lib/quonfig/evaluator.rb +25 -10
- data/lib/quonfig/http_connection.rb +12 -2
- data/lib/quonfig/options.rb +59 -5
- data/lib/quonfig/resolver.rb +57 -16
- data/lib/quonfig/sse_config_client.rb +8 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +14 -1
- data/lib/quonfig/telemetry/telemetry_reporter.rb +231 -115
- data/lib/quonfig/telemetry/transport_queue.rb +311 -0
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
+
require 'set'
|
|
4
5
|
|
|
5
6
|
module Quonfig
|
|
6
7
|
# Public-API resolver: looks up a config by key in a ConfigStore and runs
|
|
@@ -16,6 +17,7 @@ module Quonfig
|
|
|
16
17
|
# production read path (with config_loader, SSE updates, telemetry), see
|
|
17
18
|
# Quonfig::ConfigResolver — the two coexist during the JSON migration.
|
|
18
19
|
class Resolver
|
|
20
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
19
21
|
TRUE_VALUES = %w[true 1 t yes].freeze
|
|
20
22
|
# Prefix the eval-summary aggregator stamps onto redacted confidential
|
|
21
23
|
# values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
|
|
@@ -29,6 +31,9 @@ module Quonfig
|
|
|
29
31
|
def initialize(store, evaluator)
|
|
30
32
|
@store = store
|
|
31
33
|
@evaluator = evaluator
|
|
34
|
+
# Config keys already warned about a missing hash property (qfg-9dxb.8).
|
|
35
|
+
@hash_missing_warned = Set.new
|
|
36
|
+
@hash_missing_mutex = Mutex.new
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def raw(key)
|
|
@@ -50,7 +55,8 @@ module Quonfig
|
|
|
50
55
|
return nil if eval_result.nil?
|
|
51
56
|
|
|
52
57
|
weighted_index = nil
|
|
53
|
-
|
|
58
|
+
hash_missing = []
|
|
59
|
+
resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
|
|
54
60
|
weighted_index = idx
|
|
55
61
|
end
|
|
56
62
|
EvalResult.new(
|
|
@@ -58,7 +64,8 @@ module Quonfig
|
|
|
58
64
|
rule_index: eval_result.rule_index,
|
|
59
65
|
config: config,
|
|
60
66
|
weighted_value_index: weighted_index,
|
|
61
|
-
reportable_value: redacted_reportable_value(eval_result.value)
|
|
67
|
+
reportable_value: redacted_reportable_value(eval_result.value),
|
|
68
|
+
hash_property_missing: !hash_missing.empty?
|
|
62
69
|
)
|
|
63
70
|
end
|
|
64
71
|
|
|
@@ -68,32 +75,42 @@ module Quonfig
|
|
|
68
75
|
# - confidential + decryptWith → look up the key config, decrypt
|
|
69
76
|
# - everything else passes through unchanged
|
|
70
77
|
def resolve_value(value, config, context = nil, &on_weighted_index)
|
|
78
|
+
resolve_value_on_path(value, config, context, [], &on_weighted_index)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
82
|
+
# narrow; the real ConfigResolver still owns the production hot path.
|
|
83
|
+
def symbolize_json_names?
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# resolve_value plus +key_path+: the config keys already being resolved
|
|
90
|
+
# above this one through decryptWith, so a decryptWith cycle raises a
|
|
91
|
+
# DecryptionError instead of recursing until SystemStackError
|
|
92
|
+
# (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
|
|
93
|
+
# gets an entry for each weighted rollout whose hash property was missing
|
|
94
|
+
# from the context (qfg-9dxb.8).
|
|
95
|
+
def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
|
|
71
96
|
return nil if value.nil?
|
|
72
97
|
|
|
73
98
|
type = vget(value, :type, 'type')
|
|
74
99
|
|
|
75
100
|
return resolve_provided(value, config) if type == 'provided'
|
|
76
101
|
|
|
77
|
-
return resolve_weighted(value, config, context, &on_weighted_index) if type == 'weighted_values'
|
|
102
|
+
return resolve_weighted(value, config, context, key_path, hash_missing, &on_weighted_index) if type == 'weighted_values'
|
|
78
103
|
|
|
79
104
|
confidential = vget(value, :confidential, 'confidential')
|
|
80
105
|
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
81
106
|
if confidential && decrypt_with && !decrypt_with.to_s.empty?
|
|
82
107
|
return resolve_decryption(value, config, context,
|
|
83
|
-
decrypt_with)
|
|
108
|
+
decrypt_with, key_path)
|
|
84
109
|
end
|
|
85
110
|
|
|
86
111
|
value
|
|
87
112
|
end
|
|
88
113
|
|
|
89
|
-
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
90
|
-
# narrow; the real ConfigResolver still owns the production hot path.
|
|
91
|
-
def symbolize_json_names?
|
|
92
|
-
false
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
114
|
# If +value+ is confidential or has a decryptWith key, return the
|
|
98
115
|
# `*****<5-hex>` redacted string the eval-summary telemetry aggregator
|
|
99
116
|
# should ship in place of the resolved plaintext. The hash is computed
|
|
@@ -162,7 +179,12 @@ module Quonfig
|
|
|
162
179
|
# (or fall back to a per-call random) into [0,1), then walk the variant
|
|
163
180
|
# weights until cumulative weight >= bucket. Recurses through
|
|
164
181
|
# resolve_value so nested provided/encrypted variants work too.
|
|
165
|
-
|
|
182
|
+
#
|
|
183
|
+
# A hash property that is configured but missing from the context (or
|
|
184
|
+
# nil) hashes an empty value, the same bucket as a present "", and warns
|
|
185
|
+
# once per config key (qfg-9dxb.8). With no hash property configured the
|
|
186
|
+
# pick stays random per call.
|
|
187
|
+
def resolve_weighted(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
|
|
166
188
|
payload = vget(value, :value, 'value') || {}
|
|
167
189
|
weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
|
|
168
190
|
return value unless weighted.is_a?(Array) && !weighted.empty?
|
|
@@ -182,23 +204,42 @@ module Quonfig
|
|
|
182
204
|
end
|
|
183
205
|
|
|
184
206
|
cfg_key = config_key(config)
|
|
207
|
+
if hash_value.nil? && !hash_property.to_s.empty?
|
|
208
|
+
hash_value = ''
|
|
209
|
+
hash_missing&.push(true)
|
|
210
|
+
warn_hash_property_missing(cfg_key, hash_property)
|
|
211
|
+
end
|
|
185
212
|
picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
|
|
186
213
|
variant, index = picker.resolve
|
|
187
214
|
on_weighted_index&.call(index)
|
|
188
215
|
variant_value = vget(variant, :value, 'value')
|
|
189
|
-
|
|
216
|
+
resolve_value_on_path(variant_value, config, context, key_path, hash_missing, &on_weighted_index)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def warn_hash_property_missing(cfg_key, hash_property)
|
|
220
|
+
return unless @hash_missing_mutex.synchronize { @hash_missing_warned.add?(cfg_key) }
|
|
221
|
+
|
|
222
|
+
LOG.warn "quonfig: weighted rollout for \"#{cfg_key}\" hashes on \"#{hash_property}\" " \
|
|
223
|
+
'which is missing from context; hashing an empty value instead'
|
|
190
224
|
end
|
|
191
225
|
|
|
192
226
|
# Recursively resolve the decryption-key config (it may itself be a
|
|
193
227
|
# provided ENV_VAR), then AES-GCM decrypt the value with that key.
|
|
194
|
-
def resolve_decryption(value, config, context, decrypt_with)
|
|
228
|
+
def resolve_decryption(value, config, context, decrypt_with, key_path)
|
|
229
|
+
key_path += [config_key(config).to_s]
|
|
230
|
+
if key_path.include?(decrypt_with.to_s)
|
|
231
|
+
raise Quonfig::Errors::DecryptionError.new(
|
|
232
|
+
config_key(config), %(decryption key config "#{decrypt_with}" is part of a decryptWith cycle)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
195
236
|
key_cfg = @store.get(decrypt_with)
|
|
196
237
|
raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
|
|
197
238
|
|
|
198
239
|
key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
|
|
199
240
|
raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
|
|
200
241
|
|
|
201
|
-
resolved_key =
|
|
242
|
+
resolved_key = resolve_value_on_path(key_match.value, key_cfg, context, key_path)
|
|
202
243
|
secret_key = vget(resolved_key, :value, 'value').to_s
|
|
203
244
|
raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
|
|
204
245
|
|
|
@@ -604,6 +604,14 @@ module Quonfig
|
|
|
604
604
|
return nil
|
|
605
605
|
end
|
|
606
606
|
|
|
607
|
+
# qfg-9dxb.3: drop a JSON event that is not a config envelope (no meta
|
|
608
|
+
# object with a non-empty version) exactly like malformed JSON — it must
|
|
609
|
+
# never reach install and wipe an established client's keys.
|
|
610
|
+
unless Quonfig::ConfigEnvelope.wire_envelope?(parsed)
|
|
611
|
+
(@logger || LOG).error 'SSE Streaming Error: non-envelope event (missing meta.version); dropped'
|
|
612
|
+
return nil
|
|
613
|
+
end
|
|
614
|
+
|
|
607
615
|
envelope = Quonfig::ConfigEnvelope.new(
|
|
608
616
|
configs: parsed['configs'] || [],
|
|
609
617
|
meta: parsed['meta'] || {}
|
|
@@ -10,11 +10,16 @@ module Quonfig
|
|
|
10
10
|
# matching sdk-node and sdk-go. This is NOT the old Prefab protobuf.
|
|
11
11
|
class ExampleContextsAggregator
|
|
12
12
|
ONE_HOUR_SECONDS = 60 * 60
|
|
13
|
+
# Bound on the once-per-hour rate-limit map (P6 of the telemetry
|
|
14
|
+
# transport policy, qfg-y8je.8): a new key arriving when it is full
|
|
15
|
+
# prunes expired entries, and is not recorded if it is still full.
|
|
16
|
+
SEEN_CAP = 100_000
|
|
13
17
|
|
|
14
18
|
attr_reader :data, :cache
|
|
15
19
|
|
|
16
|
-
def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS)
|
|
20
|
+
def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS, seen_cap: SEEN_CAP)
|
|
17
21
|
@max_contexts = max_contexts
|
|
22
|
+
@seen_cap = seen_cap
|
|
18
23
|
@data = Concurrent::Array.new
|
|
19
24
|
@cache = Quonfig::RateLimitCache.new(rate_limit_seconds)
|
|
20
25
|
end
|
|
@@ -30,6 +35,7 @@ module Quonfig
|
|
|
30
35
|
return if key.nil? || key.empty?
|
|
31
36
|
|
|
32
37
|
return unless @data.size < @max_contexts && !@cache.fresh?(key)
|
|
38
|
+
return unless room_in_cache?
|
|
33
39
|
|
|
34
40
|
@cache.set(key)
|
|
35
41
|
@data.push([Quonfig::TimeHelpers.now_in_ms, context])
|
|
@@ -59,6 +65,13 @@ module Quonfig
|
|
|
59
65
|
|
|
60
66
|
private
|
|
61
67
|
|
|
68
|
+
def room_in_cache?
|
|
69
|
+
return true if @cache.data.size < @seen_cap
|
|
70
|
+
|
|
71
|
+
@cache.prune
|
|
72
|
+
@cache.data.size < @seen_cap
|
|
73
|
+
end
|
|
74
|
+
|
|
62
75
|
def grouped_key_for(context)
|
|
63
76
|
return context.grouped_key if context.respond_to?(:grouped_key)
|
|
64
77
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Quonfig
|
|
4
6
|
module Telemetry
|
|
5
|
-
# Owns the background thread that
|
|
6
|
-
#
|
|
7
|
-
#
|
|
7
|
+
# Owns the background thread that drains the aggregators once per tick and
|
|
8
|
+
# hands the serialized window to a TransportQueue, which retains failed
|
|
9
|
+
# batches byte-for-byte and resends them under the telemetry transport
|
|
10
|
+
# policy (qfg-y8je.8: 60s ticks, one POST in flight, 15s timeout / 5s
|
|
11
|
+
# connect, 30s floor after a failure, Retry-After up to 10 min,
|
|
12
|
+
# 5 batches / 2MB / 5 min retention, disable on 401/403/404; see
|
|
13
|
+
# TransportQueue).
|
|
8
14
|
#
|
|
9
15
|
# Wire shape matches api-telemetry's TelemetryEventsSchema:
|
|
10
16
|
#
|
|
@@ -24,16 +30,47 @@ module Quonfig
|
|
|
24
30
|
class TelemetryReporter
|
|
25
31
|
LOG = Quonfig::InternalLogger.new(self)
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
TELEMETRY_PATH = '/api/v1/telemetry/'
|
|
34
|
+
BODY_SNIPPET_BYTES = 1024
|
|
35
|
+
private_constant :BODY_SNIPPET_BYTES
|
|
36
|
+
|
|
37
|
+
# Build the aggregators the options enable and a reporter over them, or
|
|
38
|
+
# nil when every collector is off. Client#initialize_telemetry uses this;
|
|
39
|
+
# +clock+ is a test seam (defaults to the monotonic clock).
|
|
40
|
+
def self.build(options:, instance_hash:, failover_aggregator: nil, clock: nil)
|
|
41
|
+
shapes = (ContextShapeAggregator.new(max_shapes: options.collect_max_shapes) if options.collect_max_shapes.to_i.positive?)
|
|
42
|
+
examples = (ExampleContextsAggregator.new(max_contexts: options.collect_max_example_contexts) if options.collect_max_example_contexts.to_i.positive?)
|
|
43
|
+
summaries = (EvaluationSummariesAggregator.new(max_keys: options.collect_max_evaluation_summaries) if options.collect_max_evaluation_summaries.to_i.positive?)
|
|
44
|
+
return nil if shapes.nil? && examples.nil? && summaries.nil?
|
|
45
|
+
|
|
46
|
+
new(
|
|
47
|
+
options: options,
|
|
48
|
+
instance_hash: instance_hash,
|
|
49
|
+
context_shape_aggregator: shapes,
|
|
50
|
+
example_contexts_aggregator: examples,
|
|
51
|
+
evaluation_summaries_aggregator: summaries,
|
|
52
|
+
failover_aggregator: failover_aggregator,
|
|
53
|
+
clock: clock
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resolved transport settings (the +telemetry_*+ options and
|
|
58
|
+
# +collect_sync_interval+). +flush_interval_ms+ is nil when the interval
|
|
59
|
+
# is a callable.
|
|
60
|
+
attr_reader :config
|
|
29
61
|
|
|
62
|
+
# +sync_interval+ (seconds, or a callable returning seconds) defaults to
|
|
63
|
+
# +options.collect_sync_interval+ (60). +http_connection+ is a test seam:
|
|
64
|
+
# anything answering +post(path, body_string)+ with a response that has
|
|
65
|
+
# +status+.
|
|
30
66
|
def initialize(options:, instance_hash:,
|
|
31
67
|
context_shape_aggregator: nil,
|
|
32
68
|
example_contexts_aggregator: nil,
|
|
33
69
|
evaluation_summaries_aggregator: nil,
|
|
34
70
|
failover_aggregator: nil,
|
|
35
71
|
sync_interval: nil,
|
|
36
|
-
http_connection: nil
|
|
72
|
+
http_connection: nil,
|
|
73
|
+
clock: nil)
|
|
37
74
|
@options = options
|
|
38
75
|
@instance_hash = instance_hash
|
|
39
76
|
@sdk_key = options.sdk_key
|
|
@@ -47,8 +84,31 @@ module Quonfig
|
|
|
47
84
|
# aggregator at the failover call sites; the reporter only drains it.
|
|
48
85
|
@failover_aggregator = failover_aggregator
|
|
49
86
|
@http_connection = http_connection
|
|
50
|
-
@sync_interval =
|
|
51
|
-
@
|
|
87
|
+
@sync_interval = sync_interval.nil? ? options.collect_sync_interval : sync_interval
|
|
88
|
+
@config = {
|
|
89
|
+
flush_interval_ms: @sync_interval.is_a?(Numeric) ? (@sync_interval * 1000).to_i : nil,
|
|
90
|
+
timeout_ms: options.telemetry_timeout_ms,
|
|
91
|
+
connect_timeout_ms: options.telemetry_connect_timeout_ms,
|
|
92
|
+
max_retained_batches: options.telemetry_max_retained_batches,
|
|
93
|
+
max_retained_bytes: options.telemetry_max_retained_bytes,
|
|
94
|
+
max_retained_age_ms: options.telemetry_max_retained_age_ms
|
|
95
|
+
}.freeze
|
|
96
|
+
@queue = TransportQueue.new(
|
|
97
|
+
sender: method(:post_batch),
|
|
98
|
+
telemetry_url: "#{@telemetry_destination}#{TELEMETRY_PATH}",
|
|
99
|
+
timeout_ms: @config[:timeout_ms],
|
|
100
|
+
max_retained_batches: @config[:max_retained_batches],
|
|
101
|
+
max_retained_bytes: @config[:max_retained_bytes],
|
|
102
|
+
max_retained_age_ms: @config[:max_retained_age_ms],
|
|
103
|
+
clock: clock || MonotonicClock,
|
|
104
|
+
on_disabled: method(:on_disabled)
|
|
105
|
+
)
|
|
106
|
+
# Held for a whole tick (serialize + drain), so at most one POST is in
|
|
107
|
+
# flight (P2); a tick that finds it held is skipped.
|
|
108
|
+
@tick_mutex = Mutex.new
|
|
109
|
+
@state_mutex = Mutex.new
|
|
110
|
+
@wake = ConditionVariable.new
|
|
111
|
+
@closed = false
|
|
52
112
|
@thread = nil
|
|
53
113
|
@at_exit_registered = false
|
|
54
114
|
# Set on #start. Everything that can EMIT is gated on it so a forked
|
|
@@ -69,97 +129,104 @@ module Quonfig
|
|
|
69
129
|
# summaries are recorded separately via
|
|
70
130
|
# +record_evaluation(...)+ since they require the evaluation result.
|
|
71
131
|
def record(context)
|
|
72
|
-
return if context.nil?
|
|
132
|
+
return if context.nil? || @queue.disabled?
|
|
73
133
|
|
|
74
134
|
@context_shape_aggregator&.push(context)
|
|
75
135
|
@example_contexts_aggregator&.record(context)
|
|
76
136
|
end
|
|
77
137
|
|
|
78
138
|
def record_evaluation(**kwargs)
|
|
139
|
+
return if @queue.disabled?
|
|
140
|
+
|
|
79
141
|
@evaluation_summaries_aggregator&.record(**kwargs)
|
|
80
142
|
end
|
|
81
143
|
|
|
82
144
|
def start
|
|
83
145
|
return if @thread&.alive?
|
|
84
146
|
return unless enabled?
|
|
147
|
+
return if @closed || @queue.disabled?
|
|
85
148
|
|
|
86
149
|
# Claim ownership for THIS process. fork(2) copies the reporter, its
|
|
87
150
|
# aggregators, and the process-wide at_exit closure registered below;
|
|
88
151
|
# the pid recorded here is what lets the copy know it is not the
|
|
89
152
|
# owner and must stay silent (qfg-lv4n.1, dd-trace-rb's pattern).
|
|
90
153
|
@owner_pid = Process.pid
|
|
91
|
-
@stopped.make_false
|
|
92
154
|
register_at_exit_handler
|
|
93
155
|
@thread = Thread.new do
|
|
94
156
|
Thread.current.name = 'quonfig-telemetry-reporter'
|
|
95
157
|
LOG.debug "Telemetry reporter started instance_hash=#{@instance_hash} destination=#{@telemetry_destination}"
|
|
96
|
-
|
|
97
|
-
until @stopped.true?
|
|
98
|
-
begin
|
|
99
|
-
sleep_duration = @sync_interval.call
|
|
100
|
-
slept = 0.0
|
|
101
|
-
step = 0.5
|
|
102
|
-
while slept < sleep_duration && !@stopped.true?
|
|
103
|
-
sleep([step, sleep_duration - slept].min)
|
|
104
|
-
slept += step
|
|
105
|
-
end
|
|
106
|
-
break if @stopped.true?
|
|
107
|
-
|
|
108
|
-
sync
|
|
109
|
-
rescue StandardError => e
|
|
110
|
-
LOG.warn "[quonfig] Telemetry reporter error: #{e.class}: #{e.message}"
|
|
111
|
-
end
|
|
112
|
-
end
|
|
158
|
+
run_loop
|
|
113
159
|
end
|
|
114
160
|
end
|
|
115
161
|
|
|
116
|
-
|
|
117
|
-
|
|
162
|
+
# One tick of the contract's model: skip if closed, disabled or a POST is
|
|
163
|
+
# in flight (P2; the live window keeps aggregating); expire aged batches;
|
|
164
|
+
# skip if the 30s floor or Retry-After has not elapsed; serialize the
|
|
165
|
+
# live window once and append it; drain oldest-first.
|
|
166
|
+
def tick
|
|
167
|
+
return if foreign_process?('tick')
|
|
168
|
+
return unless @tick_mutex.try_lock
|
|
118
169
|
|
|
119
|
-
@stopped.make_true
|
|
120
|
-
thread = @thread
|
|
121
|
-
@thread = nil
|
|
122
|
-
thread&.wakeup if thread&.alive?
|
|
123
|
-
# Final drain attempt on stop so tests / short-lived processes
|
|
124
|
-
# don't silently drop pending telemetry.
|
|
125
170
|
begin
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
171
|
+
run_tick
|
|
172
|
+
ensure
|
|
173
|
+
@tick_mutex.unlock
|
|
129
174
|
end
|
|
130
175
|
end
|
|
131
176
|
|
|
132
|
-
#
|
|
133
|
-
#
|
|
177
|
+
# Send the live window now. Waits for an in-flight POST first (bounded by
|
|
178
|
+
# the request timeout), then runs a tick, so after a failure it respects
|
|
179
|
+
# the 30s floor and Retry-After. Never raises.
|
|
134
180
|
#
|
|
135
181
|
# Silent in any process other than the one that started the reporter:
|
|
136
182
|
# after a fork the child holds a full copy of the PARENT's un-flushed
|
|
137
183
|
# window, and the parent is still going to flush it itself.
|
|
138
|
-
def
|
|
184
|
+
def flush
|
|
139
185
|
return if foreign_process?('sync')
|
|
140
186
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
187
|
+
@tick_mutex.synchronize { run_tick }
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
LOG.debug "Telemetry flush failed: #{e.class}: #{e.message}"
|
|
190
|
+
end
|
|
191
|
+
alias sync flush
|
|
192
|
+
|
|
193
|
+
# Shutdown (P8): stop the reporter thread (aborting an in-flight POST),
|
|
194
|
+
# then give the live window one POST with a 5s deadline. The retained
|
|
195
|
+
# queue is not drained. Idempotent; never raises; never blocks exit for
|
|
196
|
+
# longer than that deadline.
|
|
197
|
+
def close
|
|
198
|
+
return if foreign_process?('close')
|
|
199
|
+
|
|
200
|
+
thread = @state_mutex.synchronize do
|
|
201
|
+
return if @closed
|
|
202
|
+
|
|
203
|
+
@closed = true
|
|
204
|
+
current = @thread
|
|
205
|
+
@thread = nil
|
|
206
|
+
current
|
|
153
207
|
end
|
|
208
|
+
stop_thread(thread)
|
|
209
|
+
return if @queue.disabled?
|
|
154
210
|
|
|
155
|
-
|
|
211
|
+
body = serialize_window
|
|
212
|
+
return if body.nil?
|
|
156
213
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
214
|
+
@queue.send_final(body, [TransportQueue::SHUTDOWN_FLUSH_DEADLINE_MS, @config[:timeout_ms]].min)
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
LOG.debug "Telemetry close failed: #{e.class}: #{e.message}"
|
|
217
|
+
end
|
|
218
|
+
alias stop close
|
|
219
|
+
|
|
220
|
+
# Test-visible state (the contract's retained_count / retained_bytes /
|
|
221
|
+
# telemetry_enabled).
|
|
222
|
+
def debug_state
|
|
223
|
+
{
|
|
224
|
+
retained_count: @queue.retained_count,
|
|
225
|
+
retained_bytes: @queue.retained_bytes,
|
|
226
|
+
enabled: !@queue.disabled?,
|
|
227
|
+
in_flight: @queue.in_flight?,
|
|
228
|
+
thread_alive: @thread&.alive? || false
|
|
160
229
|
}
|
|
161
|
-
|
|
162
|
-
post(payload)
|
|
163
230
|
end
|
|
164
231
|
|
|
165
232
|
# Visible for tests.
|
|
@@ -180,7 +247,7 @@ module Quonfig
|
|
|
180
247
|
# not exist in the child, and the HTTP connection's fd is shared with
|
|
181
248
|
# the parent.
|
|
182
249
|
def discard_inherited!
|
|
183
|
-
@
|
|
250
|
+
@closed = true
|
|
184
251
|
@thread = nil
|
|
185
252
|
@context_shape_aggregator = nil
|
|
186
253
|
@example_contexts_aggregator = nil
|
|
@@ -190,6 +257,106 @@ module Quonfig
|
|
|
190
257
|
|
|
191
258
|
private
|
|
192
259
|
|
|
260
|
+
def run_tick
|
|
261
|
+
return if @closed || @queue.disabled?
|
|
262
|
+
|
|
263
|
+
@queue.expire
|
|
264
|
+
return unless @queue.send_allowed?
|
|
265
|
+
|
|
266
|
+
body = serialize_window
|
|
267
|
+
@queue.append(body) if body
|
|
268
|
+
@queue.drain
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Fixed cadence: tick k fires at k * interval regardless of how long a
|
|
272
|
+
# drain takes. The interval never grows (the old exponential backoff,
|
|
273
|
+
# which grew even on success, is gone: P4).
|
|
274
|
+
def run_loop
|
|
275
|
+
next_at = MonotonicClock.now_ms + next_interval_ms
|
|
276
|
+
until loop_done?
|
|
277
|
+
wait_ms = next_at - MonotonicClock.now_ms
|
|
278
|
+
if wait_ms.positive?
|
|
279
|
+
@state_mutex.synchronize { @wake.wait(@state_mutex, wait_ms / 1000.0) unless loop_done? }
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
next_at += next_interval_ms
|
|
283
|
+
next_at = MonotonicClock.now_ms + next_interval_ms if next_at <= MonotonicClock.now_ms
|
|
284
|
+
begin
|
|
285
|
+
tick
|
|
286
|
+
rescue StandardError => e
|
|
287
|
+
LOG.debug "Telemetry tick failed: #{e.class}: #{e.message}"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def loop_done?
|
|
293
|
+
@closed || @queue.disabled?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def next_interval_ms
|
|
297
|
+
seconds = @sync_interval.respond_to?(:call) ? @sync_interval.call : @sync_interval
|
|
298
|
+
[(seconds.to_f * 1000).to_i, 1].max
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def wake_thread
|
|
302
|
+
@state_mutex.synchronize { @wake.broadcast }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Stop the reporter thread. A thread parked in its wait exits on the
|
|
306
|
+
# broadcast; one still busy (a POST in flight) is killed, which aborts
|
|
307
|
+
# the POST like sdk-node's close() does. The aborted batch is never resent.
|
|
308
|
+
def stop_thread(thread)
|
|
309
|
+
return if thread.nil? || thread == Thread.current || !thread.alive?
|
|
310
|
+
|
|
311
|
+
wake_thread
|
|
312
|
+
thread.kill unless thread.join(0.1)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def on_disabled
|
|
316
|
+
# Nothing aggregates for a dead endpoint: drop what the window holds.
|
|
317
|
+
serialize_window
|
|
318
|
+
wake_thread
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Drain the aggregators into one serialized payload. This is the only
|
|
322
|
+
# serialization: the queue stores and resends these exact bytes (P5, P9).
|
|
323
|
+
def serialize_window
|
|
324
|
+
events = [
|
|
325
|
+
@evaluation_summaries_aggregator&.drain_event,
|
|
326
|
+
@context_shape_aggregator&.drain_event,
|
|
327
|
+
@example_contexts_aggregator&.drain_event,
|
|
328
|
+
# nil unless the window saw failover activity.
|
|
329
|
+
@failover_aggregator&.drain_event
|
|
330
|
+
].compact
|
|
331
|
+
return nil if events.empty?
|
|
332
|
+
|
|
333
|
+
JSON.generate('instanceHash' => @instance_hash, 'events' => events)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# The TransportQueue sender: one POST of +body+ verbatim.
|
|
337
|
+
def post_batch(body, timeout_ms)
|
|
338
|
+
response = telemetry_connection(timeout_ms).post(TELEMETRY_PATH, body)
|
|
339
|
+
headers = response.respond_to?(:headers) ? response.headers : nil
|
|
340
|
+
snippet = response.respond_to?(:body) ? response.body.to_s.byteslice(0, BODY_SNIPPET_BYTES) : ''
|
|
341
|
+
TransportQueue::Result.new(
|
|
342
|
+
status: response.status.to_i,
|
|
343
|
+
retry_after: headers && headers['retry-after'],
|
|
344
|
+
body_snippet: snippet
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# A connection bounded by +timeout_ms+ overall and the connect timeout
|
|
349
|
+
# (never longer than +timeout_ms+) for TCP connect + TLS (P1).
|
|
350
|
+
def telemetry_connection(timeout_ms)
|
|
351
|
+
return @http_connection if @http_connection
|
|
352
|
+
|
|
353
|
+
Quonfig::HttpConnection.new(
|
|
354
|
+
@telemetry_destination, @sdk_key,
|
|
355
|
+
timeout_ms: timeout_ms,
|
|
356
|
+
open_timeout_ms: [@config[:connect_timeout_ms], timeout_ms].min
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
193
360
|
# True when this reporter belongs to a different process — i.e. we are
|
|
194
361
|
# a fork(2) copy. Never true before #start (nothing has been claimed,
|
|
195
362
|
# and nothing was registered at_exit either).
|
|
@@ -204,7 +371,7 @@ module Quonfig
|
|
|
204
371
|
|
|
205
372
|
# Rails / Passenger / Puma workers often terminate via SIGTERM without
|
|
206
373
|
# a chance to call Client#stop. Register a Kernel.at_exit hook on
|
|
207
|
-
# first start so the
|
|
374
|
+
# first start so the live window still gets its one final flush.
|
|
208
375
|
def register_at_exit_handler
|
|
209
376
|
return if @at_exit_registered
|
|
210
377
|
|
|
@@ -212,62 +379,11 @@ module Quonfig
|
|
|
212
379
|
@at_exit_registered = true
|
|
213
380
|
end
|
|
214
381
|
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
# can't hang process exit.
|
|
218
|
-
AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS = 1.0
|
|
219
|
-
private_constant :AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS
|
|
220
|
-
|
|
221
|
-
# Idempotent final drain. Safe to call after #stop has already
|
|
222
|
-
# drained: aggregators return nil when empty and #sync becomes a
|
|
223
|
-
# no-op. Bounded so a stuck reporter thread or dead telemetry
|
|
382
|
+
# Idempotent final flush (#close). Safe after #stop: a second close is a
|
|
383
|
+
# no-op. Bounded by the 5s shutdown deadline, so a dead telemetry
|
|
224
384
|
# endpoint can't hang process exit.
|
|
225
385
|
def final_drain_on_exit
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
@stopped.make_true
|
|
229
|
-
thread = @thread
|
|
230
|
-
@thread = nil
|
|
231
|
-
if thread&.alive?
|
|
232
|
-
thread.wakeup
|
|
233
|
-
thread.join(AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS)
|
|
234
|
-
end
|
|
235
|
-
sync
|
|
236
|
-
rescue StandardError => e
|
|
237
|
-
LOG.debug "[quonfig] at_exit telemetry drain failed: #{e.class}: #{e.message}"
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def post(payload)
|
|
241
|
-
conn = http_connection
|
|
242
|
-
return if conn.nil?
|
|
243
|
-
|
|
244
|
-
response = conn.post('/api/v1/telemetry/', payload)
|
|
245
|
-
status = response.respond_to?(:status) ? response.status : nil
|
|
246
|
-
if status && status >= 400
|
|
247
|
-
LOG.warn "[quonfig] Telemetry POST failed: #{status}"
|
|
248
|
-
else
|
|
249
|
-
LOG.debug "[quonfig] Telemetry POST ok: events=#{payload['events'].size}"
|
|
250
|
-
end
|
|
251
|
-
response
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def http_connection
|
|
255
|
-
@http_connection ||= begin
|
|
256
|
-
return nil if @sdk_key.nil? || @telemetry_destination.nil?
|
|
257
|
-
|
|
258
|
-
Quonfig::HttpConnection.new(@telemetry_destination, @sdk_key)
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def calculate_sync_interval(sync_interval)
|
|
263
|
-
return proc { sync_interval } if sync_interval.is_a?(Numeric)
|
|
264
|
-
return sync_interval if sync_interval.respond_to?(:call)
|
|
265
|
-
|
|
266
|
-
Quonfig::ExponentialBackoff.new(
|
|
267
|
-
initial_delay: DEFAULT_INITIAL_DELAY_SECONDS,
|
|
268
|
-
max_delay: DEFAULT_MAX_DELAY_SECONDS,
|
|
269
|
-
multiplier: 1.5
|
|
270
|
-
)
|
|
386
|
+
close
|
|
271
387
|
end
|
|
272
388
|
end
|
|
273
389
|
end
|