smplkit 3.0.95 → 3.0.97

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/account/client.rb +128 -0
  3. data/lib/smplkit/account/models.rb +71 -0
  4. data/lib/smplkit/api_support.rb +91 -0
  5. data/lib/smplkit/audit/buffer.rb +3 -1
  6. data/lib/smplkit/audit/categories.rb +21 -10
  7. data/lib/smplkit/audit/client.rb +18 -9
  8. data/lib/smplkit/audit/event_types.rb +26 -10
  9. data/lib/smplkit/audit/events.rb +93 -17
  10. data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +93 -85
  11. data/lib/smplkit/audit/models.rb +86 -32
  12. data/lib/smplkit/audit/resource_types.rb +21 -9
  13. data/lib/smplkit/buffers.rb +250 -0
  14. data/lib/smplkit/client.rb +161 -70
  15. data/lib/smplkit/config/client.rb +874 -186
  16. data/lib/smplkit/config/helpers.rb +44 -6
  17. data/lib/smplkit/config/models.rb +114 -7
  18. data/lib/smplkit/config_resolution.rb +17 -9
  19. data/lib/smplkit/errors.rb +14 -3
  20. data/lib/smplkit/flags/client.rb +602 -116
  21. data/lib/smplkit/flags/models.rb +110 -8
  22. data/lib/smplkit/flags/types.rb +8 -9
  23. data/lib/smplkit/jobs/client.rb +306 -0
  24. data/lib/smplkit/jobs/models.rb +47 -18
  25. data/lib/smplkit/logging/client.rb +755 -191
  26. data/lib/smplkit/logging/helpers.rb +5 -1
  27. data/lib/smplkit/logging/levels.rb +3 -1
  28. data/lib/smplkit/logging/models.rb +163 -6
  29. data/lib/smplkit/logging/normalize.rb +3 -1
  30. data/lib/smplkit/logging/resolution.rb +4 -4
  31. data/lib/smplkit/logging/sources.rb +1 -1
  32. data/lib/smplkit/platform/client.rb +597 -0
  33. data/lib/smplkit/platform/models.rb +282 -0
  34. data/lib/smplkit/{management → platform}/types.rb +21 -4
  35. data/lib/smplkit/transport.rb +103 -0
  36. data/lib/smplkit/ws.rb +1 -1
  37. data/lib/smplkit.rb +18 -6
  38. metadata +11 -7
  39. data/lib/smplkit/management/buffer.rb +0 -198
  40. data/lib/smplkit/management/client.rb +0 -1074
  41. data/lib/smplkit/management/jobs.rb +0 -226
  42. data/lib/smplkit/management/models.rb +0 -178
