smplkit 3.0.95 → 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.
@@ -1,94 +1,393 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "concurrent"
4
-
3
+ # The Smpl Logging client — one unified +LoggingClient+.
4
+ #
5
+ # Smpl Logging has two surfaces on a single client, mirroring how the config,
6
+ # flags, audit, and jobs clients expose their full surface from one class:
7
+ #
8
+ # * *Management surface* — works immediately, no +install+ required. Two
9
+ # sub-clients (the audit pattern):
10
+ #
11
+ # * +client.logging.loggers+ — logger CRUD + discovery: +new+ / +list+ / +get+
12
+ # / +delete+ plus +register+ / +flush+ / +flush_sync+ / +pending_count+.
13
+ # * +client.logging.log_groups+ — log-group CRUD: +new+ / +list+ / +get+ /
14
+ # +delete+.
15
+ #
16
+ # The fused client owns the logger-discovery buffer directly; the +loggers+
17
+ # sub-client shares that same buffer so discovery and explicit registration
18
+ # drain through one queue.
19
+ #
20
+ # * *Live surface* — directly on the client. +register_adapter+ is a PRE-install
21
+ # configuration call (allowed before +install+). +install+ opens the live
22
+ # connection (monkey-patches the app's logging framework, discovers loggers,
23
+ # fetches + applies levels, opens the shared WebSocket). +on_change+ /
24
+ # +refresh+ require +install+ first; calling them earlier raises
25
+ # +NotInstalledError+.
26
+ #
27
+ # The client supports two construction shapes:
28
+ #
29
+ # * *Wired* into +Smplkit::Client+ — borrows the parent's logging transport for
30
+ # both runtime fetch and CRUD and the parent's shared WebSocket for the live
31
+ # channel. This is the common path.
32
+ # * *Standalone* — +LoggingClient.new(api_key: ..., base_url: ..., ...)+ builds
33
+ # and owns its own logging transport and an app transport (the WebSocket
34
+ # gateway lives on the app service), and on +install+ opens and owns its own
35
+ # WebSocket. +close+ tears down only the owned transports and owned WebSocket.
5
36
  module Smplkit
6
37
  module Logging
7
- # Synchronous logging runtime namespace.
38
+ NOT_INSTALLED_MESSAGE = "Smpl Logging live operations require install() first — this opens a live " \
39
+ "connection to your running service and hooks into your application's logging " \
40
+ "framework. Call client.logging.install() before on_change/refresh()."
41
+
42
+ # Build standalone logging + app transports and resolve the app base URL.
8
43
  #
9
- # Obtained via +Smplkit::Client#logging+. Manages the discovery and level
10
- # application for a customer's logging frameworks via pluggable adapters.
11
- # CRUD has moved to +mgmt.loggers.*+ / +mgmt.log_groups.*+.
44
+ # +base_url+/+api_key+ are used directly when supplied (the path a top-level
45
+ # client takes after it has already resolved them); otherwise the management
46
+ # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
47
+ # defaults). The app transport is needed for the WebSocket gateway, which
48
+ # lives on the app service (like flags); the app base URL is returned so a
49
+ # standalone client can open its own WebSocket against the event gateway.
50
+ def self.logging_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
51
+ cfg = ConfigResolution.resolve_management_config(
52
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
53
+ )
54
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
55
+ merged = {}
56
+ merged.merge!(cfg.extra_headers || {})
57
+ merged.merge!(extra_headers || {})
58
+ tcfg = ConfigResolution::ResolvedManagementConfig.new(
59
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
60
+ debug: cfg.debug, extra_headers: merged
61
+ )
62
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
63
+ logging_http = Transport.build_api_client(SmplkitGeneratedClient::Logging, "logging", tcfg, base_url: base_url)
64
+ app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
65
+ [logging_http, app_http, app_url, resolved_key]
66
+ end
67
+
68
+ # Discover and load the SDK's built-in logging adapters.
69
+ def self.auto_load_adapters
70
+ adapters = [Adapters::StdlibLoggerAdapter.new]
71
+
72
+ begin
73
+ require "semantic_logger"
74
+ require_relative "adapters/semantic_logger_adapter"
75
+ adapters << Adapters::SemanticLoggerAdapter.new
76
+ rescue LoadError
77
+ Smplkit.debug("registration", "semantic_logger gem not installed; semantic-logger adapter skipped")
78
+ end
79
+
80
+ adapters
81
+ end
82
+
83
+ LoggerChangeEvent = Struct.new(:name, :level, :source, keyword_init: true) do
84
+ def ==(other)
85
+ other.is_a?(LoggerChangeEvent) &&
86
+ name == other.name && level == other.level && source == other.source
87
+ end
88
+ end
89
+
90
+ # Surface for +client.logging.loggers.*+ (sync).
12
91
  #
