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.
- checksums.yaml +4 -4
- data/lib/smplkit/account/client.rb +128 -0
- data/lib/smplkit/account/models.rb +71 -0
- data/lib/smplkit/api_support.rb +91 -0
- data/lib/smplkit/audit/buffer.rb +3 -1
- data/lib/smplkit/audit/categories.rb +21 -10
- data/lib/smplkit/audit/client.rb +18 -9
- data/lib/smplkit/audit/event_types.rb +26 -10
- data/lib/smplkit/audit/events.rb +93 -17
- data/lib/smplkit/{management/audit.rb → audit/forwarders.rb} +93 -85
- data/lib/smplkit/audit/models.rb +86 -32
- data/lib/smplkit/audit/resource_types.rb +21 -9
- data/lib/smplkit/buffers.rb +250 -0
- data/lib/smplkit/client.rb +161 -70
- data/lib/smplkit/config/client.rb +874 -186
- data/lib/smplkit/config/helpers.rb +44 -6
- data/lib/smplkit/config/models.rb +114 -7
- data/lib/smplkit/config_resolution.rb +17 -9
- data/lib/smplkit/errors.rb +14 -3
- data/lib/smplkit/flags/client.rb +602 -116
- data/lib/smplkit/flags/models.rb +110 -8
- data/lib/smplkit/flags/types.rb +8 -9
- data/lib/smplkit/jobs/client.rb +306 -0
- data/lib/smplkit/jobs/models.rb +47 -18
- data/lib/smplkit/logging/client.rb +755 -191
- data/lib/smplkit/logging/helpers.rb +5 -1
- data/lib/smplkit/logging/levels.rb +3 -1
- data/lib/smplkit/logging/models.rb +163 -6
- data/lib/smplkit/logging/normalize.rb +3 -1
- data/lib/smplkit/logging/resolution.rb +4 -4
- data/lib/smplkit/logging/sources.rb +1 -1
- data/lib/smplkit/platform/client.rb +597 -0
- data/lib/smplkit/platform/models.rb +282 -0
- data/lib/smplkit/{management → platform}/types.rb +21 -4
- data/lib/smplkit/transport.rb +103 -0
- data/lib/smplkit/ws.rb +1 -1
- data/lib/smplkit.rb +18 -6
- metadata +11 -7
- data/lib/smplkit/management/buffer.rb +0 -198
- data/lib/smplkit/management/client.rb +0 -1074
- data/lib/smplkit/management/jobs.rb +0 -226
- data/lib/smplkit/management/models.rb +0 -178
|
@@ -1,293 +1,857 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# level
|
|
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
|
-
#
|
|
19
|
-
# +
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
|
|
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
|
-
|
|
101
|
-
@manage.loggers.get(name)
|
|
102
|
-
end
|
|
490
|
+
# --- Live surface: install (gate) + transport / WebSocket helpers ---
|
|
103
491
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
@
|
|
110
|
-
end
|
|
504
|
+
# 0. Load adapters
|
|
505
|
+
@adapters = Logging.auto_load_adapters if @adapters.empty?
|
|
111
506
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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[
|
|
568
|
+
@key_listeners[name] << block
|
|
125
569
|
end
|
|
126
570
|
block
|
|
127
571
|
end
|
|
128
572
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
634
|
+
private
|
|
148
635
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
170
|
-
@
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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 = @
|
|
237
|
-
@loggers_cache[entry_id ||
|
|
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", "
|
|
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"] ||
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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"] ||
|
|
260
|
-
|
|
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 = @
|
|
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",
|
|
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"] ||
|
|
275
|
-
|
|
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
|
-
|
|
843
|
+
apply_deltas_and_fire(pre, "websocket")
|
|
279
844
|
end
|
|
280
845
|
|
|
281
846
|
def handle_loggers_changed(_data)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|