@@ -1,293 +1,857 @@
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
+ # * *CRUD 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
+ #
51
+ # @api private
52
+ def self.logging_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
53
+ cfg = ConfigResolution.resolve_client_config(
54
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
55
+ )
56
+ resolved_key = api_key.nil? ? cfg.api_key : api_key
57
+ merged = {}
58
+ merged.merge!(cfg.extra_headers || {})
59
+ merged.merge!(extra_headers || {})
60
+ tcfg = ConfigResolution::ResolvedClientConfig.new(
61
+ api_key: resolved_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
62
+ debug: cfg.debug, extra_headers: merged
63
+ )
64
+ app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
65
+ logging_http = Transport.build_api_client(SmplkitGeneratedClient::Logging, "logging", tcfg, base_url: base_url)
66
+ app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
67
+ [logging_http, app_http, app_url, resolved_key]
68
+ end
69
+
70
+ # Discover and load the SDK's built-in logging adapters.
71
+ #
72
+ # @api private
73
+ def self.auto_load_adapters
74
+ adapters = [Adapters::StdlibLoggerAdapter.new]
75
+
76
+ begin
77
+ require "semantic_logger"
78
+ require_relative "adapters/semantic_logger_adapter"
79
+ adapters << Adapters::SemanticLoggerAdapter.new
80
+ rescue LoadError
81
+ Smplkit.debug("registration", "semantic_logger gem not installed; semantic-logger adapter skipped")
82
+ end
83
+
84
+ adapters
85
+ end
86
+
87
+ # Fired once per managed logger whose effective level the SDK just applied.
12
88
  #
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}.
89
+ # @!attribute [rw] id
90
+ # @return [String] The affected logger's normalized id.
91
+ # @!attribute [rw] level
92
+ # @return [String] The newly-applied effective smplkit level string (e.g.
93
+ # +"INFO"+, +"DEBUG"+) — the same value the resolution algorithm returns.
94
+ # @!attribute [rw] source
95
+ # @return [String] Short string identifying the trigger — typically
96
+ # +"websocket"+ or +"manual"+ (a +refresh+ call).
97
+ LoggerChangeEvent = Struct.new(:id, :level, :source, keyword_init: true) do
98
+ def ==(other)
99
+ other.is_a?(LoggerChangeEvent) &&
100
+ id == other.id && level == other.level && source == other.source
101
+ end
102
+ end
103
+
104
+ # Surface for +client.logging.loggers.*+ (sync).
17
105
  #
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.
106
+ # Logger CRUD plus the discovery buffer. The buffer is owned by the fused
107
+ # +LoggingClient+ and shared here so discovery (driven by
108
+ # +LoggingClient#install+) and explicit +register+ drain through one queue.
109
+ class LoggersClient
110
+ def initialize(http_client, buffer:)
111
+ @api = SmplkitGeneratedClient::Logging::LoggersApi.new(http_client)
112
+ @buffer = buffer
113
+ end
114
+
115
+ # Queue one or more logger sources for registration with the server.
116
+ #
117
+ # Sources are buffered locally and sent in a batch. The batch is sent
118
+ # automatically once enough sources accumulate; pass +flush: true+ to send
119
+ # the current batch right away instead of waiting.
120
+ #
121
+ # @param items [LoggerSource, Array<LoggerSource>] A single logger source,
122
+ # or an array of them, to queue.
123
+ # @param flush [Boolean] When +true+, send the buffered sources immediately
124
+ # rather than waiting for the batch to fill.
125
+ # @return [void]
126
+ def register(items, flush: false)
127
+ batch = items.is_a?(Array) ? items : [items]
128
+ batch.each do |src|
129
+ @buffer.add(LoggerSource.new(
130
+ name: Normalize.normalize_logger_name(src.name),
131
+ resolved_level: src.resolved_level, level: src.level,
132
+ service: src.service, environment: src.environment
133
+ ))
134
+ end
135
+ if flush
136
+ self.flush
137
+ return
138
+ end
139
+ return unless @buffer.pending_count >= LOGGER_BATCH_FLUSH_SIZE
140
+
141
+ Thread.new { threshold_flush }
142
+ end
143
+
144
+ # Drain the buffer and POST pending logger sources to the bulk endpoint.
145
+ #
146
+ # @return [void]
147
+ def flush
148
+ batch = @buffer.drain
149
+ return if batch.empty?
150
+
151
+ items = batch.map do |entry|
152
+ SmplkitGeneratedClient::Logging::LoggerBulkItem.new(
153
+ id: entry["id"], resolved_level: entry["resolved_level"], level: entry["level"],
154
+ service: entry["service"], environment: entry["environment"]
155
+ )
156
+ end
157
+ body = SmplkitGeneratedClient::Logging::LoggerBulkRequest.new(loggers: items)
158
+ ApiSupport::ErrorMapping.call { @api.bulk_register_loggers(body) }
159
+ end
160
+
161
+ # Synchronous flush — alias of +flush+ for the periodic-flush path.
162
+ #
163
+ # @return [void]
164
+ def flush_sync
165
+ flush
166
+ end
167
+
168
+ # Number of sources queued and awaiting flush.
169
+ #
170
+ # @return [Integer] count of buffered sources not yet sent.
171
+ def pending_count
172
+ @buffer.pending_count
173
+ end
174
+
175
+ # Build a new unsaved logger. The returned +SmplLogger+ is local only;
176
+ # call its +SmplLogger#save+ to persist it.
177
+ #
178
+ # @param id [String] Identifier for the logger (its normalized name).
179
+ # @param managed [Boolean] When +true+ (the default), smplkit controls
180
+ # this logger's level at runtime. Set +false+ to register the logger for
181
+ # visibility without taking over its level.
182
+ # @return [SmplLogger] An unsaved logger bound to this client.
183
+ def new(id, managed: true)
184
+ SmplLogger.new(self, id: id, name: id, resolved_level: nil, managed: managed)
185
+ end
186
+
187
+ # List loggers for the authenticated account.
188
+ #
189
+ # @param page_number [Integer, nil] 1-based page index to fetch. When
190
+ # omitted, the server returns the first page.
191
+ # @param page_size [Integer, nil] Maximum number of loggers per page. When
192
+ # omitted, the server applies its default page size.
193
+ # @return [Array<SmplLogger>] The loggers on the requested page.
194
+ def list(page_number: nil, page_size: nil)
195
+ opts = {}
196
+ opts[:page_number] = page_number unless page_number.nil?
197
+ opts[:page_size] = page_size unless page_size.nil?
198
+ response = ApiSupport::ErrorMapping.call { @api.list_loggers(opts) }
199
+ (response.data || []).map { |r| Helpers.logger_resource_to_model(self, ApiSupport::ResourceShim.from_model(r)) }
200
+ end
201
+
202
+ # Fetch a single logger by id.
203
+ #
204
+ # @param id [String] Identifier of the logger to fetch.
205
+ # @return [SmplLogger] The editable logger resource.
206
+ # @raise [Smplkit::NotFoundError] If no logger with that id exists.
207
+ def get(id)
208
+ response = ApiSupport::ErrorMapping.call { @api.get_logger(id) }
209
+ Helpers.logger_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
210
+ end
211
+
212
+ # Delete a logger by id.
213
+ #
214
+ # @param id [String] Identifier of the logger to delete.
215
+ # @return [void]
216
+ # @raise [Smplkit::NotFoundError] If no logger with that id exists.
217
+ def delete(id)
218
+ ApiSupport::ErrorMapping.call { @api.delete_logger(id) }
219
+ nil
220
+ end
221
+
222
+ # @api private
223
+ def _update_logger(logger)
224
+ response = ApiSupport::ErrorMapping.call { @api.update_logger(logger.id || logger.name, logger_body(logger)) }
225
+ Helpers.logger_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
226
+ end
227
+
228
+ # Runtime entry — walks every page and returns an id-keyed Hash of
229
+ # resolution-cache entries (+level+, +group+, +managed+, +environments+).
230
+ #
231
+ # @api private
232
+ def list_logger_entries
233
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_loggers(opts) }
234
+ rows.to_h { |r| logger_entry_from_resource(ApiSupport::ResourceShim.from_model(r)) }
235
+ end
236
+
237
+ # Fetch one logger as a resolution-cache entry. Used by the +logger_changed+
238
+ # WS handler.
239
+ #
240
+ # @api private
241
+ def get_logger_entry(id)
242
+ response = ApiSupport::ErrorMapping.call { @api.get_logger(id) }
243
+ logger_entry_from_resource(ApiSupport::ResourceShim.from_model(response.data))
244
+ end
245
+
246
+ private
247
+
248
+ def threshold_flush
249
+ flush
250
+ rescue StandardError => e
251
+ Smplkit.debug("registration", "logger registration flush failed: #{e.class}: #{e.message}")
252
+ end
253
+
254
+ def logger_entry_from_resource(resource)
255
+ attrs = resource["attributes"] || {}
256
+ [
257
+ resource["id"],
258
+ {
259
+ "level" => attrs["level"],
260
+ "group" => attrs["group"],
261
+ "managed" => attrs.key?("managed") ? attrs["managed"] : true,
262
+ "environments" => attrs["environments"] || {}
263
+ }
264
+ ]
265
+ end
266
+
267
+ def logger_body(logger)
268
+ # Logger server schema: name, level, group, managed, environments.
269
+ # +resolved_level+ is read-only, +service+/+environment+ are observed via
270
+ # bulk register, +description+ is wrapper-local. The PUT is a full
271
+ # replace, so the (possibly empty) environments map is always sent —
272
+ # omitting it would leave a cleared override in place server-side.
273
+ SmplkitGeneratedClient::Logging::LoggerResponse.new(
274
+ data: SmplkitGeneratedClient::Logging::LoggerResource.new(
275
+ type: "logger",
276
+ id: logger.id,
277
+ attributes: SmplkitGeneratedClient::Logging::Logger.new(
278
+ name: logger.name,
279
+ level: logger.level&.to_s,
280
+ group: logger.log_group_id,
281
+ managed: logger.managed,
282
+ environments: Logging.environments_to_wire(logger.environments)
283
+ )
284
+ )
285
+ )
286
+ end
287
+ end
288
+
289
+ # Surface for +client.logging.log_groups.*+ (sync).
290
+ class LogGroupsClient
291
+ def initialize(http_client)
292
+ @api = SmplkitGeneratedClient::Logging::LogGroupsApi.new(http_client)
293
+ end
294
+
295
+ # Build a new unsaved log group. The returned +SmplLogGroup+ is local
296
+ # only; call its +SmplLogGroup#save+ to persist it.
297
+ #
298
+ # @param id [String] Identifier for the log group.
299
+ # @param name [String, nil] Human-readable display name. Defaults to a
300
+ # title-cased version of +id+ when omitted.
301
+ # @param group [String, nil] Identifier of the parent log group, when
302
+ # nesting groups. +nil+ for a top-level group.
303
+ # @return [SmplLogGroup] An unsaved log group bound to this client.
304
+ def new(id, name: nil, group: nil)
305
+ SmplLogGroup.new(
306
+ self, key: id, name: name.nil? ? Smplkit::Helpers.key_to_display_name(id) : name, group: group
307
+ )
308
+ end
309
+
310
+ # List log groups for the authenticated account.
311
+ #
312
+ # @param page_number [Integer, nil] 1-based page index to fetch. When
313
+ # omitted, the server returns the first page.
314
+ # @param page_size [Integer, nil] Maximum number of log groups per page.
315
+ # When omitted, the server applies its default page size.
316
+ # @return [Array<SmplLogGroup>] The log groups on the requested page.
317
+ def list(page_number: nil, page_size: nil)
318
+ opts = {}
319
+ opts[:page_number] = page_number unless page_number.nil?
320
+ opts[:page_size] = page_size unless page_size.nil?
321
+ response = ApiSupport::ErrorMapping.call { @api.list_log_groups(opts) }
322
+ (response.data || []).map do |r|
323
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(r))
324
+ end
325
+ end
326
+
327
+ # Fetch a single log group by id.
328
+ #
329
+ # @param id [String] Identifier of the log group to fetch.
330
+ # @return [SmplLogGroup] The editable log group resource.
331
+ # @raise [Smplkit::NotFoundError] If no log group with that id exists.
332
+ def get(id)
333
+ response = ApiSupport::ErrorMapping.call { @api.get_log_group(id) }
334
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
335
+ end
336
+
337
+ # Delete a log group by id.
338
+ #
339
+ # @param id [String] Identifier of the log group to delete.
340
+ # @return [void]
341
+ # @raise [Smplkit::NotFoundError] If no log group with that id exists.
342
+ def delete(id)
343
+ ApiSupport::ErrorMapping.call { @api.delete_log_group(id) }
344
+ nil
345
+ end
346
+
347
+ # @api private
348
+ def _create_log_group(group)
349
+ response = ApiSupport::ErrorMapping.call { @api.create_log_group(log_group_body(group)) }
350
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
351
+ end
352
+
353
+ # @api private
354
+ def _update_log_group(group)
355
+ response = ApiSupport::ErrorMapping.call { @api.update_log_group(group.key, log_group_body(group)) }
356
+ Helpers.log_group_resource_to_model(self, ApiSupport::ResourceShim.from_model(response.data))
357
+ end
358
+
359
+ # Runtime entry — walks every page and returns an id-keyed Hash of
360
+ # resolution-cache entries (+level+, +group+, +environments+). The +group+
361
+ # key carries the *parent group id* so the resolution algorithm can walk
362
+ # the chain with the same key shape it uses for loggers.
363
+ #
364
+ # @api private
365
+ def list_group_entries
366
+ rows = ApiSupport::PaginatedFetch.collect { |opts| @api.list_log_groups(opts) }
367
+ rows.to_h { |r| group_entry_from_resource(ApiSupport::ResourceShim.from_model(r)) }
368
+ end
369
+
370
+ # Fetch one log group as a resolution-cache entry. Used by the
371
+ # +group_changed+ WS handler.
372
+ #
373
+ # @api private
374
+ def get_group_entry(key)
375
+ response = ApiSupport::ErrorMapping.call { @api.get_log_group(key) }
376
+ group_entry_from_resource(ApiSupport::ResourceShim.from_model(response.data))
377
+ end
378
+
379
+ private
380
+
381
+ def group_entry_from_resource(resource)
382
+ attrs = resource["attributes"] || {}
383
+ [
384
+ resource["id"],
385
+ {
386
+ "level" => attrs["level"],
387
+ "group" => attrs["parent_id"],
388
+ "environments" => attrs["environments"] || {}
389
+ }
390
+ ]
391
+ end
392
+
393
+ def log_group_body(group)
394
+ # LogGroup server schema: name, level, parent_id, environments (no description).
395
+ SmplkitGeneratedClient::Logging::LogGroupResponse.new(
396
+ data: SmplkitGeneratedClient::Logging::LogGroupResource.new(
397
+ type: "log_group",
398
+ id: group.key,
399
+ attributes: SmplkitGeneratedClient::Logging::LogGroup.new(
400
+ name: group.name,
401
+ level: group.level&.to_s,
402
+ parent_id: group.group,
403
+ environments: Logging.environments_to_wire(group.environments)
404
+ )
405
+ )
406
+ )
407
+ end
408
+ end
409
+
410
+ # The Smpl Logging client (sync).
411
+ #
412
+ # One client exposes the full surface, reachable as +client.logging+
413
+ # (+Smplkit::Client+) or constructed directly:
414
+ #
415
+ # logging = Smplkit::LoggingClient.new(environment: "production", service: "my-svc")
416
+ # logging.loggers.new("sqlalchemy.engine").save
417
+ # logging.install
418
+ #
419
+ # The CRUD surface (+loggers+ / +log_groups+ sub-clients) works
420
+ # immediately. +register_adapter+ is a pre-install configuration call. The
421
+ # live surface (+install+ / +on_change+ / +refresh+) requires +install+
422
+ # first; calling +on_change+ / +refresh+ earlier raises +NotInstalledError+.
28
423
  class LoggingClient
