smplkit 3.0.94 → 3.0.96

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/_generated/audit/lib/smplkit_audit_client/api/categories_api.rb +2 -2
  3. data/lib/smplkit/_generated/audit/lib/smplkit_audit_client/api/event_types_api.rb +2 -2
  4. data/lib/smplkit/_generated/audit/lib/smplkit_audit_client/api/events_api.rb +4 -4
  5. data/lib/smplkit/_generated/audit/lib/smplkit_audit_client/api/resource_types_api.rb +2 -2
  6. data/lib/smplkit/_generated/audit/lib/smplkit_audit_client/models/event_search_request.rb +1 -1
  7. data/lib/smplkit/_generated/audit/spec/api/categories_api_spec.rb +1 -1
  8. data/lib/smplkit/_generated/audit/spec/api/event_types_api_spec.rb +1 -1
  9. data/lib/smplkit/_generated/audit/spec/api/events_api_spec.rb +2 -2
  10. data/lib/smplkit/_generated/audit/spec/api/resource_types_api_spec.rb +1 -1
  11. data/lib/smplkit/account/client.rb +121 -0
  12. data/lib/smplkit/account/models.rb +53 -0
  13. data/lib/smplkit/api_support.rb +83 -0
  14. data/lib/smplkit/audit/client.rb +9 -10
  15. data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +73 -76
  16. data/lib/smplkit/audit/models.rb +40 -1
  17. data/lib/smplkit/buffers.rb +235 -0
  18. data/lib/smplkit/client.rb +126 -67
  19. data/lib/smplkit/config/client.rb +617 -182
  20. data/lib/smplkit/config_resolution.rb +11 -5
  21. data/lib/smplkit/errors.rb +8 -0
  22. data/lib/smplkit/flags/client.rb +472 -114
  23. data/lib/smplkit/flags/types.rb +6 -7
  24. data/lib/smplkit/{management/jobs.rb → jobs/client.rb} +148 -89
  25. data/lib/smplkit/logging/client.rb +647 -192
  26. data/lib/smplkit/logging/helpers.rb +1 -0
  27. data/lib/smplkit/logging/models.rb +92 -1
  28. data/lib/smplkit/logging/sources.rb +1 -1
  29. data/lib/smplkit/platform/client.rb +472 -0
  30. data/lib/smplkit/platform/models.rb +182 -0
  31. data/lib/smplkit/{management → platform}/types.rb +7 -4
  32. data/lib/smplkit/transport.rb +99 -0
  33. data/lib/smplkit.rb +18 -6
  34. metadata +11 -7
  35. data/lib/smplkit/management/buffer.rb +0 -198
  36. data/lib/smplkit/management/client.rb +0 -1074
  37. data/lib/smplkit/management/models.rb +0 -178
@@ -1,40 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # SIEM forwarder CRUD for the Smpl Audit client.
4
+ #
5
+ # Forwarders are part of the single unified audit surface — there is no
6
+ # runtime/management split for audit (see +Smplkit::Audit::AuditClient+). This
7
+ # file holds the forwarder CRUD sub-client that the unified +AuditClient+
8
+ # exposes as +.forwarders+:
9
+ #
10
+ # * +ForwardersClient+ — +forwarders.new/get/list/save/delete+
11
+ #
12
+ # The forwarder model classes (+Forwarder+, +ForwarderEnvironment+, …) live in
13
+ # +lib/smplkit/audit/models.rb+.
3
14
  module Smplkit
4
- module Management
5
- # Audit management surfaceaccessed via +mgmt.audit.forwarders+.
15
+ module Audit
16
+ # Surface for +client.audit.forwarders.*+manage the customer's
17
+ # configured SIEM forwarders.
6
18
  #
