quonfig 0.0.6 → 0.0.8
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 +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +109 -2
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/errors/decryption_error.rb +20 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
- data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
- data/lib/quonfig/errors/missing_environment_error.rb +18 -0
- data/lib/quonfig/evaluator.rb +64 -2
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/resolver.rb +187 -2
- data/lib/quonfig/stdlib_formatter.rb +95 -0
- data/lib/quonfig/telemetry/context_shape.rb +33 -0
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
- data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
- data/lib/quonfig.rb +8 -0
- data/quonfig.gemspec +20 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_enabled.rb +157 -463
- data/test/integration/test_enabled_with_contexts.rb +19 -49
- data/test/integration/test_get.rb +43 -131
- data/test/integration/test_get_feature_flag.rb +7 -13
- data/test/integration/test_get_or_raise.rb +19 -45
- data/test/integration/test_get_weighted_values.rb +9 -4
- data/test/integration/test_helpers.rb +499 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +63 -21
- data/test/test_client_telemetry.rb +132 -0
- data/test/test_context.rb +4 -1
- data/test/test_context_shape.rb +37 -0
- data/test/test_context_shape_aggregator.rb +126 -0
- data/test/test_datadir.rb +6 -2
- data/test/test_evaluation_summaries_aggregator.rb +180 -0
- data/test/test_example_contexts_aggregator.rb +119 -0
- data/test/test_http_connection.rb +1 -1
- data/test/test_resolver.rb +149 -2
- data/test/test_should_log.rb +186 -0
- data/test/test_stdlib_formatter.rb +195 -0
- data/test/test_telemetry_reporter.rb +209 -0
- metadata +19 -3
- data/scripts/generate_integration_tests.rb +0 -362
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Adapter that plugs Quonfig's dynamic log-level evaluation into Ruby's
|
|
5
|
+
# built-in +::Logger+. The formatter is a proc with the stdlib Logger
|
|
6
|
+
# contract — +(severity, datetime, progname, msg) -> String+ — that calls
|
|
7
|
+
# +client.should_log?+ before each line and returns either a formatted
|
|
8
|
+
# record (emit) or an empty string (suppress).
|
|
9
|
+
#
|
|
10
|
+
# Returning an empty string is how you "drop" a log line through the
|
|
11
|
+
# formatter hook: +::Logger+ writes exactly what the formatter returns,
|
|
12
|
+
# so an empty string produces zero visible output. We deliberately do NOT
|
|
13
|
+
# return +nil+ — stdlib Logger would still call +.to_s+ (→ "") on some
|
|
14
|
+
# Ruby versions but would emit "\n" from +::Logger::Formatter+ subclasses
|
|
15
|
+
# that pre-wrap the message. Empty string is the portable zero-output
|
|
16
|
+
# sentinel.
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# client = Quonfig::Client.new(logger_key: 'log-level.my-app')
|
|
20
|
+
# logger = ::Logger.new($stdout)
|
|
21
|
+
# logger.formatter = client.stdlib_formatter # progname wins
|
|
22
|
+
# # or
|
|
23
|
+
# logger.formatter = client.stdlib_formatter(logger_name: 'MyApp::Svc')
|
|
24
|
+
#
|
|
25
|
+
# +logger_name+ (optional) is the fallback when +progname+ is nil / the
|
|
26
|
+
# caller doesn't pass one to the individual log call. If both are set,
|
|
27
|
+
# +logger_name+ wins — matching ReforgeHQ's stdlib_formatter semantics.
|
|
28
|
+
#
|
|
29
|
+
# Level mapping (stdlib severity string → quonfig level symbol):
|
|
30
|
+
#
|
|
31
|
+
# "DEBUG" -> :debug
|
|
32
|
+
# "INFO" -> :info
|
|
33
|
+
# "WARN" -> :warn
|
|
34
|
+
# "ERROR" -> :error
|
|
35
|
+
# "FATAL" -> :fatal
|
|
36
|
+
# "ANY" -> :fatal (Logger::UNKNOWN — treat as top severity)
|
|
37
|
+
# other -> :info (defensive — unknown labels don't silently drop)
|
|
38
|
+
#
|
|
39
|
+
# No normalization is applied to +progname+ / +logger_name+; they are
|
|
40
|
+
# passed verbatim into +quonfig-sdk-logging.key+ so customer matching
|
|
41
|
+
# rules can target exact class names (e.g. +PROP_STARTS_WITH_ONE_OF
|
|
42
|
+
# "MyApp::Services::"+). Parallels the SemanticLoggerFilter.
|
|
43
|
+
module StdlibFormatter
|
|
44
|
+
# Ruby stdlib Logger severity strings → quonfig level symbols. Covers
|
|
45
|
+
# every label the stdlib actually emits.
|
|
46
|
+
SEVERITY_TO_LEVEL = {
|
|
47
|
+
'DEBUG' => :debug,
|
|
48
|
+
'INFO' => :info,
|
|
49
|
+
'WARN' => :warn,
|
|
50
|
+
'ERROR' => :error,
|
|
51
|
+
'FATAL' => :fatal,
|
|
52
|
+
'ANY' => :fatal # Logger::UNKNOWN formats as "ANY"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Build a formatter Proc. Exposed on +Quonfig::Client#stdlib_formatter+;
|
|
56
|
+
# callers should prefer the client helper.
|
|
57
|
+
#
|
|
58
|
+
# @param client [Quonfig::Client] the client whose +should_log?+ gates output.
|
|
59
|
+
# @param logger_name [String, nil] fallback logger identifier when the
|
|
60
|
+
# Logger call-site doesn't supply a progname.
|
|
61
|
+
# @return [Proc] a (severity, datetime, progname, msg) → String proc.
|
|
62
|
+
def self.build(client, logger_name: nil)
|
|
63
|
+
unless client.logger_key
|
|
64
|
+
raise Quonfig::Error,
|
|
65
|
+
'logger_key must be set at init to use stdlib_formatter. ' \
|
|
66
|
+
'Pass `logger_key:` to Quonfig::Options.new, or call ' \
|
|
67
|
+
'semantic_logger_filter(config_key:) / get(config_key) directly.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Arity MUST be 4 — ::Logger invokes the formatter with exactly that
|
|
71
|
+
# signature. Declared explicitly (not *args) so arity is 4, matching
|
|
72
|
+
# ::Logger::Formatter#call.
|
|
73
|
+
proc do |severity, datetime, progname, msg|
|
|
74
|
+
path = logger_name || progname
|
|
75
|
+
level = SEVERITY_TO_LEVEL[severity.to_s.upcase] || :info
|
|
76
|
+
|
|
77
|
+
if client.should_log?(logger_path: path, desired_level: level)
|
|
78
|
+
format_record(severity, datetime, progname, msg)
|
|
79
|
+
else
|
|
80
|
+
''
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Default record formatter. Matches ::Logger::Formatter's general shape
|
|
86
|
+
# ("I, [timestamp pid] SEVERITY -- progname: message") but without the
|
|
87
|
+
# process-id first-letter noise so output is readable in tests and
|
|
88
|
+
# modern dev logs. Callers who want a different format can wrap the
|
|
89
|
+
# gated proc with their own renderer.
|
|
90
|
+
def self.format_record(severity, datetime, progname, msg)
|
|
91
|
+
ts = datetime.respond_to?(:strftime) ? datetime.strftime('%Y-%m-%dT%H:%M:%S.%6N') : datetime.to_s
|
|
92
|
+
"[#{ts}] #{severity} -- #{progname}: #{msg}\n"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Maps a context property value to the numeric field-type code used by
|
|
6
|
+
# api-telemetry. The numbers match the codes used by sdk-node and sdk-go
|
|
7
|
+
# (and historically the Prefab proto ConfigValue oneof):
|
|
8
|
+
#
|
|
9
|
+
# 1 = integer
|
|
10
|
+
# 2 = string
|
|
11
|
+
# 4 = double (float)
|
|
12
|
+
# 5 = boolean
|
|
13
|
+
# 10 = string list (array)
|
|
14
|
+
class ContextShape
|
|
15
|
+
MAPPING = {
|
|
16
|
+
Integer => 1,
|
|
17
|
+
String => 2,
|
|
18
|
+
Float => 4,
|
|
19
|
+
TrueClass => 5,
|
|
20
|
+
FalseClass => 5,
|
|
21
|
+
Array => 10
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# We default to 2 (String) for unknown types — criteria evaluation
|
|
25
|
+
# treats them as strings via #to_s.
|
|
26
|
+
DEFAULT = MAPPING[String]
|
|
27
|
+
|
|
28
|
+
def self.field_type_number(value)
|
|
29
|
+
MAPPING.fetch(value.class, DEFAULT)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Aggregates the set of context shapes observed during config
|
|
6
|
+
# evaluation. Each unique (context-name, property, type) tuple is
|
|
7
|
+
# stored once. On sync, the set is folded into a hash grouped by
|
|
8
|
+
# context name and emitted as api-telemetry's `contextShapes` event.
|
|
9
|
+
#
|
|
10
|
+
# Matches the sdk-node/sdk-go JSON wire format — NOT the old
|
|
11
|
+
# Prefab protobuf serialization.
|
|
12
|
+
class ContextShapeAggregator
|
|
13
|
+
attr_reader :data
|
|
14
|
+
|
|
15
|
+
def initialize(max_shapes:)
|
|
16
|
+
@max_shapes = max_shapes
|
|
17
|
+
@data = Concurrent::Set.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Record every property of every named context in +context+.
|
|
21
|
+
# +context+ may be a Quonfig::Context or a bare Hash
|
|
22
|
+
# ({ 'user' => { 'key' => ..., 'email' => ... }, ... }).
|
|
23
|
+
def push(context)
|
|
24
|
+
return if @max_shapes <= 0
|
|
25
|
+
return if context.nil?
|
|
26
|
+
return if @data.size >= @max_shapes
|
|
27
|
+
|
|
28
|
+
each_named_context(context) do |name, hash|
|
|
29
|
+
next unless hash.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
hash.each_pair do |key, value|
|
|
32
|
+
next if @data.size >= @max_shapes
|
|
33
|
+
|
|
34
|
+
@data.add [name.to_s, key.to_s, Quonfig::Telemetry::ContextShape.field_type_number(value)]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fold the raw tuples into { name => { key => type, ... }, ... }.
|
|
40
|
+
# Clears the underlying set.
|
|
41
|
+
def prepare_data
|
|
42
|
+
duped = @data.dup
|
|
43
|
+
@data.clear
|
|
44
|
+
|
|
45
|
+
duped.inject({}) do |acc, (name, key, type)|
|
|
46
|
+
acc[name] ||= {}
|
|
47
|
+
acc[name][key] = type
|
|
48
|
+
acc
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Drain accumulated shapes into a single telemetry event payload,
|
|
53
|
+
# matching api-telemetry's ContextShapesSchema. Returns +nil+ when
|
|
54
|
+
# there is nothing to ship — the reporter should skip empty events.
|
|
55
|
+
def drain_event
|
|
56
|
+
return nil if @data.size.zero?
|
|
57
|
+
|
|
58
|
+
shapes = prepare_data.map do |name, field_types|
|
|
59
|
+
{ 'name' => name, 'fieldTypes' => field_types }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
{ 'contextShapes' => { 'shapes' => shapes } }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def each_named_context(context)
|
|
68
|
+
if context.respond_to?(:contexts)
|
|
69
|
+
# Quonfig::Context — each_pair yields (name, NamedContext)
|
|
70
|
+
context.contexts.each_pair do |name, named|
|
|
71
|
+
yield name, (named.respond_to?(:to_h) ? named.to_h : named)
|
|
72
|
+
end
|
|
73
|
+
elsif context.is_a?(Hash)
|
|
74
|
+
context.each_pair do |name, values|
|
|
75
|
+
values = { name.to_s => values } unless values.is_a?(Hash)
|
|
76
|
+
yield name, values
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Accumulates per-evaluation counts grouped by (config_key, config_type),
|
|
6
|
+
# with one counter per unique (config_id, conditional_value_index,
|
|
7
|
+
# weighted_value_index, selected_value). Emits api-telemetry's
|
|
8
|
+
# `summaries` event — JSON wire format matching sdk-node and sdk-go.
|
|
9
|
+
#
|
|
10
|
+
# Ported from ReforgeHQ/sdk-ruby evaluation_summary_aggregator.rb and
|
|
11
|
+
# adapted to the JSON wire format (EvaluationSummariesSchema in
|
|
12
|
+
# api-telemetry/src/telemetry-schemas.ts).
|
|
13
|
+
class EvaluationSummariesAggregator
|
|
14
|
+
attr_reader :data
|
|
15
|
+
|
|
16
|
+
def initialize(max_keys:)
|
|
17
|
+
@max_keys = max_keys
|
|
18
|
+
@data = Concurrent::Hash.new
|
|
19
|
+
@start_at_ms = nil
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Record a single evaluation.
|
|
24
|
+
#
|
|
25
|
+
# @param config_id [String]
|
|
26
|
+
# @param config_key [String]
|
|
27
|
+
# @param config_type [String, nil] "config", "feature_flag", etc.
|
|
28
|
+
# "log_level" evaluations are intentionally dropped (they're high
|
|
29
|
+
# volume and not useful for usage analytics).
|
|
30
|
+
# @param conditional_value_index [Integer] rule index
|
|
31
|
+
# @param weighted_value_index [Integer, nil]
|
|
32
|
+
# @param selected_value [Object] the unwrapped evaluated value
|
|
33
|
+
# @param reason [Integer] wire reason code (see Quonfig::Reason::WIRE_*)
|
|
34
|
+
def record(config_id:, config_key:, config_type:,
|
|
35
|
+
conditional_value_index:, weighted_value_index: nil,
|
|
36
|
+
selected_value: nil, reason: 0)
|
|
37
|
+
return if @max_keys <= 0
|
|
38
|
+
return if config_type == 'log_level'
|
|
39
|
+
|
|
40
|
+
group_key = [config_key, config_type]
|
|
41
|
+
counter_key = [config_id, conditional_value_index, weighted_value_index, selected_value]
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
unless @data.key?(group_key)
|
|
45
|
+
return if @data.size >= @max_keys
|
|
46
|
+
|
|
47
|
+
@data[group_key] = {}
|
|
48
|
+
end
|
|
49
|
+
@start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
|
|
50
|
+
|
|
51
|
+
bucket = @data[group_key]
|
|
52
|
+
bucket[counter_key] ||= { count: 0, reason: reason }
|
|
53
|
+
bucket[counter_key][:count] += 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Drain accumulated summaries into a single telemetry event payload.
|
|
58
|
+
# Returns +nil+ when there is nothing to ship.
|
|
59
|
+
def drain_event
|
|
60
|
+
snapshot = nil
|
|
61
|
+
start_at = nil
|
|
62
|
+
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
return nil if @data.empty?
|
|
65
|
+
|
|
66
|
+
snapshot = @data
|
|
67
|
+
start_at = @start_at_ms
|
|
68
|
+
@data = Concurrent::Hash.new
|
|
69
|
+
@start_at_ms = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
summaries = snapshot.map do |(config_key, config_type), counters|
|
|
73
|
+
counter_list = counters.map do |(config_id, cvi, wvi, sval), meta|
|
|
74
|
+
counter = {
|
|
75
|
+
'configId' => config_id,
|
|
76
|
+
'conditionalValueIndex' => cvi,
|
|
77
|
+
'configRowIndex' => 0,
|
|
78
|
+
'selectedValue' => wrap_selected_value(sval),
|
|
79
|
+
'count' => meta[:count],
|
|
80
|
+
'reason' => meta[:reason]
|
|
81
|
+
}
|
|
82
|
+
counter['weightedValueIndex'] = wvi unless wvi.nil?
|
|
83
|
+
counter
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
entry = { 'key' => config_key, 'counters' => counter_list }
|
|
87
|
+
entry['type'] = config_type unless config_type.nil?
|
|
88
|
+
entry
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
'summaries' => {
|
|
93
|
+
'start' => start_at || Quonfig::TimeHelpers.now_in_ms,
|
|
94
|
+
'end' => Quonfig::TimeHelpers.now_in_ms,
|
|
95
|
+
'summaries' => summaries
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Wrap the evaluated value in the Prefab-proto-style tagged hash that
|
|
103
|
+
# api-telemetry ClickHouse ingestion expects. Keys match sdk-go's
|
|
104
|
+
# marshalSelectedValue (proto field names): bool / int / double /
|
|
105
|
+
# string / stringList.
|
|
106
|
+
def wrap_selected_value(value)
|
|
107
|
+
case value
|
|
108
|
+
when true, false then { 'bool' => value }
|
|
109
|
+
when Integer then { 'int' => value }
|
|
110
|
+
when Float then { 'double' => value }
|
|
111
|
+
when String then { 'string' => value }
|
|
112
|
+
when Array then { 'stringList' => value.map(&:to_s) }
|
|
113
|
+
when nil then { 'string' => '' }
|
|
114
|
+
else { 'string' => value.to_s }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Samples *example* contexts seen during evaluation. Dedupes by the
|
|
6
|
+
# concatenation of each named context's "key" property and rate-limits
|
|
7
|
+
# each grouped key to once per hour.
|
|
8
|
+
#
|
|
9
|
+
# Emits api-telemetry's `exampleContexts` event — JSON wire format,
|
|
10
|
+
# matching sdk-node and sdk-go. This is NOT the old Prefab protobuf.
|
|
11
|
+
class ExampleContextsAggregator
|
|
12
|
+
ONE_HOUR_SECONDS = 60 * 60
|
|
13
|
+
|
|
14
|
+
attr_reader :data, :cache
|
|
15
|
+
|
|
16
|
+
def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS)
|
|
17
|
+
@max_contexts = max_contexts
|
|
18
|
+
@data = Concurrent::Array.new
|
|
19
|
+
@cache = Quonfig::RateLimitCache.new(rate_limit_seconds)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Record a context for possible emission. Expects a Quonfig::Context.
|
|
23
|
+
# Contexts with no grouped_key (nothing to dedupe on) are dropped to
|
|
24
|
+
# avoid shipping empty/anonymous samples.
|
|
25
|
+
def record(context)
|
|
26
|
+
return if @max_contexts <= 0
|
|
27
|
+
return if context.nil?
|
|
28
|
+
|
|
29
|
+
key = grouped_key_for(context)
|
|
30
|
+
return if key.nil? || key.empty?
|
|
31
|
+
|
|
32
|
+
return unless @data.size < @max_contexts && !@cache.fresh?(key)
|
|
33
|
+
|
|
34
|
+
@cache.set(key)
|
|
35
|
+
@data.push([Quonfig::TimeHelpers.now_in_ms, context])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def prepare_data
|
|
39
|
+
to_ship = @data.dup
|
|
40
|
+
@data.clear
|
|
41
|
+
@cache.prune
|
|
42
|
+
to_ship
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Drain accumulated examples into a single telemetry event payload
|
|
46
|
+
# matching api-telemetry's ExampleContextsSchema, or +nil+ if empty.
|
|
47
|
+
def drain_event
|
|
48
|
+
return nil if @data.size.zero?
|
|
49
|
+
|
|
50
|
+
to_ship = prepare_data
|
|
51
|
+
|
|
52
|
+
examples = to_ship.map do |timestamp_ms, context|
|
|
53
|
+
contexts_list = contexts_to_list(context)
|
|
54
|
+
{ 'timestamp' => timestamp_ms, 'contextSet' => { 'contexts' => contexts_list } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{ 'exampleContexts' => { 'examples' => examples } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def grouped_key_for(context)
|
|
63
|
+
return context.grouped_key if context.respond_to?(:grouped_key)
|
|
64
|
+
|
|
65
|
+
# Fallback for plain-Hash contexts: concatenate each named context's
|
|
66
|
+
# "key" (or "trackingId") value.
|
|
67
|
+
return nil unless context.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
context.values.map do |ctx|
|
|
70
|
+
next nil unless ctx.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
ctx['key'] || ctx[:key] || ctx['trackingId'] || ctx[:trackingId]
|
|
73
|
+
end.compact.map(&:to_s).reject(&:empty?).sort.join('|')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def contexts_to_list(context)
|
|
77
|
+
if context.respond_to?(:contexts)
|
|
78
|
+
context.contexts.map do |name, named|
|
|
79
|
+
values = named.respond_to?(:to_h) ? named.to_h : named
|
|
80
|
+
{ 'type' => name.to_s, 'values' => stringify_values(values) }
|
|
81
|
+
end
|
|
82
|
+
elsif context.is_a?(Hash)
|
|
83
|
+
context.map do |name, values|
|
|
84
|
+
values = { name.to_s => values } unless values.is_a?(Hash)
|
|
85
|
+
{ 'type' => name.to_s, 'values' => stringify_values(values) }
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def stringify_values(hash)
|
|
93
|
+
return {} unless hash.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
hash.each_with_object({}) do |(k, v), acc|
|
|
96
|
+
acc[k.to_s] = v
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Owns the background thread that periodically drains the context
|
|
6
|
+
# aggregators and POSTs a JSON telemetry batch to
|
|
7
|
+
# +<telemetry_destination>/api/v1/telemetry/+.
|
|
8
|
+
#
|
|
9
|
+
# Wire shape matches api-telemetry's TelemetryEventsSchema:
|
|
10
|
+
#
|
|
11
|
+
# {
|
|
12
|
+
# "instanceHash": "...",
|
|
13
|
+
# "events": [
|
|
14
|
+
# { "summaries": { "start": ..., "end": ..., "summaries": [...] } },
|
|
15
|
+
# { "contextShapes": { "shapes": [...] } },
|
|
16
|
+
# { "exampleContexts": { "examples": [...] } }
|
|
17
|
+
# ]
|
|
18
|
+
# }
|
|
19
|
+
#
|
|
20
|
+
# Auth is HTTP Basic with username "1" and the SDK key as password
|
|
21
|
+
# (matching sdk-node and sdk-go). The +X-Quonfig-SDK-Version+ header
|
|
22
|
+
# carries the +ruby-<VERSION>+ identifier.
|
|
23
|
+
class TelemetryReporter
|
|
24
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
25
|
+
|
|
26
|
+
DEFAULT_INITIAL_DELAY_SECONDS = 8
|
|
27
|
+
DEFAULT_MAX_DELAY_SECONDS = 600
|
|
28
|
+
|
|
29
|
+
def initialize(options:, instance_hash:,
|
|
30
|
+
context_shape_aggregator: nil,
|
|
31
|
+
example_contexts_aggregator: nil,
|
|
32
|
+
evaluation_summaries_aggregator: nil,
|
|
33
|
+
sync_interval: nil,
|
|
34
|
+
http_connection: nil)
|
|
35
|
+
@options = options
|
|
36
|
+
@instance_hash = instance_hash
|
|
37
|
+
@sdk_key = options.sdk_key
|
|
38
|
+
@telemetry_destination = options.telemetry_destination
|
|
39
|
+
@context_shape_aggregator = context_shape_aggregator
|
|
40
|
+
@example_contexts_aggregator = example_contexts_aggregator
|
|
41
|
+
@evaluation_summaries_aggregator = evaluation_summaries_aggregator
|
|
42
|
+
@http_connection = http_connection
|
|
43
|
+
@sync_interval = calculate_sync_interval(sync_interval)
|
|
44
|
+
@stopped = Concurrent::AtomicBoolean.new(false)
|
|
45
|
+
@thread = nil
|
|
46
|
+
@at_exit_registered = false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def enabled?
|
|
50
|
+
return false if @sdk_key.nil? || @sdk_key.to_s.empty?
|
|
51
|
+
return false if @telemetry_destination.nil? || @telemetry_destination.to_s.empty?
|
|
52
|
+
|
|
53
|
+
!@context_shape_aggregator.nil? ||
|
|
54
|
+
!@example_contexts_aggregator.nil? ||
|
|
55
|
+
!@evaluation_summaries_aggregator.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Record a context across the context-driven aggregators. Evaluation
|
|
59
|
+
# summaries are recorded separately via
|
|
60
|
+
# +record_evaluation(...)+ since they require the evaluation result.
|
|
61
|
+
def record(context)
|
|
62
|
+
return if context.nil?
|
|
63
|
+
|
|
64
|
+
@context_shape_aggregator&.push(context)
|
|
65
|
+
@example_contexts_aggregator&.record(context)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record_evaluation(**kwargs)
|
|
69
|
+
@evaluation_summaries_aggregator&.record(**kwargs)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start
|
|
73
|
+
return if @thread&.alive?
|
|
74
|
+
return unless enabled?
|
|
75
|
+
|
|
76
|
+
@stopped.make_false
|
|
77
|
+
register_at_exit_handler
|
|
78
|
+
@thread = Thread.new do
|
|
79
|
+
Thread.current.name = 'quonfig-telemetry-reporter'
|
|
80
|
+
LOG.debug "Telemetry reporter started instance_hash=#{@instance_hash} destination=#{@telemetry_destination}"
|
|
81
|
+
|
|
82
|
+
until @stopped.true?
|
|
83
|
+
begin
|
|
84
|
+
sleep_duration = @sync_interval.call
|
|
85
|
+
slept = 0.0
|
|
86
|
+
step = 0.5
|
|
87
|
+
while slept < sleep_duration && !@stopped.true?
|
|
88
|
+
sleep([step, sleep_duration - slept].min)
|
|
89
|
+
slept += step
|
|
90
|
+
end
|
|
91
|
+
break if @stopped.true?
|
|
92
|
+
|
|
93
|
+
sync
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
LOG.warn "[quonfig] Telemetry reporter error: #{e.class}: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stop
|
|
102
|
+
@stopped.make_true
|
|
103
|
+
thread = @thread
|
|
104
|
+
@thread = nil
|
|
105
|
+
thread&.wakeup if thread&.alive?
|
|
106
|
+
# Final drain attempt on stop so tests / short-lived processes
|
|
107
|
+
# don't silently drop pending telemetry.
|
|
108
|
+
begin
|
|
109
|
+
sync
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
LOG.debug "[quonfig] Final telemetry sync failed: #{e.class}: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Drain all aggregators and POST the batch. Public so tests can
|
|
116
|
+
# trigger a sync without waiting for the background loop.
|
|
117
|
+
def sync
|
|
118
|
+
events = []
|
|
119
|
+
if (summaries_event = @evaluation_summaries_aggregator&.drain_event)
|
|
120
|
+
events << summaries_event
|
|
121
|
+
end
|
|
122
|
+
if (shape_event = @context_shape_aggregator&.drain_event)
|
|
123
|
+
events << shape_event
|
|
124
|
+
end
|
|
125
|
+
if (example_event = @example_contexts_aggregator&.drain_event)
|
|
126
|
+
events << example_event
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
return if events.empty?
|
|
130
|
+
|
|
131
|
+
payload = {
|
|
132
|
+
'instanceHash' => @instance_hash,
|
|
133
|
+
'events' => events
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
post(payload)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Visible for tests.
|
|
140
|
+
def at_exit_registered?
|
|
141
|
+
@at_exit_registered
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# Rails / Passenger / Puma workers often terminate via SIGTERM without
|
|
147
|
+
# a chance to call Client#stop. Register a Kernel.at_exit hook on
|
|
148
|
+
# first start so the in-flight batch still gets flushed.
|
|
149
|
+
def register_at_exit_handler
|
|
150
|
+
return if @at_exit_registered
|
|
151
|
+
|
|
152
|
+
Kernel.at_exit { final_drain_on_exit }
|
|
153
|
+
@at_exit_registered = true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Idempotent final drain. Safe to call after #stop has already
|
|
157
|
+
# drained: aggregators return nil when empty and #sync becomes a
|
|
158
|
+
# no-op.
|
|
159
|
+
def final_drain_on_exit
|
|
160
|
+
@stopped.make_true
|
|
161
|
+
sync
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
LOG.debug "[quonfig] at_exit telemetry drain failed: #{e.class}: #{e.message}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def post(payload)
|
|
167
|
+
conn = http_connection
|
|
168
|
+
return if conn.nil?
|
|
169
|
+
|
|
170
|
+
response = conn.post('/api/v1/telemetry/', payload)
|
|
171
|
+
status = response.respond_to?(:status) ? response.status : nil
|
|
172
|
+
if status && status >= 400
|
|
173
|
+
LOG.warn "[quonfig] Telemetry POST failed: #{status}"
|
|
174
|
+
else
|
|
175
|
+
LOG.debug "[quonfig] Telemetry POST ok: events=#{payload['events'].size}"
|
|
176
|
+
end
|
|
177
|
+
response
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def http_connection
|
|
181
|
+
@http_connection ||= begin
|
|
182
|
+
return nil if @sdk_key.nil? || @telemetry_destination.nil?
|
|
183
|
+
|
|
184
|
+
Quonfig::HttpConnection.new(@telemetry_destination, @sdk_key)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def calculate_sync_interval(sync_interval)
|
|
189
|
+
return proc { sync_interval } if sync_interval.is_a?(Numeric)
|
|
190
|
+
return sync_interval if sync_interval.respond_to?(:call)
|
|
191
|
+
|
|
192
|
+
Quonfig::ExponentialBackoff.new(
|
|
193
|
+
initial_delay: DEFAULT_INITIAL_DELAY_SECONDS,
|
|
194
|
+
max_delay: DEFAULT_MAX_DELAY_SECONDS,
|
|
195
|
+
multiplier: 1.5
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
data/lib/quonfig.rb
CHANGED
|
@@ -35,6 +35,9 @@ require 'quonfig/errors/env_var_parse_error'
|
|
|
35
35
|
require 'quonfig/errors/missing_env_var_error'
|
|
36
36
|
require 'quonfig/errors/type_mismatch_error'
|
|
37
37
|
require 'quonfig/errors/uninitialized_error'
|
|
38
|
+
require 'quonfig/errors/decryption_error'
|
|
39
|
+
require 'quonfig/errors/missing_environment_error'
|
|
40
|
+
require 'quonfig/errors/invalid_environment_error'
|
|
38
41
|
require 'quonfig/options'
|
|
39
42
|
require 'quonfig/rate_limit_cache'
|
|
40
43
|
require 'quonfig/weighted_value_resolver'
|
|
@@ -48,6 +51,11 @@ require 'quonfig/sse_config_client'
|
|
|
48
51
|
require 'quonfig/http_connection'
|
|
49
52
|
require 'quonfig/caching_http_connection'
|
|
50
53
|
require 'quonfig/context'
|
|
54
|
+
require 'quonfig/telemetry/context_shape'
|
|
55
|
+
require 'quonfig/telemetry/context_shape_aggregator'
|
|
56
|
+
require 'quonfig/telemetry/example_contexts_aggregator'
|
|
57
|
+
require 'quonfig/telemetry/evaluation_summaries_aggregator'
|
|
58
|
+
require 'quonfig/telemetry/telemetry_reporter'
|
|
51
59
|
require 'quonfig/client'
|
|
52
60
|
require 'quonfig/bound_client'
|
|
53
61
|
require 'quonfig/semantic_logger_filter'
|