29
- def initialize(parent, manage:, metrics:, logging_base_url:, app_base_url:)
424
+ attr_reader :loggers, :log_groups
425
+
426
+ def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
427
+ base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
428
+ parent: nil, transport: nil, metrics: nil)
30
429
  @parent = parent
31
- @manage = manage
32
430
  @metrics = metrics
33
- @logging_base_url = logging_base_url
34
- @app_base_url = app_base_url
35
- @adapters = []
36
- @installed = false
431
+ @environment = parent.nil? ? environment : parent._environment
432
+ @service = parent&._service
433
+ @standalone_api_key = nil
434
+ if transport.nil?
435
+ @logging_http, _app_http, @app_base_url, @standalone_api_key = Logging.logging_transport(
436
+ api_key: api_key, base_url: base_url, profile: profile,
437
+ base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
438
+ )
439
+ else
440
+ @logging_http = transport
441
+ @app_base_url = nil
442
+ end
443
+
444
+ # Discovery buffer is owned by this client; the loggers sub-client shares
445
+ # it so discovery and explicit registration drain together.
446
+ @buffer = LoggerRegistrationBuffer.new
447
+ @loggers = LoggersClient.new(@logging_http, buffer: @buffer)
448
+ @log_groups = LogGroupsClient.new(@logging_http)
449
+
450
+ # Live-surface state.
451
+ @connected = false
452
+ @name_map = {} # original_name → normalized_id
453
+ @loggers_cache = {} # id → logger data
454
+ @groups_cache = {} # id → group data
37
455
  @global_listeners = []
