smplkit 3.0.15 → 3.0.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d846979c77a96bd5b8f3fbd3bf36ddbe0a6556b7925a33302142b173c7c3e96
4
- data.tar.gz: dd7e3c2b10238f038f2d9e5de715bb5b12e7a05eae85af9acf53d5ac7735b370
3
+ metadata.gz: 215caf14863d35bebf83849e104befe12423a9ef468a547cb44714e6a9ea1932
4
+ data.tar.gz: 928ab9e89ea2dca1548eb82a06aa8d9b525f874026437bb6b862b4cdc254a4c9
5
5
  SHA512:
6
- metadata.gz: 52eaa355e2937332ed2637f9cec86b2959b477ad577caeb36bda66c6ca966ca8d1782c9c8a0f6c5a5e3ce11e20f3ad08a54239f0069109b691797a09fe970810
7
- data.tar.gz: 3931a2e34214aa78575f1dc7652631335608246fec988e5a5fca12488b330d59297d585c7e2b3cf3b60e9afe0ba38853107e66e6342ec58c1ad3d6906a0704f1
6
+ metadata.gz: 1f77af115d5412a34711fb54af8de0c402e47f33e53512fb808794a103a046d514494d947a6ed9d7d42998d9aca3f196db433914de52f96b785e3b35e5c63972
7
+ data.tar.gz: d028dd314e06e87fc6185f80695ba0c4bdf21b5d77d548c62e32bc2c4138590f7566c327cc83946191c26e233be9b0d923d1ba0d3d6fb59f835f91ca9f6f3bfa
@@ -9,6 +9,11 @@ module Smplkit
9
9
  # Obtained via +Smplkit::Client#logging+. Manages the discovery and level
10
10
  # application for a customer's logging frameworks via pluggable adapters.
11
11
  # CRUD has moved to +mgmt.loggers.*+ / +mgmt.log_groups.*+.
12
+ #
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}.
12
17
  class LoggingClient
13
18
  def initialize(parent, manage:, metrics:, logging_base_url:, app_base_url:)
14
19
  @parent = parent
@@ -21,6 +26,23 @@ module Smplkit
21
26
  @global_listeners = []
22
27
  @key_listeners = Hash.new { |h, k| h[k] = [] }
23
28
  @lock = Mutex.new
29
+ # original_name → normalized_id for every adapter-discovered logger.
30
+ # We keep originals so adapter.apply_level receives whatever the
31
+ # framework's registry indexes by.
32
+ @name_map = {}
33
+ # normalized_id → resolution-cache entry. Populated by
34
+ # +_fetch_and_apply+ and mutated by the +logger_changed+ /
35
+ # +logger_deleted+ WS handlers.
36
+ @loggers_cache = {}
37
+ # group id → resolution-cache entry. Without this, any managed
38
+ # logger with +level=null+ that inherits from a group silently
39
+ # keeps whatever level its adapter had at startup.
40
+ @groups_cache = {}
41
+ # normalized_id → resolved level (string). Used to decide whether
42
+ # to fire change listeners on a re-resolution — a group-driven
43
+ # change isn't visible in the raw +loggers_cache+ but moves the
44
+ # resolved value.
45
+ @resolved_levels = {}
24
46
  end
25
47
 
26
48
  # Install the logging integration.
@@ -39,8 +61,15 @@ module Smplkit
39
61
  adapter.install_hook { |name, _explicit, effective| observe_logger(adapter, name, effective) }
40
62
  end
41
63
 
64
+ flush_initial_registration
65
+ fetch_and_apply(trigger: "install")
66
+
42
67
  @ws_manager = @parent._ensure_ws
43
68
  @ws_manager.on("logger_changed") { |data| handle_logger_changed(data) }
69
+ @ws_manager.on("logger_deleted") { |data| handle_logger_deleted(data) }
70
+ @ws_manager.on("group_changed") { |data| handle_group_changed(data) }
71
+ @ws_manager.on("group_deleted") { |data| handle_group_deleted(data) }
72
+ @ws_manager.on("loggers_changed") { |data| handle_loggers_changed(data) }
44
73
  @installed = true
45
74
  self
46
75
  end
@@ -71,6 +100,12 @@ module Smplkit
71
100
  @manage.loggers.delete(name)
72
101
  end
73
102
 
