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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/client.rb +109 -2
  5. data/lib/quonfig/context.rb +10 -1
  6. data/lib/quonfig/datadir.rb +2 -4
  7. data/lib/quonfig/errors/decryption_error.rb +20 -0
  8. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  9. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  10. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  11. data/lib/quonfig/evaluator.rb +64 -2
  12. data/lib/quonfig/http_connection.rb +1 -1
  13. data/lib/quonfig/resolver.rb +187 -2
  14. data/lib/quonfig/stdlib_formatter.rb +95 -0
  15. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  16. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  17. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  18. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  19. data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
  20. data/lib/quonfig.rb +8 -0
  21. data/quonfig.gemspec +20 -4
  22. data/test/integration/test_context_precedence.rb +35 -117
  23. data/test/integration/test_datadir_environment.rb +15 -37
  24. data/test/integration/test_enabled.rb +157 -463
  25. data/test/integration/test_enabled_with_contexts.rb +19 -49
  26. data/test/integration/test_get.rb +43 -131
  27. data/test/integration/test_get_feature_flag.rb +7 -13
  28. data/test/integration/test_get_or_raise.rb +19 -45
  29. data/test/integration/test_get_weighted_values.rb +9 -4
  30. data/test/integration/test_helpers.rb +499 -4
  31. data/test/integration/test_post.rb +15 -5
  32. data/test/integration/test_telemetry.rb +63 -21
  33. data/test/test_client_telemetry.rb +132 -0
  34. data/test/test_context.rb +4 -1
  35. data/test/test_context_shape.rb +37 -0
  36. data/test/test_context_shape_aggregator.rb +126 -0
  37. data/test/test_datadir.rb +6 -2
  38. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  39. data/test/test_example_contexts_aggregator.rb +119 -0
  40. data/test/test_http_connection.rb +1 -1
  41. data/test/test_resolver.rb +149 -2
  42. data/test/test_should_log.rb +186 -0
  43. data/test/test_stdlib_formatter.rb +195 -0
  44. data/test/test_telemetry_reporter.rb +209 -0
  45. metadata +19 -3
  46. 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'