7
- # Counterpart to the runtime {Smplkit::Audit::AuditClient}. The
8
- # runtime client owns event recording and read-side queries; this
9
- # surface owns SIEM forwarder CRUD. ADR-047 §2.7.
10
- class AuditNamespace
11
- # @return [ForwardersNamespace] CRUD surface for +mgmt.audit.forwarders+.
12
- attr_reader :forwarders
13
-
14
- def initialize(api_client)
15
- @forwarders = ForwardersNamespace.new(
16
- SmplkitGeneratedClient::Audit::ForwardersApi.new(api_client)
17
- )
18
- end
19
- end
20
-
21
- # +mgmt.audit.forwarders.*+ — manage the customer's configured SIEM
22
- # forwarders.
23
- #
24
- # The active-record entry point is {#new_forwarder}: instantiate a
25
- # draft, mutate fields, then call {Smplkit::Audit::Forwarder#save}.
26
- # The namespace exposes {#list}, {#get}, and {#delete} directly; the
27
- # +_create_forwarder+ / +_update_forwarder+ helpers are private and
28
- # invoked by {Smplkit::Audit::Forwarder#save}.
29
- class ForwardersNamespace
19
+ # The active-record entry point is {#new}: instantiate a draft, mutate
20
+ # fields, then call {Smplkit::Audit::Forwarder#save}. The client exposes
21
+ # {#list}, {#get}, and {#delete} directly; the +_create_forwarder+ /
22
+ # +_update_forwarder+ helpers are invoked by {Smplkit::Audit::Forwarder#save}.
23
+ class ForwardersClient
30
24
  def initialize(api)
31
25
  @api = api
32
26
  end
33
27
 
34
- # Construct an unsaved {Smplkit::Audit::Forwarder} bound to this
35
- # namespace. Call +#save+ on the returned instance to persist.
28
+ # Construct an unsaved {Smplkit::Audit::Forwarder} bound to this client.
29
+ # Call +#save+ on the returned instance to persist.
36
30
  #
37
- # @param name [String] Display name.
31
+ # @param id [String] Caller-supplied unique identifier (the forwarder's
32
+ # key). Unique within the account; immutable. The audit service returns
33
+ # 409 if another live forwarder already uses this id.
34
+ # @param name [String] Display name. Defaults to +id+ when not supplied.
38
35
  # @param forwarder_type [String] One of {Smplkit::Audit::ForwarderType::VALUES}.
39
36
  # @param configuration [Smplkit::Audit::HttpConfiguration] Destination
40
37
  # request configuration. Headers carry credentials and are encrypted at
@@ -56,11 +53,9 @@ module Smplkit
56
53
  # @param filter [Hash, nil] Optional JSON Logic filter; events that don't
57
54
  # match are recorded as +filtered_out+ deliveries.
58
55
  # @param transform [Object, nil] Optional template applied to each event
59
- # before delivery. Free-form by default the audit service passes the
60
- # value verbatim to the engine named by +transform_type+. Must be paired
61
- # with a non-nil +transform_type+; when +transform_type+ is
62
- # +TransformType::JSONATA+, +transform+ must be a +String+ (the JSONata
63
- # expression).
56
+ # before delivery. Must be paired with a non-nil +transform_type+; when
57
+ # +transform_type+ is +TransformType::JSONATA+, +transform+ must be a
58
+ # +String+ (the JSONata expression).
64
59
  # @param transform_type [String, nil] Engine that evaluates +transform+ —
65
60
  # one of {Smplkit::Audit::TransformType::VALUES}. Must be paired with a
66
61
  # non-nil +transform+.
@@ -68,12 +63,12 @@ module Smplkit
68
63
  # nil or both set, or when +transform_type+ is +JSONATA+ and +transform+
69
64
  # is not a +String+.
70
65
  # @return [Smplkit::Audit::Forwarder]