103
+ # Re-fetch all loggers and groups and re-apply resolved levels. Fires
104
+ # change listeners for any logger whose resolved level moved.
105
+ def refresh
106
+ fetch_and_apply(trigger: "refresh")
107
+ end
108
+
74
109
  def on_change(name = nil, &block)
75
110
  raise ArgumentError, "on_change requires a block" unless block
76
111
 
@@ -102,15 +137,17 @@ module Smplkit
102
137
 
103
138
  return unless @adapters.empty?
104
139
 
105
- # :nocov: defensive log — unreachable in practice because stdlib
106
- # +Logger+ is always present, so +StdlibLoggerAdapter+ is always
140
+ # Defensive log — unreachable in practice because stdlib +Logger+
141
+ # is always present, so +StdlibLoggerAdapter+ is always
107
142
  # constructible.
143
+ # :nocov:
108
144
  Smplkit.debug("registration", "no logging adapters loaded; runtime features disabled")
109
145
  # :nocov:
110
146
  end
111
147
 
112
148
  def observe_logger(_adapter, raw_name, level)
113
149
  normalized = Normalize.normalize_logger_name(raw_name)
150
+ @name_map[raw_name] = normalized
114
151
  @manage.loggers.register(LoggerSource.new(
115
152
  name: normalized,
116
153
  resolved_level: level,
@@ -120,16 +157,135 @@ module Smplkit
120
157
  ))
121
158
  end
122
159
 
160
+ def flush_initial_registration
161
+ @manage.loggers.flush
162
+ rescue StandardError => e
163
+ Smplkit.debug("registration", "initial logger flush failed: #{e.class}: #{e.message}")
164
+ end
165
+
166
+ # Full re-fetch of loggers + groups, then apply resolved levels.
167
+ # Fires change listeners for any logger whose resolved level moved.
168
+ def fetch_and_apply(trigger:)
169
+ Smplkit.debug("resolution", "full resolution pass starting (trigger: #{trigger})")
170
+ loggers = @manage.loggers.list_logger_entries
171
+ groups = @manage.log_groups.list_group_entries
172
+ @loggers_cache = loggers
173
+ @groups_cache = groups
174
+ apply_levels(source: "websocket")
175
+ rescue StandardError => e
176
+ Smplkit.debug("resolution", "fetch_and_apply failed (trigger: #{trigger}): #{e.class}: #{e.message}")
177
+ end
178
+
179
+ # Resolve the effective level for every locally-known managed logger
180
+ # and push it to every adapter. Returns the list of normalized ids
181
+ # whose resolved level changed.
182
+ #
183
+ # +source+ is the +LoggerChangeEvent#source+ for any change-listener
184
+ # event we fire. The default reflects callers that arrived through a
185
+ # server event (WebSocket).
186
+ def apply_levels(source: "websocket")
187
+ changed = []
188
+ @name_map.each do |raw_name, normalized_id|
189
+ entry = @loggers_cache[normalized_id]
190
+ next if entry.nil?
191
+ next unless entry["managed"]
192
+
193
+ resolved_string = Resolution.resolve_level(
194
+ normalized_id, @parent._environment, @loggers_cache, @groups_cache
195
+ )
196
+ coerced = LogLevel.coerce(resolved_string)
197
+ push_to_adapters(raw_name, coerced)
198
+ previous = @resolved_levels[normalized_id]
199
+ if previous != resolved_string
200
+ @resolved_levels[normalized_id] = resolved_string
201
+ changed << [normalized_id, coerced]
202
+ end
203
+ end
204
+ fire_resolved_change_events(changed, source: source)
205
+ changed
206
+ end
207
+
208
+ def push_to_adapters(raw_name, coerced_level)
209
+ @adapters.each do |a|
210
+ a.apply_level(raw_name, coerced_level)
211
+ rescue StandardError => e
212
+ Smplkit.debug("logging", "adapter apply_level raised: #{e.class}: #{e.message}")
213
+ end
214
+ end
215
+
216
+ def fire_resolved_change_events(changed, source:)
217
+ changed.each do |(normalized_id, coerced_level)|
218
+ event = LoggerChangeEvent.new(name: normalized_id, level: coerced_level, source: source)
219
+ (@global_listeners + @key_listeners[normalized_id]).each do |cb|
220
+ cb.call(event)
221
+ rescue StandardError => e
222
+ Smplkit.debug("logging", "listener raised: #{e.class}: #{e.message}")
223
+ end
224
+ end
225
+ end
226
+
123
227
  def handle_logger_changed(data)