38
456
  @key_listeners = Hash.new { |h, k| h[k] = [] }
457
+ @adapters = []
458
+ @explicit_adapters = false
459
+ @ws_manager = nil
460
+ @owns_ws = false
39
461
  @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
462
  end
85
- alias start install
86
463
 
464
+ # --- Adapter registration (pre-install, ungated) ---
465
+
466
+ # Register a logging adapter. Must be called before install().
467
+ #
468
+ # If called at least once, auto-loading is disabled — only explicitly
469
+ # registered adapters are used. This is a pre-install configuration call:
470
+ # it is intentionally NOT gated by +install+.
87
471
  def register_adapter(adapter)
472
+ raise "Cannot register adapters after install()" if @connected
88
473
  unless adapter.is_a?(Adapters::Base)
89
474
  raise ArgumentError, "adapter must implement Smplkit::Logging::Adapters::Base"
90
475
  end
91
476
 
477
+ @explicit_adapters = true
92
478
  @adapters << adapter
93
479
  self
94
480
  end
95
481
 
482
+ # Registered logging adapters.
483
+ #
484
+ # @return [Array<Adapters::Base>] a copy of the adapters this client uses
485
+ # to discover loggers and apply levels.
96
486
  def adapters
97
487
  @adapters.dup
98
488
  end