13
- # Level resolution is client-side: the server stores raw configuration
14
- # and the SDK walks the chain (env override → base → group chain →
15
- # dot-notation ancestry) to compute each managed logger's effective
16
- # level. See {Smplkit::Logging::Resolution}.
92
+ # Logger CRUD plus the discovery buffer. The buffer is owned by the fused
93
+ # +LoggingClient+ and shared here so discovery (driven by
94
+ # +LoggingClient#install+) and explicit +register+ drain through one queue.
95
+ class LoggersClient
96
+ def initialize(http_client, buffer:)
97
+ @api = SmplkitGeneratedClient::Logging::LoggersApi.new(http_client)
98
+ @buffer = buffer
99
+ end
100
+
101
+ # Buffer logger sources for registration; optionally flush immediately.
102
+ def register(items, flush: false)
103
+ batch = items.is_a?(Array) ? items : [items]
104
+ batch.each do |src|
105
+ @buffer.add(LoggerSource.new(
106
+ name: Normalize.normalize_logger_name(src.name),
107
+ resolved_level: src.resolved_level, level: src.level,
108
+ service: src.service, environment: src.environment
109
+ ))
110
+ end
111
+ if flush
112
+ self.flush
113
+ return
114
+ end
115
+ return unless @buffer.pending_count >= LOGGER_BATCH_FLUSH_SIZE
116
+
117
+ Thread.new { threshold_flush }
118
+ end
119
+
120
+ # Drain the buffer and POST pending logger sources to the bulk endpoint.
121
+ def flush
122
+ batch = @buffer.drain
123
+ return if batch.empty?
124
+
125
+ items = batch.map do |entry|
126
+ SmplkitGeneratedClient::Logging::LoggerBulkItem.new(
127
+ id: entry["id"], resolved_level: entry["resolved_level"], level: entry["level"],
128
+ service: entry["service"], environment: entry["environment"]
129
+ )
130
+ end
131
+ body = SmplkitGeneratedClient::Logging::LoggerBulkRequest.new(loggers: items)
132
+ ApiSupport::ErrorMapping.call { @api.bulk_register_loggers(body) }
133
+ end
134
+
135
+ # Synchronous flush — alias of +flush+ for the periodic-flush path.
136
+ def flush_sync
137
+ flush
138
+ end
139
+
140
+ # Number of sources queued and awaiting flush.
141
+ def pending_count
142
+ @buffer.pending_count
143
+ end
144
+
145
+ # Return a new unsaved +SmplLogger+. Call +SmplLogger#save+ to persist.
146
+ def new(id, managed: true)
147
+ SmplLogger.new(self, id: id, name: id, resolved_level: nil, managed: managed)
148
+ end
149
+
150
+ # List loggers for the authenticated account.
151
+ def list(page_number: nil, page_size: nil)
152
+ opts = {}
153
+ opts[:page_number] = page_number unless page_number.nil?
154
+ opts[:page_size] = page_size unless page_size.nil?
155
+ response = ApiSupport::ErrorMapping.call { @api.list_loggers(opts) }
156
+ (response.data || []).map { |r| Helpers.logger_resource_to_model(self, ApiSupport::ResourceShim.from_model(r)) }
157
+ end
158
+
159
+ # Fetch the editable +SmplLogger+ resource by id.
160
+ def get(id)
161
+ response = ApiSupport::ErrorMapping.call { @api.get_logger(id) }
162
+ Helpers.logger_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
163
+ end
164
+
165
+ # Delete a logger by id.
166
+ def delete(id)
167
+ ApiSupport::ErrorMapping.call { @api.delete_logger(id) }
168
+ nil
169
+ end
170
+
171
+ def _update_logger(logger)
172
+ response = ApiSupport::ErrorMapping.call { @api.update_logger(logger.id || logger.name, logger_body(logger)) }
173
+ Helpers.logger_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
174
+ end
175
+
176
+ # Runtime entry — walks every page and returns an id-keyed Hash of
177
+ # resolution-cache entries (+level+, +group+, +managed+, +environments+).
178
+ def list_logger_entries
179
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_loggers(opts) }
180
+ rows.to_h { |r| logger_entry_from_resource(ApiSupport::ResourceShim.from_model(r)) }
181
+ end
182
+
183
+ # Fetch one logger as a resolution-cache entry. Used by the +logger_changed+
184
+ # WS handler.
185
+ def get_logger_entry(id)
186
+ response = ApiSupport::ErrorMapping.call { @api.get_logger(id) }
187
+ logger_entry_from_resource(ApiSupport::ResourceShim.from_model(response.data))
188
+ end
189
+
190
+ private
191
+
192
+ def threshold_flush
193
+ flush
194
+ rescue StandardError => e
195
+ Smplkit.debug("registration", "logger registration flush failed: #{e.class}: #{e.message}")
196
+ end
197
+
198
+ def logger_entry_from_resource(resource)
199
+ attrs = resource["attributes"] || {}
200
+ [
201
+ resource["id"],
202
+ {
203
+ "level" => attrs["level"],
204
+ "group" => attrs["group"],
205
+ "managed" => attrs.key?("managed") ? attrs["managed"] : true,
206
+ "environments" => attrs["environments"] || {}
207
+ }
208
+ ]
209
+ end
210
+
211
+ def logger_body(logger)
212
+ # Logger server schema: name, level, group, managed, environments.
213
+ # +resolved_level+ is read-only, +service+/+environment+ are observed via
214
+ # bulk register, +description+ is wrapper-local. The PUT is a full
215
+ # replace, so the (possibly empty) environments map is always sent —
216
+ # omitting it would leave a cleared override in place server-side.
217
+ SmplkitGeneratedClient::Logging::LoggerResponse.new(
218
+ data: SmplkitGeneratedClient::Logging::LoggerResource.new(
219
+ type: "logger",
220
+ id: logger.id,
221
+ attributes: SmplkitGeneratedClient::Logging::Logger.new(
222
+ name: logger.name,
223
+ level: logger.level&.to_s,
224
+ group: logger.log_group_id,
225
+ managed: logger.managed,
226
+ environments: Logging.environments_to_wire(logger.environments)
227
+ )
228
+ )
229
+ )
230
+ end
231
+ end
232
+
233
+ # Surface for +client.logging.log_groups.*+ (sync).
234
+ class LogGroupsClient
235
+ def initialize(http_client)
236
+ @api = SmplkitGeneratedClient::Logging::LogGroupsApi.new(http_client)
237
+ end
238
+
239
+ # Return a new unsaved +SmplLogGroup+. Call +SmplLogGroup#save+ to persist.
240
+ def new(id, name: nil, group: nil)
241
+ SmplLogGroup.new(
242
+ self, key: id, name: name.nil? ? Smplkit::Helpers.key_to_display_name(id) : name, parent_id: group
243
+ )
244
+ end
245
+
246
+ # List log groups for the authenticated account.
247
+ def list(page_number: nil, page_size: nil)
248
+ opts = {}
249
+ opts[:page_number] = page_number unless page_number.nil?
250
+ opts[:page_size] = page_size unless page_size.nil?
251
+ response = ApiSupport::ErrorMapping.call { @api.list_log_groups(opts) }
252
+ (response.data || []).map do |r|
253
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(r))
254
+ end
255
+ end
256
+
257
+ # Fetch the editable +SmplLogGroup+ resource by id.
258
+ def get(id)
259
+ response = ApiSupport::ErrorMapping.call { @api.get_log_group(id) }
260
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
261
+ end
262
+
263
+ # Delete a log group by id.
264
+ def delete(id)
265
+ ApiSupport::ErrorMapping.call { @api.delete_log_group(id) }
266
+ nil
267
+ end
268
+
269
+ def _create_log_group(group)
270
+ response = ApiSupport::ErrorMapping.call { @api.create_log_group(log_group_body(group)) }
271
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
272
+ end
273
+
274
+ def _update_log_group(group)
275
+ response = ApiSupport::ErrorMapping.call { @api.update_log_group(group.key, log_group_body(group)) }
276
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
277
+ end
278
+
279
+ # Runtime entry — walks every page and returns an id-keyed Hash of
280
+ # resolution-cache entries (+level+, +group+, +environments+). The +group+
281
+ # key carries the *parent group id* so the resolution algorithm can walk
282
+ # the chain with the same key shape it uses for loggers.
283
+ def list_group_entries
284
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_log_groups(opts) }
285
+ rows.to_h { |r| group_entry_from_resource(ApiSupport::ResourceShim.from_model(r)) }
286
+ end
287
+
288
+ def get_group_entry(key)
289
+ response = ApiSupport::ErrorMapping.call { @api.get_log_group(key) }
290
+ group_entry_from_resource(ApiSupport::ResourceShim.from_model(response.data))
291
+ end
292
+
293
+ private
294
+
295
+ def group_entry_from_resource(resource)
296
+ attrs = resource["attributes"] || {}
297
+ [
298
+ resource["id"],
299
+ {
300
+ "level" => attrs["level"],
301
+ "group" => attrs["parent_id"],
302
+ "environments" => attrs["environments"] || {}
303
+ }
304
+ ]
305
+ end
306
+
307
+ def log_group_body(group)
308
+ # LogGroup server schema: name, level, parent_id (no description).
309
+ SmplkitGeneratedClient::Logging::LogGroupResponse.new(
310
+ data: SmplkitGeneratedClient::Logging::LogGroupResource.new(
311
+ type: "log_group",
312
+ id: group.key,
313
+ attributes: SmplkitGeneratedClient::Logging::LogGroup.new(
314
+ name: group.name,
315
+ level: group.level&.to_s,
316
+ parent_id: group.parent_id
317
+ )
318
+ )
319
+ )
320
+ end
321
+ end
322
+
323
+ # The Smpl Logging client (sync).
324
+ #
325
+ # One client exposes the full surface, reachable as +client.logging+
326
+ # (+Smplkit::Client+) or constructed directly:
327
+ #
328
+ # logging = Smplkit::LoggingClient.new(environment: "production", service: "my-svc")
329
+ # logging.loggers.new("sqlalchemy.engine").save
330
+ # logging.install
17
331
  #