124
- name = Normalize.normalize_logger_name(data["name"] || data["id"] || "")
125
- return if name.empty?
228
+ key = data["id"] || data["name"] || ""
229
+ normalized = Normalize.normalize_logger_name(key)
230
+ return if normalized.empty?
126
231
 
127
- level = data["resolved_level"] || data["level"]
128
- coerced = level && LogLevel.coerce(level)
129
- @adapters.each { |a| a.apply_level(name, coerced) } if coerced
232
+ begin
233
+ entry_id, entry = @manage.loggers.get_logger_entry(normalized)
234
+ @loggers_cache[entry_id || normalized] = entry
235
+ rescue StandardError => e
236
+ Smplkit.debug("websocket", "logger_changed fetch failed for #{normalized.inspect}: #{e.class}: #{e.message}")
237
+ return
238
+ end
239
+
240
+ apply_levels(source: "websocket")
241
+ end
242
+
243
+ def handle_logger_deleted(data)
244
+ key = data["id"] || data["name"] || ""
245
+ normalized = Normalize.normalize_logger_name(key)
246
+ return if normalized.empty?
247
+
248
+ existed = @loggers_cache.delete(normalized)
249
+ @resolved_levels.delete(normalized)
250
+ return unless existed
251
+
252
+ apply_levels(source: "websocket")
253
+ fire_deletion_event(normalized)
254
+ end
255
+
256
+ def handle_group_changed(data)
257
+ key = data["id"] || data["key"] || ""
258
+ return if key.to_s.empty?
259
+
260
+ begin
261
+ entry_id, entry = @manage.log_groups.get_group_entry(key)
262
+ @groups_cache[entry_id || key] = entry
263
+ rescue StandardError => e
264
+ Smplkit.debug("websocket", "group_changed fetch failed for #{key.inspect}: #{e.class}: #{e.message}")
265
+ return
266
+ end
267
+
268
+ apply_levels(source: "websocket")
269
+ end
130
270
 
131
- event = LoggerChangeEvent.new(name: name, level: coerced, source: "websocket")
132
- (@global_listeners + @key_listeners[name]).each do |cb|
271
+ def handle_group_deleted(data)
272
+ key = data["id"] || data["key"] || ""
273
+ return if key.to_s.empty?
274
+
275
+ existed = @groups_cache.delete(key)
276
+ return unless existed
277
+
278
+ apply_levels(source: "websocket")
279
+ fire_deletion_event(key)
280
+ end
281
+
282
+ def handle_loggers_changed(_data)
283
+ fetch_and_apply(trigger: "loggers_changed WS event")
284
+ end
285
+
286
+ def fire_deletion_event(key)
287
+ event = LoggerChangeEvent.new(name: key, level: nil, source: "websocket", deleted: true)
288
+ (@global_listeners + @key_listeners[key]).each do |cb|
133
289
  cb.call(event)
134
290
  rescue StandardError => e
135
291
  Smplkit.debug("logging", "listener raised: #{e.class}: #{e.message}")
@@ -137,9 +293,15 @@ module Smplkit
137
293
  end
138
294
  end
139
295
 
140
- LoggerChangeEvent = Struct.new(:name, :level, :source, keyword_init: true) do
296
+ LoggerChangeEvent = Struct.new(:name, :level, :source, :deleted, keyword_init: true) do
297
+ def initialize(name:, level:, source:, deleted: false)
298
+ super
299
+ end
300
+
141
301
  def ==(other)
142
- other.is_a?(LoggerChangeEvent) && name == other.name && level == other.level && source == other.source
302
+ other.is_a?(LoggerChangeEvent) &&
303
+ name == other.name && level == other.level &&
304
+ source == other.source && deleted == other.deleted
143
305
  end
144
306
  end