99
489
 
100
- def get(name)
101
- @manage.loggers.get(name)
102
- end
490
+ # --- Live surface: install (gate) + transport / WebSocket helpers ---
103
491
 
104
- def list(page_number: nil, page_size: nil)
105
- @manage.loggers.list(page_number: page_number, page_size: page_size)
106
- end
492
+ # Hook smplkit into the application's logging machinery.
493
+ #
494
+ # Loads adapters, scans existing loggers, applies levels from the smplkit
495
+ # server, and wires WebSocket handlers for live updates. This IS the
496
+ # explicit consent gate — +on_change+ / +refresh+ require it first.
497
+ #
498
+ # Idempotent — safe to call multiple times.
499
+ def install
500
+ Smplkit.debug("lifecycle", "LoggingClient.install() called")
501
+ @parent&._ensure_started
502
+ return self if @connected
107
503
 
108
- def delete(name)
109
- @manage.loggers.delete(name)
110
- end
504
+ # 0. Load adapters
505
+ @adapters = Logging.auto_load_adapters if @adapters.empty?
111
506
 
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")
507
+ # 1. Discover existing loggers from all adapters (keep discovery and hook
508
+ # installation as two passes discover every adapter before any hook is
509
+ # live, mirroring the Python SDK).
510
+ # rubocop:disable Style/CombinableLoops
511
+ @adapters.each do |adapter|
512
+ existing = adapter.discover
513
+ existing.each do |name, explicit_level, effective_level|
514
+ @name_map[name] = Normalize.normalize_logger_name(name)
515
+ @loggers.register(loggersource_for(name, explicit_level, effective_level))
516
+ end
517
+ rescue StandardError => e
518
+ Smplkit.debug("logging", "adapter #{adapter.name} discover failed: #{e.class}: #{e.message}")
519
+ end
520
+
521
+ # 2. Install continuous discovery hooks
522
+ @adapters.each do |adapter|
523
+ adapter.install_hook { |name, explicit, effective| on_new_logger(name, explicit, effective) }
524
+ rescue StandardError => e
525
+ Smplkit.debug("logging", "adapter #{adapter.name} install_hook failed: #{e.class}: #{e.message}")
526
+ end
527
+ # rubocop:enable Style/CombinableLoops
528
+
529
+ # 3. Flush initial batch
530
+ begin
531
+ @loggers.flush
532
+ rescue StandardError => e
533
+ Smplkit.debug("registration", "bulk logger registration failed: #{e.class}: #{e.message}")
534
+ end
535
+
536
+ # 4-6. Fetch, resolve, apply
537
+ begin
538
+ fetch_and_apply(trigger: "install()")
539
+ rescue StandardError => e
540
+ Smplkit.debug("resolution",
541
+ "failed to fetch/apply logging levels during connect " \
542
+ "(logging: #{@logging_http&.config&.host}): #{e.class}: #{e.message}")
543
+ end
544
+
545
+ # 7. Register WebSocket event handlers for real-time level updates
546
+ @ws_manager = ensure_ws
547
+ ws_handlers.each { |event, handler| @ws_manager.on(event, &handler) }
548
+
549
+ @connected = true
550
+ self
116
551
  end