18
- # Change-listener contract every call the SDK makes to
19
- # +adapter.apply_level(logger_id, new_level)+ is paired with exactly one
20
- # listener notification for that logger, and every notification
21
- # corresponds to exactly one adapter apply. A trigger that moves N
22
- # loggers' effective levels invokes the global listener N times (once
23
- # per logger), each invocation also fires every matching key-scoped
24
- # listener for that id. There are no batch / summary events and no
25
- # deletion-flavored events — logger / group deletions only emit
26
- # listener invocations for *dependents* whose computed effective level
27
- # actually moved; the deleted key itself emits nothing.
332
+ # The management surface (+loggers+ / +log_groups+ sub-clients) works
333
+ # immediately. +register_adapter+ is a pre-install configuration call. The
334
+ # live surface (+install+ / +on_change+ / +refresh+) requires +install+
335
+ # first; calling +on_change+ / +refresh+ earlier raises +NotInstalledError+.
28
336
  class LoggingClient
29
- def initialize(parent, manage:, metrics:, logging_base_url:, app_base_url:)
337
+ attr_reader :loggers, :log_groups
338
+
339
+ def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
340
+ base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
341
+ parent: nil, transport: nil, metrics: nil)
30
342
  @parent = parent
31
- @manage = manage
32
343
  @metrics = metrics