145
307
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Smplkit
6
+ module Logging
7
+ # Client-side level resolution per ADR-034 §3.1.
8
+ #
9
+ # The server stores raw configuration and returns it as-is; the SDK is
10
+ # responsible for walking the inheritance chain. Mirrors the Python
11
+ # SDK's +smplkit.logging._resolution+ verbatim — both implementations
12
+ # MUST resolve identically for any given (loggers, groups, env) input.
13
+ module Resolution
14
+ FALLBACK_LEVEL = "INFO"
15
+
16
+ module_function
17
+
18
+ # Resolve the effective level for +logger_id+ in +environment+.
19
+ #
20
+ # Resolution chain (first non-nil wins):
21
+ #
22
+ # 1. Logger's own +environments[env].level+
23
+ # 2. Logger's own +level+
24
+ # 3. Group chain (recursive: group env level → group level → parent group …)
25
+ # 4. Dot-notation ancestry (+com.acme.payments+ → +com.acme+ → +com+,
26
+ # applying steps 1–3 at each)
27
+ # 5. System fallback: +"INFO"+
28
+ #
29
+ # +loggers+ and +groups+ are id-keyed Hashes whose values are Hashes
30
+ # with the same shape as the Python SDK: +"level"+, +"group"+ (parent
31
+ # group id for loggers; parent_id for groups), +"environments"+
32
+ # (Hash keyed by env name with +{"level" => "..."}+ values).
33
+ def resolve_level(logger_id, environment, loggers, groups)
34
+ result = resolve_for_entry(logger_id, environment, loggers, groups)
35
+ if result
36
+ if Smplkit::Debug.enabled
37
+ source = find_resolution_source(logger_id, environment, loggers, groups)
38
+ Smplkit.debug("resolution", "#{logger_id} -> #{result} (source: #{source})")
39
+ end
40
+ return result
41
+ end
42
+
43
+ parts = logger_id.split(".")
44
+ (parts.length - 1).downto(1) do |i|
45
+ ancestor_id = parts[0, i].join(".")
46
+ result = resolve_for_entry(ancestor_id, environment, loggers, groups)
47
+ if result
48
+ Smplkit.debug("resolution", "#{logger_id} -> #{result} (source: ancestor \"#{ancestor_id}\")")
49
+ return result
50
+ end
51
+ end
52
+
53
+ Smplkit.debug("resolution", "#{logger_id} -> #{FALLBACK_LEVEL} (source: system default)")
54
+ FALLBACK_LEVEL
55
+ end
56
+
57
+ # Try to resolve a level for a single entry (logger or ancestor).
58
+ # Returns +nil+ if no level is found at any step of 1–3.
59
+ def resolve_for_entry(logger_id, environment, loggers, groups)
60
+ entry = loggers[logger_id]
61
+ return nil if entry.nil?
62
+
63
+ env_level = env_level_of(entry, environment)
64
+ return env_level if env_level
65
+
66
+ base = entry["level"]
67
+ return base if base
68
+
69
+ resolve_group_chain(entry["group"], environment, groups)
70
+ end
71
+
72
+ # Walk the group chain looking for a level. Cycle-safe via +visited+.
73
+ def resolve_group_chain(group_id, environment, groups)
74
+ visited = Set.new
75
+ current_id = group_id
76
+ while !current_id.nil? && !visited.include?(current_id)
77
+ visited.add(current_id)
78
+ group = groups[current_id]
79
+ break if group.nil?
80
+
81
+ env_level = env_level_of(group, environment)
82
+ return env_level if env_level
83
+
84
+ base = group["level"]
85
+ return base if base
86
+
87
+ current_id = group["group"]
88
+ end
89
+ nil
90
+ end
91
+
92
+ # Human-readable label for which resolution step won. Only consulted
93
+ # when debug logging is enabled; mirrors Python's +_find_resolution_source+.
94
+ def find_resolution_source(logger_id, environment, loggers, groups)
95
+ entry = loggers[logger_id]
96
+ return "not found" if entry.nil?
97
+
98
+ return %(env override "#{environment}") if env_level_of(entry, environment)
99
+ return "base level" if entry["level"]
100
+
101
+ group_id = entry["group"]
102
+ return %(group "#{group_id}") if resolve_group_chain(group_id, environment, groups)
103
+
104
+ "unknown"
105
+ end
106
+
107
+ def env_level_of(entry, environment)
108
+ envs = entry["environments"]
109
+ return nil unless envs.is_a?(Hash)
110
+
111
+ env_data = envs[environment]
112
+ return nil unless env_data.is_a?(Hash)
113
+
114
+ env_data["level"]
115
+ end
116
+ end
117
+ end
118
+ end
@@ -816,8 +816,38 @@ module Smplkit
816
816
  Smplkit::Logging::Helpers.logger_resource_to_model(self, ResourceShim.from_model(response.data))