117
552
 
553
+ # --- Live surface: change listeners ---
554
+
555
+ # Register a change listener.
556
+ #
557
+ # client.logging.on_change { |event| ... } # global
558
+ # client.logging.on_change("sqlalchemy.engine") { |e| ... } # key-scoped
559
+ #
560
+ # Requires +install+ first; raises +NotInstalledError+ otherwise.
118
561
  def on_change(name = nil, &block)
562
+ require_installed
119
563
  raise ArgumentError, "on_change requires a block" unless block
120
564
 
121
565
  if name.nil?
122
566
  @global_listeners << block
123
567
  else
124
- @key_listeners[Normalize.normalize_logger_name(name)] << block
568
+ @key_listeners[name] << block
125
569
  end
126
570
  block
127
571
  end
128
572
 
129
- def _close
130
- @adapters.each(&:uninstall_hook) if @installed
131
- @installed = false
573
+ # Re-fetch all loggers and groups and fire listener events for any deltas.
574
+ #
575
+ # Requires +install+ first; raises +NotInstalledError+ otherwise.
576
+ def refresh
577
+ require_installed
578
+ Smplkit.debug("resolution", "refresh() called, triggering full resolution pass")
579
+ fetch_and_apply_deltas(trigger: "refresh()", source: "manual")
132
580
  end
133
581
 
134
- private
582
+ # Release resources — only those this client owns.
583
+ #
584
+ # Uninstalls the adapter hooks, unsubscribes from the WebSocket, and tears
585
+ # down the owned WebSocket (standalone install). A wired client borrows the
586
+ # parent's transport and WebSocket and closes neither.
587
+ def close
588
+ Smplkit.debug("lifecycle", "LoggingClient.close() called")
589
+ @adapters.each do |adapter|
590
+ adapter.uninstall_hook
591
+ rescue StandardError => e
592
+ Smplkit.debug("logging", "adapter #{adapter.name} uninstall_hook failed: #{e.class}: #{e.message}")
593
+ end
594
+ if @ws_manager
595
+ ws_handlers.each { |event, handler| @ws_manager.off(event, handler) }
596
+ if @owns_ws
597
+ @ws_manager.stop
598
+ @owns_ws = false
599
+ end
600
+ @ws_manager = nil
601
+ end
602
+ @connected = false
603
+ end
135
604
 
136
- def auto_load_adapters
137
- @adapters << Adapters::StdlibLoggerAdapter.new
605
+ # Release resources held by this client.
606
+ #
607
+ # @api private
608
+ # @return [void]
609
+ alias _close close
138
610
 
611
+ # Construct a +LoggingClient+, yield it to the block, and close it on exit.
612
+ #
613
+ # Mirrors Ruby's +File.open+ block form: the client is closed
614
+ # automatically when the block returns or raises, so a standalone client's
615
+ # owned transports and WebSocket are always torn down.
616
+ #
617
+ # Smplkit::LoggingClient.open(environment: "production") do |logging|
618
+ # logging.loggers.new("sqlalchemy.engine").save
619
+ # logging.install
620
+ # end
621
+ #
622
+ # @param kwargs [Hash] keyword arguments forwarded to +new+.
623
+ # @yieldparam client [LoggingClient] the constructed client.
624
+ # @return [Object] the block's return value.
625
+ def self.open(**kwargs)
626
+ client = new(**kwargs)
139
627
  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")
628
+ yield client
629
+ ensure
630
+ client.close
145
631
  end
632
+ end
146
633
 
147
- return unless @adapters.empty?
634
+ private
148
635
 
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:
636
+ def require_installed
637
+ raise NotInstalledError, NOT_INSTALLED_MESSAGE unless @connected
155
638
  end
156
639
 
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
- ))
640
+ # Memoized event → handler map so +install+ registers and +close+
641
+ # unsubscribes the exact same callback objects. They are stored as procs
642
+ # (not bound methods) because +SharedWebSocket#off+ removes by object
643
+ # identity, and +on(event, &proc)+ stores the very proc passed here.
644
+ def ws_handlers
645
+ @ws_handlers ||= {
646
+ "logger_changed" => proc { |data| handle_logger_changed(data) },
647
+ "logger_deleted" => proc { |data| handle_logger_deleted(data) },
648
+ "group_changed" => proc { |data| handle_group_changed(data) },
649
+ "group_deleted" => proc { |data| handle_group_deleted(data) },
650
+ "loggers_changed" => proc { |data| handle_loggers_changed(data) }
651
+ }
167
652
  end