33
- @logging_base_url = logging_base_url
34
- @app_base_url = app_base_url
35
- @adapters = []
36
- @installed = false
344
+ @environment = parent.nil? ? environment : parent._environment
345
+ @service = parent&._service
346
+ @standalone_api_key = nil
347
+ if transport.nil?
348
+ @logging_http, _app_http, @app_base_url, @standalone_api_key = Logging.logging_transport(
349
+ api_key: api_key, base_url: base_url, profile: profile,
350
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
351
+ )
352
+ else
353
+ @logging_http = transport
354
+ @app_base_url = nil
355
+ end
356
+
357
+ # Discovery buffer is owned by this client; the loggers sub-client shares
358
+ # it so discovery and explicit registration drain together.
359
+ @buffer = LoggerRegistrationBuffer.new
360
+ @loggers = LoggersClient.new(@logging_http, buffer: @buffer)
361
+ @log_groups = LogGroupsClient.new(@logging_http)
362
+
363
+ # Live-surface state.
364
+ @connected = false
365
+ @name_map = {} # original_name → normalized_id
366
+ @loggers_cache = {} # id → logger data
367
+ @groups_cache = {} # id → group data
37
368
  @global_listeners = []
38
369
  @key_listeners = Hash.new { |h, k| h[k] = [] }
370
+ @adapters = []
371
+ @explicit_adapters = false
372
+ @ws_manager = nil
373
+ @owns_ws = false
39
374
  @lock = Mutex.new
40
- # original_name → normalized_id for every adapter-discovered logger.
41
- # We keep originals so adapter.apply_level receives whatever the
42
- # framework's registry indexes by.
43
- @name_map = {}
44
- # normalized_id → resolution-cache entry.
45
- @loggers_cache = {}
46
- # group id → resolution-cache entry. Without this, any managed
47
- # logger with +level=null+ that inherits from a group silently
48
- # keeps whatever level its adapter had at startup.
49
- @groups_cache = {}
50
- # normalized_id → last-applied resolved level (string). Drives the
51
- # lockstep between adapter.apply_level and listener notifications:
52
- # we only push (and fire) when the freshly-resolved level differs
53
- # from what's recorded here.
54
- @resolved_levels = {}
55
- end
56
-
57
- # Install the logging integration.
58
- #
59
- # Auto-loads the +stdlib-logger+ adapter (always) and the
60
- # +semantic-logger+ adapter (when the gem is available). Customer
61
- # explicit registration via +register_adapter+ wins over auto-load.
62
- def install
63
- return self if @installed
64
-
65
- auto_load_adapters if @adapters.empty?
66
-
67
- @adapters.each do |adapter|
68
- discovered = adapter.discover
69
- discovered.each { |(name, _explicit, effective)| observe_logger(adapter, name, effective) }
70
- adapter.install_hook { |name, _explicit, effective| observe_logger(adapter, name, effective) }
71
- end
72
-
73
- flush_initial_registration
74
- fetch_and_apply(trigger: "install", source: "manual")
75
-
76
- @ws_manager = @parent._ensure_ws
77
- @ws_manager.on("logger_changed") { |data| handle_logger_changed(data) }
78
- @ws_manager.on("logger_deleted") { |data| handle_logger_deleted(data) }
79
- @ws_manager.on("group_changed") { |data| handle_group_changed(data) }
80
- @ws_manager.on("group_deleted") { |data| handle_group_deleted(data) }
81
- @ws_manager.on("loggers_changed") { |data| handle_loggers_changed(data) }
82
- @installed = true
83
- self
84
375
  end
85
- alias start install
86
376
 
377
+ # --- Adapter registration (pre-install, ungated) ---
378
+
379
+ # Register a logging adapter. Must be called before install().
380
+ #
381
+ # If called at least once, auto-loading is disabled — only explicitly
382
+ # registered adapters are used. This is a pre-install configuration call:
383
+ # it is intentionally NOT gated by +install+.
87
384
  def register_adapter(adapter)
385
+ raise "Cannot register adapters after install()" if @connected
88
386
  unless adapter.is_a?(Adapters::Base)
89
387
  raise ArgumentError, "adapter must implement Smplkit::Logging::Adapters::Base"
90
388
  end
91
389
 
390
+ @explicit_adapters = true
92
391
  @adapters << adapter
93
392
  self
94
393
  end
@@ -97,197 +396,353 @@ module Smplkit
97
396
  @adapters.dup
98
397
  end
99
398
 
100
- def get(name)
101
- @manage.loggers.get(name)
102
- end
399
+ # --- Live surface: install (gate) + transport / WebSocket helpers ---
103
400
 