817
817
  end
818
818
 
819
+ # Runtime entry — walks every page and returns an id-keyed Hash of
820
+ # resolution-cache entries (+level+, +group+, +managed+,
821
+ # +environments+). Mirrors the Python SDK's
822
+ # +LoggingClient._fetch_and_apply+ loggers branch.
823
+ def list_logger_entries
824
+ rows = PaginatedFetch.collect { |opts| @api.list_loggers(opts) }
825
+ rows.to_h { |r| logger_entry_from_resource(ResourceShim.from_model(r)) }
826
+ end
827
+
828
+ # Fetch one logger as a resolution-cache entry. Used by the
829
+ # +logger_changed+ WS handler.
830
+ def get_logger_entry(id)
831
+ normalized = Smplkit::Logging::Normalize.normalize_logger_name(id)
832
+ response = ErrorMapping.call { @api.get_logger(normalized) }
833
+ logger_entry_from_resource(ResourceShim.from_model(response.data))
834
+ end
835
+
819
836
  private
820
837
 
838
+ def logger_entry_from_resource(resource)
839
+ attrs = resource["attributes"] || {}
840
+ [
841
+ resource["id"],
842
+ {
843
+ "level" => attrs["level"],
844
+ "group" => attrs["group"],
845
+ "managed" => attrs.key?("managed") ? attrs["managed"] : true,
846
+ "environments" => attrs["environments"] || {}
847
+ }
848
+ ]
849
+ end
850
+
821
851
  def logger_body(logger)
822
852
  # Logger server schema: name, level, group, managed.
823
853
  # +resolved_level+ is read-only, +service+/+environment+ are
@@ -880,8 +910,35 @@ module Smplkit
880
910
  Smplkit::Logging::Helpers.log_group_resource_to_model(self, ResourceShim.from_model(response.data))
881
911
  end
882
912
 
913
+ # Runtime entry — walks every page and returns an id-keyed Hash of
914
+ # resolution-cache entries (+level+, +group+, +environments+). The
915
+ # +group+ key carries the *parent group id* so the resolution
916
+ # algorithm can walk the chain with the same key shape it uses for
917
+ # loggers.
918
+ def list_group_entries
919
+ rows = PaginatedFetch.collect { |opts| @api.list_log_groups(opts) }
920
+ rows.to_h { |r| group_entry_from_resource(ResourceShim.from_model(r)) }
921
+ end
922
+
923
+ def get_group_entry(key)
924
+ response = ErrorMapping.call { @api.get_log_group(key) }
925
+ group_entry_from_resource(ResourceShim.from_model(response.data))
926
+ end
927
+
883
928
  private
884
929
 
930
+ def group_entry_from_resource(resource)
931
+ attrs = resource["attributes"] || {}
932
+ [
933
+ resource["id"],
934
+ {
935
+ "level" => attrs["level"],
936
+ "group" => attrs["parent_id"],
937
+ "environments" => attrs["environments"] || {}
938
+ }
939
+ ]
940
+ end
941
+
885
942
  def log_group_body(group)
886
943
  # LogGroup server schema: name, level, parent_id (no description).
887
944
  SmplkitGeneratedClient::Logging::LogGroupResponse.new(
data/lib/smplkit.rb CHANGED
@@ -55,6 +55,7 @@ require_relative "smplkit/logging/normalize"
55
55
  require_relative "smplkit/logging/sources"
56
56
  require_relative "smplkit/logging/models"
57
57
  require_relative "smplkit/logging/helpers"
58
+ require_relative "smplkit/logging/resolution"
58
59
  require_relative "smplkit/logging/adapters/base"
59
60
  require_relative "smplkit/logging/adapters/stdlib_logger_adapter"
60
61
  require_relative "smplkit/logging/client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smplkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.15
4
+ version: 3.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC
@@ -701,6 +701,7 @@ files:
701
701
  - lib/smplkit/logging/levels.rb
702
702
  - lib/smplkit/logging/models.rb
703
703
  - lib/smplkit/logging/normalize.rb
704
+ - lib/smplkit/logging/resolution.rb
704
705
  - lib/smplkit/logging/sources.rb
705
706
  - lib/smplkit/management/audit.rb
706
707
  - lib/smplkit/management/buffer.rb