168
653
 
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}")
654
+ def ensure_ws
655
+ return @parent._ensure_ws unless @parent.nil?
656
+
657
+ if @ws_manager.nil?
658
+ @ws_manager = SharedWebSocket.new(
659
+ app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
660
+ )
661
+ @ws_manager.start
662
+ @owns_ws = true
663
+ end
664
+ @ws_manager
665
+ end
666
+
667
+ # --- Internal ---
668
+
669
+ # Build a LoggerSource from an adapter's (name, explicit, effective)
670
+ # discovery tuple.
671
+ def loggersource_for(name, explicit_level, effective_level)
672
+ LoggerSource.new(
673
+ name: name,
674
+ resolved_level: effective_level,
675
+ level: explicit_level,
676
+ service: @service,
677
+ environment: @environment
678
+ )
679
+ end
680
+
681
+ # Callback from adapters when a new logger is created.
682
+ def on_new_logger(name, explicit_level, effective_level)
683
+ normalized = Normalize.normalize_logger_name(name)
684
+ @name_map[name] = normalized
685
+ @loggers.register(loggersource_for(name, explicit_level, effective_level))
686
+
687
+ # If connected, try to apply level from cache.
688
+ return unless @connected && @loggers_cache.key?(normalized)
689
+
690
+ entry = @loggers_cache[normalized]
691
+ return unless entry["managed"]
692
+
693
+ resolved = Resolution.resolve_level(normalized, @environment, @loggers_cache, @groups_cache)
694
+ coerced = LogLevel.coerce(resolved)
695
+ push_to_adapters(name, coerced)
173
696
  end
174
697
 
175
- # Full re-fetch of loggers + groups, then apply resolved levels.
176
- def fetch_and_apply(trigger:, source: "websocket")
698
+ def push_to_adapters(original_name, coerced_level)
699
+ @adapters.each do |adapter|
700
+ adapter.apply_level(original_name, coerced_level)
701
+ rescue StandardError => e
702
+ Smplkit.debug("logging", "adapter #{adapter.name} apply_level failed for #{original_name}: " \
703
+ "#{e.class}: #{e.message}")
704
+ end
705
+ end
706
+
707
+ # Re-fetch loggers/groups into the cache (no apply, no fire).
708
+ def fetch_cache(trigger)
177
709
  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|
710
+ @loggers_cache = @loggers.list_logger_entries
711
+ @groups_cache = @log_groups.list_group_entries
712
+ end
713
+
714
+ # Fetch loggers/groups and unconditionally apply levels (initial install
715
+ # path).
716
+ #
717
+ # Silent — does not fire change-listener events. Use
718
+ # +fetch_and_apply_deltas+ from the WS / refresh paths to get per-logger
719
+ # fanout.
720
+ def fetch_and_apply(trigger: "unknown")
721
+ fetch_cache(trigger)
722
+ apply_levels
723
+ end
724
+
725
+ # Fetch loggers/groups; apply + fire listeners only on effective-level
726
+ # deltas.
727
+ def fetch_and_apply_deltas(trigger:, source:)
728
+ pre = snapshot_effective_levels
729
+ fetch_cache(trigger)
730
+ apply_deltas_and_fire(pre, source)
731
+ end
732
+
733
+ # Apply resolved levels to all managed, locally-present loggers.
734
+ def apply_levels
735
+ @name_map.each do |original_name, normalized_id|
196
736
  entry = @loggers_cache[normalized_id]
197
737
  next if entry.nil?
198
738
  next unless entry["managed"]
199
739
 
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
740
+ resolved = Resolution.resolve_level(normalized_id, @environment, @loggers_cache, @groups_cache)
741
+ push_to_adapters(original_name, LogLevel.coerce(resolved))
742
+ @metrics&.record("logging.level_changes", unit: "changes", dimensions: { "logger" => normalized_id })
743
+ end
744
+ end
205
745
 
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)
746
+ # Effective level for every locally-tracked managed logger.
747
+ #
748
+ # This is the universe of loggers the adapter applies levels to — the only
749
+ # loggers whose listener can fire. A logger not in +name_map+ (never
750
+ # instantiated locally) or marked +managed=false+ in the cache is excluded.
751
+ def snapshot_effective_levels
752
+ snapshot = {}
753
+ @name_map.each_value do |normalized_id|
754
+ entry = @loggers_cache[normalized_id]
755
+ next if entry.nil? || !entry["managed"]
756
+
757
+ snapshot[normalized_id] = Resolution.resolve_level(
758
+ normalized_id, @environment, @loggers_cache, @groups_cache
759
+ )
210
760
  end
761
+ snapshot
211
762
  end
212
763
 
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}")
764
+ # Apply + fire per-logger whenever the effective level moved.
765
+ #
766
+ # For every locally-tracked managed logger, recompute the effective level
767
+ # and compare to +pre+. On a delta: call +apply_level+ on every adapter AND
768
+ # fire one +LoggerChangeEvent+ per affected logger — once to each matching
769
+ # key-scoped listener and once to every global listener (a global is
770
+ # semantically a key-scoped subscription on every logger). No-op when
771
+ # nothing moved: no apply, no fire.
772
+ def apply_deltas_and_fire(pre, source)
773
+ @name_map.each do |original_name, normalized_id|
774
+ entry = @loggers_cache[normalized_id]
775
+ next if entry.nil? || !entry["managed"]
776
+
777
+ new_level = Resolution.resolve_level(normalized_id, @environment, @loggers_cache, @groups_cache)
778
+ next if pre[normalized_id] == new_level
779
+
780
+ push_to_adapters(original_name, LogLevel.coerce(new_level))
781
+ @metrics&.record("logging.level_changes", unit: "changes", dimensions: { "logger" => normalized_id })
782
+ fire_for_logger(normalized_id, new_level, source)
218
783
  end