104
- def list(page_number: nil, page_size: nil)
105
- @manage.loggers.list(page_number: page_number, page_size: page_size)
106
- end
401
+ # Hook smplkit into the application's logging machinery.
402
+ #
403
+ # Loads adapters, scans existing loggers, applies levels from the smplkit
404
+ # server, and wires WebSocket handlers for live updates. This IS the
405
+ # explicit consent gate — +on_change+ / +refresh+ require it first.
406
+ #
407
+ # Idempotent — safe to call multiple times.
408
+ def install
409
+ Smplkit.debug("lifecycle", "LoggingClient.install() called")
410
+ @parent&._ensure_started
411
+ return self if @connected
107
412
 
108
- def delete(name)
109
- @manage.loggers.delete(name)
110
- end
413
+ # 0. Load adapters
414
+ @adapters = Logging.auto_load_adapters if @adapters.empty?
111
415
 
112
- # Re-fetch all loggers and groups and re-apply resolved levels. Fires
113
- # listeners only for loggers whose effective level moved.
114
- def refresh
115
- fetch_and_apply(trigger: "refresh", source: "manual")
416
+ # 1. Discover existing loggers from all adapters (keep discovery and hook
417
+ # installation as two passes discover every adapter before any hook is
418
+ # live, mirroring the Python SDK).
419
+ # rubocop:disable Style/CombinableLoops
420
+ @adapters.each do |adapter|
421
+ existing = adapter.discover
422
+ existing.each do |name, explicit_level, effective_level|
423
+ @name_map[name] = Normalize.normalize_logger_name(name)
424
+ @loggers.register(loggersource_for(name, explicit_level, effective_level))
425
+ end
426
+ rescue StandardError => e
427
+ Smplkit.debug("logging", "adapter #{adapter.name} discover failed: #{e.class}: #{e.message}")
428
+ end
429
+
430
+ # 2. Install continuous discovery hooks
431
+ @adapters.each do |adapter|
432
+ adapter.install_hook { |name, explicit, effective| on_new_logger(name, explicit, effective) }
433
+ rescue StandardError => e
434
+ Smplkit.debug("logging", "adapter #{adapter.name} install_hook failed: #{e.class}: #{e.message}")
435
+ end
436
+ # rubocop:enable Style/CombinableLoops
437
+
438
+ # 3. Flush initial batch
439
+ begin
440
+ @loggers.flush
441
+ rescue StandardError => e
442
+ Smplkit.debug("registration", "bulk logger registration failed: #{e.class}: #{e.message}")
443
+ end
444
+
445
+ # 4-6. Fetch, resolve, apply
446
+ begin
447
+ fetch_and_apply(trigger: "install()")
448
+ rescue StandardError => e
449
+ Smplkit.debug("resolution",
450
+ "failed to fetch/apply logging levels during connect " \
451
+ "(logging: #{@logging_http&.config&.host}): #{e.class}: #{e.message}")
452
+ end
453
+
454
+ # 7. Register WebSocket event handlers for real-time level updates
455
+ @ws_manager = ensure_ws
456
+ ws_handlers.each { |event, handler| @ws_manager.on(event, &handler) }
457
+
458
+ @connected = true
459
+ self
116
460
  end
117
461
 
462
+ # --- Live surface: change listeners ---
463
+
464
+ # Register a change listener.
465
+ #
466
+ # client.logging.on_change { |event| ... } # global
467
+ # client.logging.on_change("sqlalchemy.engine") { |e| ... } # key-scoped
468
+ #
469
+ # Requires +install+ first; raises +NotInstalledError+ otherwise.
118
470
  def on_change(name = nil, &block)
471
+ require_installed
119
472
  raise ArgumentError, "on_change requires a block" unless block
120
473
 
121
474
  if name.nil?
122
475
  @global_listeners << block
123
476
  else
124
- @key_listeners[Normalize.normalize_logger_name(name)] << block
477
+ @key_listeners[name] << block
125
478
  end
126
479
  block
127
480
  end
128
481
 
129
- def _close
130
- @adapters.each(&:uninstall_hook) if @installed
131
- @installed = false
482
+ # Re-fetch all loggers and groups and fire listener events for any deltas.
483
+ #
484
+ # Requires +install+ first; raises +NotInstalledError+ otherwise.
485
+ def refresh
486
+ require_installed
487
+ Smplkit.debug("resolution", "refresh() called, triggering full resolution pass")
488
+ fetch_and_apply_deltas(trigger: "refresh()", source: "manual")
132
489
  end
133
490
 
134
- private
135
-
136
- def auto_load_adapters
137
- @adapters << Adapters::StdlibLoggerAdapter.new
491
+ # Release resources — only those this client owns.
492
+ #
493
+ # Uninstalls the adapter hooks, unsubscribes from the WebSocket, and tears
494
+ # down the owned WebSocket (standalone install). A wired client borrows the
495
+ # parent's transport and WebSocket and closes neither.
496
+ def close
497
+ Smplkit.debug("lifecycle", "LoggingClient.close() called")
498
+ @adapters.each do |adapter|
499
+ adapter.uninstall_hook
500
+ rescue StandardError => e
501
+ Smplkit.debug("logging", "adapter #{adapter.name} uninstall_hook failed: #{e.class}: #{e.message}")
502
+ end
503
+ if @ws_manager
504
+ ws_handlers.each { |event, handler| @ws_manager.off(event, handler) }
505
+ if @owns_ws
506
+ @ws_manager.stop
507
+ @owns_ws = false
508
+ end
509
+ @ws_manager = nil
510
+ end
511
+ @connected = false
512
+ end
513
+ alias _close close
138
514
 