71
- def new_forwarder(id, forwarder_type:, configuration:, name: nil,
72
- environments: nil, description: nil,
73
- forward_smplkit_events: false,
74
- filter: nil, transform: nil, transform_type: nil)
75
- Smplkit::Audit::Forwarder.send(:validate_transform_pair!, transform, transform_type)
76
- Smplkit::Audit::Forwarder.new(
66
+ def new(id, forwarder_type:, configuration:, name: nil,
67
+ environments: nil, description: nil,
68
+ forward_smplkit_events: false,
69
+ filter: nil, transform: nil, transform_type: nil)
70
+ Forwarder.send(:validate_transform_pair!, transform, transform_type)
71
+ Forwarder.new(
77
72
  self,
78
73
  id: id,
79
74
  name: name || id,
@@ -91,33 +86,31 @@ module Smplkit
91
86
  # List forwarders for the authenticated account.
92
87
  #
93
88
  # Offset paginated per ADR-014: pass +page_number+ (1-based) and
94
- # +page_size+ (default 1000, max 1000). Pass +meta_total: true+ to
95
- # populate +total+ and +total_pages+ in the returned +pagination+
96
- # block (costs an extra COUNT query server-side).
89
+ # +page_size+ (default 1000, max 1000). Pass +meta_total: true+ to populate
90
+ # +total+ and +total_pages+ in the returned +pagination+ block (costs an
91
+ # extra COUNT query server-side).
97
92
  #
98
93
  # @return [ForwarderListPage]
99
94
  def list(forwarder_type: nil, page_number: nil, page_size: nil, meta_total: nil)
100
95
  opts = {}
101
- opts[:filter_forwarder_type] = Smplkit::Audit::ForwarderType.coerce(forwarder_type) if forwarder_type
96
+ opts[:filter_forwarder_type] = ForwarderType.coerce(forwarder_type) if forwarder_type
102
97
  opts[:page_number] = page_number if page_number
103
98
  opts[:page_size] = page_size if page_size
104
99
  opts[:meta_total] = meta_total unless meta_total.nil?
105
100
 
106
- resp = Smplkit::Audit.call_api { @api.list_forwarders(opts) }
107
- forwarders = (resp.data || []).map do |r|
108
- Smplkit::Audit::Forwarder.from_resource(r, client: self)
109
- end
110
- ForwarderListPage.new(forwarders, Smplkit::Audit.extract_pagination(resp.meta))
101
+ resp = Audit.call_api { @api.list_forwarders(opts) }
102
+ forwarders = (resp.data || []).map { |r| Forwarder.from_resource(r, client: self) }
103
+ ForwarderListPage.new(forwarders, Audit.extract_pagination(resp.meta))
111
104
  end
112
105
 
113
- # Fetch a single forwarder by id. The returned instance is bound to
114
- # this namespace, so +forwarder.save+ and +forwarder.delete+ work.
106
+ # Fetch a single forwarder by id. The returned instance is bound to this
107
+ # client, so +forwarder.save+ and +forwarder.delete+ work.
115
108
  #
116
109
  # @param forwarder_id [String]
117
110
  # @return [Smplkit::Audit::Forwarder]
118
111
  def get(forwarder_id)
119
- resp = Smplkit::Audit.call_api { @api.get_forwarder(forwarder_id) }
120
- Smplkit::Audit::Forwarder.from_resource(resp.data, client: self)
112
+ resp = Audit.call_api { @api.get_forwarder(forwarder_id) }
113
+ Forwarder.from_resource(resp.data, client: self)
121
114
  end
122
115
 
123
116
  # Soft-delete a forwarder.
@@ -125,7 +118,7 @@ module Smplkit
125
118
  # @param forwarder_id [String]
126
119
  # @return [nil]
127
120
  def delete(forwarder_id)
128
- Smplkit::Audit.call_api { @api.delete_forwarder(forwarder_id) }
121
+ Audit.call_api { @api.delete_forwarder(forwarder_id) }
129
122
  nil
130
123
  end
131
124
 
@@ -136,22 +129,21 @@ module Smplkit
136
129
  raise ArgumentError, "Forwarder.id is required on create (caller-supplied key)"
137
130
  end
138
131
 
139
- resp = Smplkit::Audit.call_api { @api.create_forwarder(build_create_body(forwarder)) }
140
- Smplkit::Audit::Forwarder.from_resource(resp.data, client: self)
132
+ resp = Audit.call_api { @api.create_forwarder(build_create_body(forwarder)) }
133
+ Forwarder.from_resource(resp.data, client: self)
141
134
  end
142
135
 
143
- # @api private — Full-replace PUT for an existing forwarder. Called
144
- # by {Smplkit::Audit::Forwarder#save} on instances with +created_at+.
136
+ # @api private — Full-replace PUT for an existing forwarder. Called by
137
+ # {Smplkit::Audit::Forwarder#save} on instances with +created_at+.
145
138
  #
146
- # Header values must be re-supplied as plaintext; the GET path
147
- # redacts them, so a PUT body containing +"<redacted>"+ would
148
- # persist that literal. Track real header values client-side and
149
- # round-trip them.
139
+ # Header values must be re-supplied as plaintext; the GET path redacts
140
+ # them, so a PUT body containing +"<redacted>"+ would persist that literal.
141
+ # Track real header values client-side and round-trip them.
150
142
  def _update_forwarder(forwarder)
151
143
  raise ArgumentError, "cannot update a Forwarder with no id" if forwarder.id.nil?
152
144
 
153
- resp = Smplkit::Audit.call_api { @api.update_forwarder(forwarder.id, build_body(forwarder)) }
154
- Smplkit::Audit::Forwarder.from_resource(resp.data, client: self)
145
+ resp = Audit.call_api { @api.update_forwarder(forwarder.id, build_body(forwarder)) }
146
+ Forwarder.from_resource(resp.data, client: self)
155
147
  end
156
148
 
157
149
  private
@@ -160,16 +152,15 @@ module Smplkit
160
152
  #
161
153
  # Accepts either {Smplkit::Audit::ForwarderEnvironment} values or plain
162
154
  # hashes (+{ enabled: true, configuration: HttpConfiguration.new(...) }+)
163
- # so callers can use the lightweight hash form without importing the
164
- # model.
155
+ # so callers can use the lightweight hash form without importing the model.
165
156
  def normalize_environments(environments)
166
157
  return {} if environments.nil? || environments.empty?
167
158
 
168
159
  environments.each_with_object({}) do |(env_key, value), out|
169
- out[env_key.to_s] = if value.is_a?(Smplkit::Audit::ForwarderEnvironment)
160
+ out[env_key.to_s] = if value.is_a?(ForwarderEnvironment)
170
161
  value
171
162
  else
172
- Smplkit::Audit::ForwarderEnvironment.new(
163
+ ForwarderEnvironment.new(
173
164
  enabled: value[:enabled] || value["enabled"] || false,
174
165
  configuration: value[:configuration] || value["configuration"]
175
166
  )
@@ -186,7 +177,7 @@ module Smplkit
186
177
  (environments || {}).each_with_object({}) do |(env_key, env), out|
187
178
  out[env_key.to_s] = SmplkitGeneratedClient::Audit::ForwarderEnvironment.new(
188
179
  enabled: env.enabled,
189
- configuration: env.configuration.nil? ? nil : Smplkit::Audit::HttpConfiguration.to_wire(env.configuration)
180
+ configuration: env.configuration.nil? ? nil : HttpConfiguration.to_wire(env.configuration)
190
181
  )
191
182
  end
192
183
  end
@@ -197,20 +188,20 @@ module Smplkit
197
188
  SmplkitGeneratedClient::Audit::Forwarder.new(
198
189
  name: forwarder.name,
199
190
  description: forwarder.description,
200
- forwarder_type: Smplkit::Audit::ForwarderType.coerce(forwarder.forwarder_type),
191
+ forwarder_type: ForwarderType.coerce(forwarder.forwarder_type),
201
192
  forward_smplkit_events: forwarder.forward_smplkit_events,
202
193
  environments: environments_to_wire(forwarder.environments),
203
194
  filter: forwarder.filter,
204
- transform_type: Smplkit::Audit::TransformType.coerce(forwarder.transform_type),
195
+ transform_type: TransformType.coerce(forwarder.transform_type),
205
196
  transform: forwarder.transform,
206
- configuration: Smplkit::Audit::HttpConfiguration.to_wire(forwarder.configuration)
197
+ configuration: HttpConfiguration.to_wire(forwarder.configuration)
207
198
  )
208
199
  end
209
200
 
210
201
  def build_create_body(forwarder)
211
- # Create uses the distinct ForwarderCreateRequest envelope; the
212
- # audit service requires data.id (the customer-supplied key) on
213
- # create and 409s on conflict.
202
+ # Create uses the distinct ForwarderCreateRequest envelope; the audit
203
+ # service requires data.id (the customer-supplied key) on create and
204
+ # 409s on conflict.
214
205
  resource = SmplkitGeneratedClient::Audit::ForwarderCreateResource.new(
215
206
  id: forwarder.id.to_s,
216
207
  type: "forwarder",
@@ -230,13 +221,19 @@ module Smplkit
230
221
  end
231
222
  end
232
223
 
233
- # A single page returned from {ForwardersNamespace#list}.
224
+ # A single page returned from {ForwardersClient#list}.
234
225
  #
235
226
  # @!attribute [rw] forwarders
236
227
  # @return [Array<Smplkit::Audit::Forwarder>] Forwarders in this page.
237
228
  # @!attribute [rw] pagination
238
229
  # @return [Hash] +meta.pagination+ block (+:page+, +:size+, and — only when
239
230
  # the caller passed +meta_total: true+ — +:total+ / +:total_pages+).
240
- ForwarderListPage = Struct.new(:forwarders, :pagination)
231
+ ForwarderListPage = Struct.new(:forwarders, :pagination) do
232
+ include Enumerable
233
+
234
+ def each(&) = forwarders.each(&)
235
+ def length = forwarders.length
236
+ alias_method :size, :length
237
+ end
241
238
  end
242
239
  end
@@ -422,7 +422,7 @@ module Smplkit
422
422
  # A SIEM streaming forwarder configured on the customer's account.
423
423
  #
424
424
  # Active-record style: instantiate via
425
- # +mgmt.audit.forwarders.new_forwarder(...)+, mutate fields directly,
425
+ # +client.audit.forwarders.new(...)+, mutate fields directly,
426
426
  # and call {#save} to persist or {#delete} to remove. Header values in
427
427
  # +configuration.headers+ are returned redacted on reads — the GET path
428
428
  # on the audit API replaces every header value with +"<redacted>"+.
@@ -558,6 +558,45 @@ module Smplkit
558
558
  end
559
559
  alias delete! delete
560
560
 
561
+ # Set this forwarder's destination configuration in memory.
562
+ #
563
+ # With +environment+ omitted, replaces the base {#configuration}. With
564
+ # +environment+ given, sets the per-environment override's configuration
565
+ # on {#environments}, creating the override entry if it doesn't exist yet
566
+ # (preserving any already-set +enabled+ on it). Call {#save} to persist.
567
+ def set_configuration(configuration, environment: nil)
568
+ if environment.nil?
569
+ @configuration = configuration
570
+ else
571
+ _environment_override(environment).configuration = configuration
572
+ end
573
+ end
574
+
575
+ # Set this forwarder's enablement in memory.
576
+ #
577
+ # With +environment+ omitted, sets the base {#enabled} (which the server
578
+ # pins false regardless — enablement is per-environment). With
579
+ # +environment+ given, sets the per-environment override's +enabled+ on
580
+ # {#environments}, creating the override entry if it doesn't exist yet
581
+ # (preserving any already-set +configuration+ on it). Call {#save} to
582
+ # persist.
583
+ def set_enabled(enabled, environment: nil)
584
+ if environment.nil?
585
+ @enabled = enabled
586
+ else
587
+ _environment_override(environment).enabled = enabled
588
+ end
589
+ end
590
+
591
+ # Return the override for +environment+, creating an empty one if absent.
592
+ #
593
+ # The per-environment mutators reach through here so an existing
594
+ # override's other field is preserved when only one of +enabled+ /
595
+ # +configuration+ is being set.
596
+ def _environment_override(environment)
597
+ @environments[environment] ||= ForwarderEnvironment.new
598
+ end
599
+
561
600
  # @api private
562
601
  def _apply(other)
563
602
  @id = other.id
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ # Registration buffers backing the SDK's batched-discovery sub-clients.
6
+ #
7
+ # Four buffer types, each owned by the sub-client that drains it:
8
+ #
9
+ # - +ContextRegistrationBuffer+ -> +client.platform.contexts._buffer+
10
+ # - +FlagRegistrationBuffer+ -> +client.flags._buffer+
11
+ # - +ConfigRegistrationBuffer+ -> +client.config._buffer+
12
+ # - +LoggerRegistrationBuffer+ -> +client.logging.loggers._buffer+
13
+ #
14
+ # There is exactly one buffer + one bulk-flush implementation per resource.
15
+ module Smplkit
16
+ # When the deduplication LRU exceeds this size, the oldest entry is
17
+ # evicted. The next observation of an evicted entry will re-flush.
18
+ CONTEXT_REGISTRATION_LRU_SIZE = 10_000
19
+
20
+ # Pending-queue size that triggers an immediate background flush from
21
+ # inside +register+. The periodic timer on +Client+ covers the tail
22
+ # case for low-traffic services.
23
+ CONTEXT_BATCH_FLUSH_SIZE = 100
24
+ FLAG_BATCH_FLUSH_SIZE = 50
25
+ LOGGER_BATCH_FLUSH_SIZE = 50
26
+ CONFIG_BATCH_FLUSH_SIZE = 50
27
+
28
+ # Thread-safe batch buffer for context registration.
29
+ class ContextRegistrationBuffer
30
+ def initialize
31
+ @seen = {}
32
+ @pending = []
33
+ @lock = Mutex.new
34
+ end
35
+
36
+ # Queue any unseen contexts.
37
+ def observe(contexts)
38
+ @lock.synchronize do
39
+ contexts.each do |ctx|
40
+ cache_key = [ctx.type, ctx.key]
41
+ next if @seen.key?(cache_key)
42
+
43
+ @seen.shift if @seen.size >= CONTEXT_REGISTRATION_LRU_SIZE
44
+ @seen[cache_key] = ctx.attributes
45
+ @pending << { "type" => ctx.type, "key" => ctx.key, "attributes" => ctx.attributes.dup }
46
+ end
47
+ end
48
+ end
49
+
50
+ # Return and clear the current pending batch.
51
+ def drain
52
+ @lock.synchronize do
53
+ batch = @pending
54
+ @pending = []
55
+ batch
56
+ end
57
+ end
58
+
59
+ def pending_count
60
+ @lock.synchronize { @pending.length }
61
+ end
62
+ end
63
+
64
+ # Thread-safe batch buffer for flag declarations.
65
+ #
66
+ # Use +peek+ + +commit(ids)+ for the send path so a failed POST leaves
67
+ # declarations queued for the next attempt; the legacy +drain+ is
68
+ # unconditional and used only by tests.
69
+ class FlagRegistrationBuffer
70
+ def initialize
71
+ @seen = {}
72
+ @pending = []
73
+ @lock = Mutex.new
74
+ end
75
+
76
+ def add(declaration)
77
+ @lock.synchronize do
78
+ next if @seen.key?(declaration.id)
79
+
80
+ @seen[declaration.id] = true
81
+ item = { "id" => declaration.id, "type" => declaration.type, "default" => declaration.default }
82
+ item["service"] = declaration.service if declaration.service
83
+ item["environment"] = declaration.environment if declaration.environment
84
+ @pending << item
85
+ end
86
+ end
87
+
88
+ def peek
89
+ @lock.synchronize { @pending.dup }
90
+ end
91
+
92
+ def commit(ids)
93
+ return if ids.nil? || ids.empty?
94
+
95
+ committed = ids.to_set
96
+ @lock.synchronize { @pending.reject! { |item| committed.include?(item["id"]) } }
97
+ end
98
+
99
+ def drain
100
+ @lock.synchronize do
101
+ batch = @pending
102
+ @pending = []
103
+ batch
104
+ end
105
+ end
106
+
107
+ def pending_count
108
+ @lock.synchronize { @pending.length }
109
+ end
110
+ end
111
+
112
+ # Thread-safe batch buffer for config declarations.
113
+ #
114
+ # Configs differ from flags/loggers because each entry carries a nested
115
+ # +items+ dict that grows incrementally as the customer's code touches more
116
+ # typed getters on a declared handle. The buffer therefore stores per-config
117
+ # metadata permanently (so post-flush deltas can be re-attributed to the
118
+ # right service/environment) and dedups items per +(config_id, item_key)+ so
119
+ # we never re-send an item that the server has already accepted.
120
+ #
121
+ # Call sites:
122
+ #
123
+ # - +declare+ once per +client.config.bind(id, ...)+.
124
+ # - +add_item+ for every introspected leaf field, or anything else the
125
+ # runtime client observes. Repeated calls with the same
126
+ # +(config_id, item_key)+ after a successful flush are no-ops.
127
+ # - +drain+ returns the pending payload list, clears the pending buffer, and
128
+ # records what was sent.
129
+ #
130
+ # The buffer never drops metadata — only +pending+ is cleared on flush. If
131
+ # the customer's code declares new items via typed getters after a flush, a
132
+ # fresh pending entry is created using the stored metadata so the server can
133
+ # route the delta to the right source row.
134
+ class ConfigRegistrationBuffer
135
+ def initialize
136
+ @pending = {} # config_id -> { id:, items: {}, ...meta }
137
+ @meta = {} # config_id -> { service:, environment:, parent:, name:, description: }
138
+ @sent_items = {} # "#{config_id}::#{item_key}" -> true
139
+ @lock = Mutex.new
140
+ end
141
+
142
+ # Register a configuration. Idempotent within a process.
143
+ def declare(config_id, service:, environment:, parent: nil, name: nil, description: nil)
144
+ @lock.synchronize do
145
+ next if @meta.key?(config_id)
146
+
147
+ @meta[config_id] = {
148
+ service: service, environment: environment,
149
+ parent: parent, name: name, description: description
150
+ }
151
+ @pending[config_id] = build_entry(config_id)
152
+ end
153
+ end
154
+
155
+ # Queue an item declaration if not already sent.
156
+ #
157
+ # Must be preceded by +declare+ for the same +config_id+; otherwise the
158
+ # call is dropped (no implicit declaration).
159
+ def add_item(config_id, item_key, item_type, default, description = nil)
160
+ @lock.synchronize do
161
+ next unless @meta.key?(config_id)
162
+ next if @sent_items.key?("#{config_id}::#{item_key}")
163
+
164
+ entry = (@pending[config_id] ||= build_entry(config_id))
165
+ next if entry["items"].key?(item_key)
166
+
167
+ item = { "value" => default, "type" => item_type }
168
+ item["description"] = description unless description.nil?
169
+ entry["items"][item_key] = item
170
+ end
171
+ end
172
+
173
+ # Return and clear the pending batch; record sent items.
174
+ def drain
175
+ @lock.synchronize do
176
+ entries = @pending.values
177
+ entries.each do |entry|
178
+ entry["items"].each_key { |item_key| @sent_items["#{entry["id"]}::#{item_key}"] = true }
179
+ end
180
+ @pending = {}
181
+ entries
182
+ end
183
+ end
184
+
185
+ def pending_count
186
+ @lock.synchronize { @pending.size }
187
+ end
188
+
189
+ private
190
+
191
+ def build_entry(config_id)
192
+ meta = @meta[config_id]
193
+ entry = { "id" => config_id, "items" => {} }
194
+ %i[service environment parent name description].each do |k|
195
+ v = meta[k]
196
+ entry[k.to_s] = v unless v.nil?
197
+ end
198
+ entry
199
+ end
200
+ end
201
+
202
+ # Thread-safe batch buffer for logger discovery.
203
+ class LoggerRegistrationBuffer
204
+ def initialize
205
+ @seen = {}
206
+ @pending = []
207
+ @lock = Mutex.new
208
+ end
209
+
210
+ def add(source)
211
+ @lock.synchronize do
212
+ next if @seen.key?(source.name)
213
+
214
+ @seen[source.name] = source.resolved_level
215
+ item = { "id" => source.name, "resolved_level" => source.resolved_level&.to_s }
216
+ item["level"] = source.level&.to_s if source.level
217
+ item["service"] = source.service if source.service
218
+ item["environment"] = source.environment if source.environment
219
+ @pending << item
220
+ end
221
+ end
222
+
223
+ def drain
224
+ @lock.synchronize do
225
+ batch = @pending
226
+ @pending = []
227
+ batch
228
+ end
229
+ end
230
+
231
+ def pending_count
232
+ @lock.synchronize { @pending.length }
233
+ end
234
+ end
235
+ end