219
784
  end
220
785
 
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|
786
+ # Fire one +LoggerChangeEvent+ to every matching subscriber.
787
+ #
788
+ # Both the key-scoped listeners registered for +logger_id+ and every global
789
+ # listener receive the same payload.
790
+ def fire_for_logger(logger_id, level, source)
791
+ event = LoggerChangeEvent.new(id: logger_id, level: level, source: source)
792
+ (@global_listeners + @key_listeners[logger_id]).each do |cb|
224
793
  cb.call(event)
225
794
  rescue StandardError => e
226
795
  Smplkit.debug("logging", "listener raised: #{e.class}: #{e.message}")
227
796
  end
228
797
  end
229
798
 
230
- def handle_logger_changed(data)
231
- key = data["id"] || data["name"] || ""
232
- normalized = Normalize.normalize_logger_name(key)
233
- return if normalized.empty?
799
+ # --- Internal: event handlers (called by SharedWebSocket) ---
234
800
 
801
+ def handle_logger_changed(data)
802
+ key = data["id"] || ""
803
+ Smplkit.debug("websocket", "logger_changed: fetching logger #{key.inspect}")
804
+ pre = snapshot_effective_levels
235
805
  begin
236
- entry_id, entry = @manage.loggers.get_logger_entry(normalized)
237
- @loggers_cache[entry_id || normalized] = entry
806
+ entry_id, entry = @loggers.get_logger_entry(key)
807
+ @loggers_cache[entry_id || key] = entry
238
808
  rescue StandardError => e
239
- Smplkit.debug("websocket", "logger_changed fetch failed for #{normalized.inspect}: #{e.class}: #{e.message}")
809
+ Smplkit.debug("websocket", "failed to fetch logger #{key.inspect} after WS event: #{e.class}: #{e.message}")
240
810
  return
241
811
  end
242
-
243
- apply_levels(source: "websocket")
812
+ apply_deltas_and_fire(pre, "websocket")
244
813
  end
245
814
 
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
815
  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")
816
+ key = data["id"] || ""
817
+ Smplkit.debug("websocket", "logger_deleted: removing logger #{key.inspect}")
818
+ pre = snapshot_effective_levels
819
+ @loggers_cache.delete(key)
820
+ apply_deltas_and_fire(pre, "websocket")
256
821
  end
257
822
 
258
823
  def handle_group_changed(data)
259
- key = data["id"] || data["key"] || ""
260
- return if key.to_s.empty?
261
-
824
+ key = data["id"] || ""
825
+ Smplkit.debug("websocket", "group_changed: fetching group #{key.inspect}")
826
+ pre = snapshot_effective_levels
262
827
  begin
263
- entry_id, entry = @manage.log_groups.get_group_entry(key)
828
+ entry_id, entry = @log_groups.get_group_entry(key)
264
829
  @groups_cache[entry_id || key] = entry
265
830
  rescue StandardError => e
266
- Smplkit.debug("websocket", "group_changed fetch failed for #{key.inspect}: #{e.class}: #{e.message}")
831
+ Smplkit.debug("websocket",
832
+ "failed to fetch log group #{key.inspect} after WS event: #{e.class}: #{e.message}")
267
833
  return
268
834
  end
269
-
270
- apply_levels(source: "websocket")
835
+ apply_deltas_and_fire(pre, "websocket")
271
836
  end
272
837
 
273
838
  def handle_group_deleted(data)
274
- key = data["id"] || data["key"] || ""
275
- return if key.to_s.empty?
276
-
839
+ key = data["id"] || ""
840
+ Smplkit.debug("websocket", "group_deleted: removing group #{key.inspect}")
841
+ pre = snapshot_effective_levels
277
842
  @groups_cache.delete(key)
278
- apply_levels(source: "websocket")
843
+ apply_deltas_and_fire(pre, "websocket")
279
844
  end
280
845
 
281
846
  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
847
+ Smplkit.debug("websocket", "loggers_changed: full re-fetch")
848
+ fetch_and_apply_deltas(trigger: "loggers_changed WS event", source: "websocket")
849
+ rescue StandardError => e
850
+ Smplkit.debug("websocket",
851
+ "failed to re-fetch/apply logging levels after loggers_changed event: #{e.class}: #{e.message}")
290
852
  end
291
853
  end
292
854
  end
855
+
856
+ LoggingClient = Logging::LoggingClient
293
857
  end