515
+ # Construct, yield to the block, and close on exit.
516
+ def self.open(**kwargs)
517
+ client = new(**kwargs)
139
518
  begin
140
- require "semantic_logger"
141
- require_relative "adapters/semantic_logger_adapter"
142
- @adapters << Adapters::SemanticLoggerAdapter.new
143
- rescue LoadError
144
- Smplkit.debug("registration", "semantic_logger gem not installed; semantic-logger adapter skipped")
519
+ yield client
520
+ ensure
521
+ client.close
145
522
  end
523
+ end
146
524
 
147
- return unless @adapters.empty?
525
+ private
148
526
 
149
- # Defensive log — unreachable in practice because stdlib +Logger+
150
- # is always present, so +StdlibLoggerAdapter+ is always
151
- # constructible.
152
- # :nocov:
153
- Smplkit.debug("registration", "no logging adapters loaded; runtime features disabled")
154
- # :nocov:
527
+ def require_installed
528
+ raise NotInstalledError, NOT_INSTALLED_MESSAGE unless @connected
155
529
  end
156
530
 
157
- def observe_logger(_adapter, raw_name, level)
158
- normalized = Normalize.normalize_logger_name(raw_name)
159
- @name_map[raw_name] = normalized
160
- @manage.loggers.register(LoggerSource.new(
161
- name: normalized,
162
- resolved_level: level,
163
- level: nil,
164
- service: @parent._service,
165
- environment: @parent._environment
166
- ))
531
+ # Memoized event → handler map so +install+ registers and +close+
532
+ # unsubscribes the exact same callback objects. They are stored as procs
533
+ # (not bound methods) because +SharedWebSocket#off+ removes by object
534
+ # identity, and +on(event, &proc)+ stores the very proc passed here.
535
+ def ws_handlers
536
+ @ws_handlers ||= {
537
+ "logger_changed" => proc { |data| handle_logger_changed(data) },
538
+ "logger_deleted" => proc { |data| handle_logger_deleted(data) },
539
+ "group_changed" => proc { |data| handle_group_changed(data) },
540
+ "group_deleted" => proc { |data| handle_group_deleted(data) },
541
+ "loggers_changed" => proc { |data| handle_loggers_changed(data) }
542
+ }
167
543
  end
168
544
 
169
- def flush_initial_registration
170
- @manage.loggers.flush
171
- rescue StandardError => e
172
- Smplkit.debug("registration", "initial logger flush failed: #{e.class}: #{e.message}")
545
+ def ensure_ws
546
+ return @parent._ensure_ws unless @parent.nil?
547
+
548
+ if @ws_manager.nil?
549
+ @ws_manager = SharedWebSocket.new(
550
+ app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
551
+ )
552
+ @ws_manager.start
553
+ @owns_ws = true
554
+ end
555
+ @ws_manager
556
+ end
557
+
558
+ # --- Internal ---
559
+
560
+ # Build a LoggerSource from an adapter's (name, explicit, effective)
561
+ # discovery tuple.
562
+ def loggersource_for(name, explicit_level, effective_level)
563
+ LoggerSource.new(
564
+ name: name,
565
+ resolved_level: effective_level,
566
+ level: explicit_level,
567
+ service: @service,
568
+ environment: @environment
569
+ )
570
+ end
571
+
572
+ # Callback from adapters when a new logger is created.
573
+ def on_new_logger(name, explicit_level, effective_level)
574
+ normalized = Normalize.normalize_logger_name(name)
575
+ @name_map[name] = normalized
576
+ @loggers.register(loggersource_for(name, explicit_level, effective_level))
577
+
578
+ # If connected, try to apply level from cache.
579
+ return unless @connected && @loggers_cache.key?(normalized)
580
+
581
+ entry = @loggers_cache[normalized]
582
+ return unless entry["managed"]
583
+
584
+ resolved = Resolution.resolve_level(normalized, @environment, @loggers_cache, @groups_cache)
585
+ coerced = LogLevel.coerce(resolved)
586
+ push_to_adapters(name, coerced)
587
+ end
588
+
589
+ def push_to_adapters(original_name, coerced_level)
590
+ @adapters.each do |adapter|
591
+ adapter.apply_level(original_name, coerced_level)
592
+ rescue StandardError => e
593
+ Smplkit.debug("logging", "adapter #{adapter.name} apply_level failed for #{original_name}: " \
594
+ "#{e.class}: #{e.message}")
595
+ end
173
596
  end
174
597
 
175
- # Full re-fetch of loggers + groups, then apply resolved levels.
176
- def fetch_and_apply(trigger:, source: "websocket")
598
+ # Re-fetch loggers/groups into the cache (no apply, no fire).
599
+ def fetch_cache(trigger)
177
600
  Smplkit.debug("resolution", "full resolution pass starting (trigger: #{trigger})")
178
- loggers = @manage.loggers.list_logger_entries
179
- groups = @manage.log_groups.list_group_entries
180
- @loggers_cache = loggers
181
- @groups_cache = groups
182
- apply_levels(source: source)
183
- rescue StandardError => e
184
- Smplkit.debug("resolution", "fetch_and_apply failed (trigger: #{trigger}): #{e.class}: #{e.message}")
185
- end
186
-
187
- # Apply newly-resolved levels in lockstep with listener
188
- # notifications. For every locally-tracked managed logger whose
189
- # freshly-computed effective level differs from the last applied
190
- # value: push to adapters, then fire each global + matching
191
- # key-scoped listener. No adapter push happens without a paired
192
- # listener notification, and no notification fires without a
193
- # paired adapter push.
194
- def apply_levels(source: "websocket")
195
- @name_map.each do |raw_name, normalized_id|
601
+ @loggers_cache = @loggers.list_logger_entries
602
+ @groups_cache = @log_groups.list_group_entries
603
+ end
604
+
605
+ # Fetch loggers/groups and unconditionally apply levels (initial install
606
+ # path).
607
+ #
608
+ # Silent — does not fire change-listener events. Use
609
+ # +fetch_and_apply_deltas+ from the WS / refresh paths to get per-logger
610
+ # fanout.
611
+ def fetch_and_apply(trigger: "unknown")
612
+ fetch_cache(trigger)
613
+ apply_levels
614
+ end
615
+
616
+ # Fetch loggers/groups; apply + fire listeners only on effective-level
617
+ # deltas.
618
+ def fetch_and_apply_deltas(trigger:, source:)
619
+ pre = snapshot_effective_levels
620
+ fetch_cache(trigger)
621
+ apply_deltas_and_fire(pre, source)
622
+ end
623
+
624
+ # Apply resolved levels to all managed, locally-present loggers.
625
+ def apply_levels
626
+ @name_map.each do |original_name, normalized_id|
196
627
  entry = @loggers_cache[normalized_id]
197
628
  next if entry.nil?
198
629
  next unless entry["managed"]
199
630
 
200
- resolved_string = Resolution.resolve_level(
201
- normalized_id, @parent._environment, @loggers_cache, @groups_cache
202
- )
203
- previous = @resolved_levels[normalized_id]
204
- next if previous == resolved_string
631
+ resolved = Resolution.resolve_level(normalized_id, @environment, @loggers_cache, @groups_cache)
632
+ push_to_adapters(original_name, LogLevel.coerce(resolved))
633
+ @metrics&.record("logging.level_changes", unit: "changes", dimensions: { "logger" => normalized_id })
634
+ end
635
+ end
636
+
637
+ # Effective level for every locally-tracked managed logger.
638
+ #
639
+ # This is the universe of loggers the adapter applies levels to — the only
640
+ # loggers whose listener can fire. A logger not in +name_map+ (never
641
+ # instantiated locally) or marked +managed=false+ in the cache is excluded.
642
+ def snapshot_effective_levels
643
+ snapshot = {}
644
+ @name_map.each_value do |normalized_id|
645
+ entry = @loggers_cache[normalized_id]
646
+ next if entry.nil? || !entry["managed"]
205
647
 
206
- coerced = LogLevel.coerce(resolved_string)
207
- @resolved_levels[normalized_id] = resolved_string
208
- push_to_adapters(raw_name, coerced)
209
- fire_change_event(normalized_id, coerced, source: source)
648
+ snapshot[normalized_id] = Resolution.resolve_level(
649
+ normalized_id, @environment, @loggers_cache, @groups_cache
650
+ )
210
651
  end
652
+ snapshot
211
653
  end
212
654
 
213
- def push_to_adapters(raw_name, coerced_level)
214
- @adapters.each do |a|
215
- a.apply_level(raw_name, coerced_level)
216
- rescue StandardError => e
217
- Smplkit.debug("logging", "adapter apply_level raised: #{e.class}: #{e.message}")
655
+ # Apply + fire per-logger whenever the effective level moved.
656
+ #
657
+ # For every locally-tracked managed logger, recompute the effective level
658
+ # and compare to +pre+. On a delta: call +apply_level+ on every adapter AND
659
+ # fire one +LoggerChangeEvent+ per affected logger — once to each matching
660
+ # key-scoped listener and once to every global listener (a global is
661
+ # semantically a key-scoped subscription on every logger). No-op when
662
+ # nothing moved: no apply, no fire.
663
+ def apply_deltas_and_fire(pre, source)
664
+ @name_map.each do |original_name, normalized_id|
665
+ entry = @loggers_cache[normalized_id]
666
+ next if entry.nil? || !entry["managed"]
667
+
668
+ new_level = Resolution.resolve_level(normalized_id, @environment, @loggers_cache, @groups_cache)
669
+ next if pre[normalized_id] == new_level
670
+
671
+ push_to_adapters(original_name, LogLevel.coerce(new_level))
672
+ @metrics&.record("logging.level_changes", unit: "changes", dimensions: { "logger" => normalized_id })
673
+ fire_for_logger(normalized_id, new_level, source)
218
674
  end
219
675
  end
220
676
 
221
- def fire_change_event(normalized_id, level, source:)
222
- event = LoggerChangeEvent.new(name: normalized_id, level: level, source: source)
223
- (@global_listeners + @key_listeners[normalized_id]).each do |cb|
677
+ # Fire one +LoggerChangeEvent+ to every matching subscriber.
678
+ #
679
+ # Both the key-scoped listeners registered for +logger_id+ and every global
680
+ # listener receive the same payload.
681
+ def fire_for_logger(logger_id, level, source)
682
+ event = LoggerChangeEvent.new(name: logger_id, level: level, source: source)
683
+ (@global_listeners + @key_listeners[logger_id]).each do |cb|
224
684
  cb.call(event)
225
685
  rescue StandardError => e
226
686
  Smplkit.debug("logging", "listener raised: #{e.class}: #{e.message}")
227
687
  end
228
688
  end
229
689
 
230
- def handle_logger_changed(data)
231
- key = data["id"] || data["name"] || ""
232
- normalized = Normalize.normalize_logger_name(key)
233
- return if normalized.empty?
690
+ # --- Internal: event handlers (called by SharedWebSocket) ---
234
691
 
692
+ def handle_logger_changed(data)
693
+ key = data["id"] || ""
694
+ Smplkit.debug("websocket", "logger_changed: fetching logger #{key.inspect}")
695
+ pre = snapshot_effective_levels
235
696
  begin
236
- entry_id, entry = @manage.loggers.get_logger_entry(normalized)
237
- @loggers_cache[entry_id || normalized] = entry
697
+ entry_id, entry = @loggers.get_logger_entry(key)
698
+ @loggers_cache[entry_id || key] = entry
238
699
  rescue StandardError => e
239
- Smplkit.debug("websocket", "logger_changed fetch failed for #{normalized.inspect}: #{e.class}: #{e.message}")
700
+ Smplkit.debug("websocket", "failed to fetch logger #{key.inspect} after WS event: #{e.class}: #{e.message}")
240
701
  return
241
702
  end
242
-
243
- apply_levels(source: "websocket")
703
+ apply_deltas_and_fire(pre, "websocket")
244
704
  end
245
705
 
246
- # Deletion is a pure cache eviction. The deleted key itself fires
247
- # nothing; dependents whose effective level moves fire through the
248
- # normal apply path.
249
706
  def handle_logger_deleted(data)
250
- key = data["id"] || data["name"] || ""
251
- normalized = Normalize.normalize_logger_name(key)
252
- return if normalized.empty?
253
-
254
- @loggers_cache.delete(normalized)
255
- apply_levels(source: "websocket")
707
+ key = data["id"] || ""
708
+ Smplkit.debug("websocket", "logger_deleted: removing logger #{key.inspect}")
709
+ pre = snapshot_effective_levels
710
+ @loggers_cache.delete(key)
711
+ apply_deltas_and_fire(pre, "websocket")
256
712
  end
257
713
 
258
714
  def handle_group_changed(data)
259
- key = data["id"] || data["key"] || ""
260
- return if key.to_s.empty?
261
-
715
+ key = data["id"] || ""
716
+ Smplkit.debug("websocket", "group_changed: fetching group #{key.inspect}")
717
+ pre = snapshot_effective_levels
262
718
  begin
263
- entry_id, entry = @manage.log_groups.get_group_entry(key)
719
+ entry_id, entry = @log_groups.get_group_entry(key)
264
720
  @groups_cache[entry_id || key] = entry
265
721
  rescue StandardError => e
266
- Smplkit.debug("websocket", "group_changed fetch failed for #{key.inspect}: #{e.class}: #{e.message}")
722
+ Smplkit.debug("websocket",
723
+ "failed to fetch log group #{key.inspect} after WS event: #{e.class}: #{e.message}")
267
724
  return
268
725
  end
269
-
270
- apply_levels(source: "websocket")
726
+ apply_deltas_and_fire(pre, "websocket")
271
727
  end
272
728
 
273
729
  def handle_group_deleted(data)
274
- key = data["id"] || data["key"] || ""
275
- return if key.to_s.empty?
276
-
730
+ key = data["id"] || ""
731
+ Smplkit.debug("websocket", "group_deleted: removing group #{key.inspect}")
732
+ pre = snapshot_effective_levels
277
733
  @groups_cache.delete(key)
278
- apply_levels(source: "websocket")
734
+ apply_deltas_and_fire(pre, "websocket")
279
735
  end
280
736
 
281
737
  def handle_loggers_changed(_data)
282
- fetch_and_apply(trigger: "loggers_changed WS event")
283
- end
284
- end
285
-
286
- LoggerChangeEvent = Struct.new(:name, :level, :source, keyword_init: true) do
287
- def ==(other)
288
- other.is_a?(LoggerChangeEvent) &&
289
- name == other.name && level == other.level && source == other.source
738
+ Smplkit.debug("websocket", "loggers_changed: full re-fetch")
739
+ fetch_and_apply_deltas(trigger: "loggers_changed WS event", source: "websocket")
740
+ rescue StandardError => e
741
+ Smplkit.debug("websocket",
742
+ "failed to re-fetch/apply logging levels after loggers_changed event: #{e.class}: #{e.message}")
290
743
  end
291
744
  end
292
745
  end
746
+
747
+ LoggingClient = Logging::LoggingClient
293
748
  end