smplkit 1.0.9 → 1.0.11

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: 7c58128ff207d255ca61de78cd05db65a37dce681d7940441db0a10acddd5e7a
4
- data.tar.gz: 73500c410292e3a5a6daefa1a75ffaa0b9a61f951e50c9f8f52a1b5a893dd38a
3
+ metadata.gz: 45b89bb8091aeb0e42f1e6ccbf0b94cde98f827bae408770317658dd24c4de3c
4
+ data.tar.gz: 06eff16b91b28ef98f9fc2b7a64253d099154a6730a7ecbf1f2e99df9d8629c3
5
5
  SHA512:
6
- metadata.gz: 0f55ecc713c6c7171b52469098c6f9b3268d1bf43fe66ec8da85eb1d1a5293cb1f0e801c975cd1db4a9d15c3e509319b382b5f98220beb26d2761e7b78781cc1
7
- data.tar.gz: 7c5a33b527b9d042919b62c570ba14578607131386f1bcba9a6ede04f99f0d97ae9288287f99f9bad0bb826f3f12970196e82908fbf56d5e70c43ea0768d75dc
6
+ metadata.gz: 8f06ccbadf581ad0f7253ebb80ca35ecc6f1dcd94c63ad476bbfd742068702774f9b2dc416da6d6f035fd50502cebea17bfae6d8a203f4d0f4c39dec495c30ef
7
+ data.tar.gz: 6ac31365d9a694b9b11e0a8b00e7ca37c3a8fa5354fe678ef91fdc7bcd7a3ad257220b5521a4704ebdb210838c3e4ad120afe5dc4af01686f7339538510b0024
@@ -15,7 +15,15 @@ module Smplkit
15
15
  # SemanticLogger has its own internal logger registry and its own level
16
16
  # system that natively includes TRACE — a 1-to-1 map across all seven
17
17
  # smplkit canonical levels.
18
+ #
19
+ # New-logger detection uses +Module#prepend+ on +SemanticLogger::Logger+
20
+ # to intercept +initialize+. The hook is installed once globally and
21
+ # shared across all adapter instances via a class-level +@adapters+ list,
22
+ # matching the same pattern used by +StdlibLoggerAdapter+.
18
23
  class SemanticLoggerAdapter < Base
24
+ @hook_module = nil
25
+ @global_lock = Mutex.new
26
+
19
27
  def initialize
20
28
  super
21
29
  @loggers = Concurrent::Hash.new
@@ -33,8 +41,6 @@ module Smplkit
33
41
 
34
42
  def discover
35
43
  rows = []
36
- # Default named loggers SemanticLogger creates: itself + the global
37
- # one. Customers add more via +SemanticLogger[ClassOrName]+.
38
44
  all_loggers.each do |name, logger|
39
45
  level = logger.respond_to?(:level) ? logger.level : nil
40
46
  smpl_level = Levels.semantic_level_to_smpl(level)
@@ -55,25 +61,75 @@ module Smplkit
55
61
  def install_hook(&on_new_logger)
56
62
  @on_new = on_new_logger
57
63
  @uninstalled = false
58
- # SemanticLogger's API for new-logger interception varies across
59
- # versions. The Ruby SDK initial release relies on +discover+ being
60
- # called periodically — full prepend-based interception will be
61
- # filled in once tested against the targeted +semantic_logger+
62
- # version pinned in dev deps. (See ISSUES.md.)
64
+
65
+ self.class.global_lock.synchronize do
66
+ unless self.class.hook_installed?
67
+ hook = self.class.build_hook
68
+ ::SemanticLogger::Logger.prepend(hook)
69
+ self.class.instance_variable_set(:@hook_module, hook)
70
+ end
71
+ self.class.adapters << self unless self.class.adapters.include?(self)
72
+ end
63
73
  end
64
74
 
65
75
  def uninstall_hook
66
76
  @uninstalled = true
77
+ self.class.global_lock.synchronize do
78
+ self.class.adapters.delete(self)
79
+ end
80
+ end
81
+
82
+ # Called by the prepended hook when a new SemanticLogger::Logger is created.
83
+ def on_new_logger_created(logger)
84
+ return if @uninstalled
85
+
86
+ name = logger.name
87
+ return unless name
88
+
89
+ @loggers[name] = logger
90
+ level = logger.respond_to?(:level) ? logger.level : nil
91
+ smpl_level = Levels.semantic_level_to_smpl(level)
92
+ @on_new&.call(name, smpl_level, smpl_level)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ class << self
98
+ attr_reader :global_lock, :hook_module
99
+
100
+ def adapters
101
+ @adapters ||= Concurrent::Array.new
102
+ end
103
+
104
+ def hook_installed?
105
+ !@hook_module.nil?
106
+ end
107
+
108
+ # Uses define_method so the adapter_class reference is captured as a
109
+ # closure, avoiding constant-lookup issues in anonymous modules.
110
+ def build_hook
111
+ adapter_class = self
112
+ Module.new do
113
+ define_method(:initialize) do |klass, level = nil, filter = nil|
114
+ super(klass, level, filter)
115
+ adapter_class.adapters.each do |adapter|
116
+ adapter.on_new_logger_created(self)
117
+ rescue StandardError
118
+ # Don't let hook errors break logger creation.
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ def reset_hook!
125
+ @hook_module = nil
126
+ end
67
127
  end
68
128
 
69
129
  private
70
130
 
71
131
  def all_loggers
72
132
  loggers = @loggers.dup
73
- if defined?(::SemanticLogger::Logger) && ::SemanticLogger::Logger.respond_to?(:processors)
74
- # No-op probe to keep this method tolerant of the live API.
75
- end
76
-
77
133
  if defined?(::SemanticLogger) &&
78
134
  ::SemanticLogger.respond_to?(:default_level) &&
79
135
  ::SemanticLogger.respond_to?(:[])
data/lib/smplkit/ws.rb CHANGED
@@ -1,42 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "async"
5
+ require "async/http/endpoint"
6
+ require "async/websocket/client"
3
7
  require "concurrent"
4
8
 
5
9
  module Smplkit
6
10
  # Manages a single WebSocket connection to the app service event gateway.
7
11
  #
8
12
  # A single +SharedWebSocket+ instance is shared across all product modules
9
- # (config, flags) within one +Smplkit::Client+. Product modules register
10
- # listeners for specific event types; the shared connection dispatches
11
- # incoming events to the appropriate listeners.
13
+ # (config, flags, logging) within one +Smplkit::Client+. Product modules
14
+ # register listeners for specific event types; the shared connection
15
+ # dispatches incoming events to the appropriate listeners.
12
16
  #
13
- # The connection runs on a dedicated SDK-owned thread; public methods are
14
- # thread-safe and non-blocking.
17
+ # The connection runs on a dedicated SDK-owned thread that hosts the
18
+ # +Async+ reactor and the underlying +async-websocket+ I/O. Public
19
+ # methods are thread-safe and non-blocking.
20
+ #
21
+ # Gateway protocol (mirrors the Python reference in +smplkit._ws+):
15
22
  #
16
- # The app service gateway protocol:
17
23
  # - Connect to +wss://app.<base_domain>/api/ws/v1/events?api_key={key}+
18
24
  # - Receive +{"type": "connected"}+ on success
19
25
  # - Receive events: +{"event": "config_changed", ...}+, etc.
20
- # - No subscribe message - the API key determines the account
26
+ # - No subscribe message the API key determines the account
21
27
  # - Heartbeat: server sends +"ping"+ (text), client responds with +"pong"+
22
28
  #
23
- # NOTE: The actual WebSocket I/O is wired to async-websocket on a worker
24
- # thread. The initial Ruby SDK release defers full live-update wiring to a
25
- # follow-up because async-websocket interactions need integration testing
26
- # against the real platform.
29
+ # On disconnect the reactor reconnects with exponential backoff
30
+ # (1, 2, 4, 8, 16, 32, 60 seconds, then capped). +stop+ closes the
31
+ # connection from the outer thread; the reader exits and the daemon
32
+ # thread terminates.
27
33
  class SharedWebSocket
28
34
  BACKOFF_SCHEDULE = [1, 2, 4, 8, 16, 32, 60].freeze
29
35
 
36
+ USER_AGENT = "smplkit-ruby-sdk/#{Smplkit::VERSION}".freeze
37
+
30
38
  def initialize(app_base_url:, api_key:, metrics: nil)
31
39
  @app_base_url = app_base_url
32
40
  @api_key = api_key
33
41
  @metrics = metrics
34
- @listeners = Concurrent::Hash.new { |h, k| h[k] = [] }
42
+ @listeners = Hash.new { |h, k| h[k] = [] }
35
43
  @listeners_lock = Mutex.new
36
44
  @connection_status = "disconnected"
37
45
  @closed = false
46
+ @ws_thread = nil
47
+ @connection = nil
48
+ @connection_lock = Mutex.new
38
49
  end
39
50
 
51
+ # ----- Listener registration ------------------------------------
52
+
40
53
  def on(event_name, &callback)
41
54
  @listeners_lock.synchronize { @listeners[event_name] << callback }
42
55
  end
@@ -45,6 +58,9 @@ module Smplkit
45
58
  @listeners_lock.synchronize { @listeners[event_name].delete(callback) }
46
59
  end
47
60
 
61
+ # Dispatch +data+ to every listener registered for +event_name+.
62
+ # Listener exceptions are caught and logged; one bad listener never
63
+ # blocks the rest.
48
64
  def dispatch(event_name, data)
49
65
  callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
50
66
  callbacks.each do |cb|
@@ -54,39 +70,186 @@ module Smplkit
54
70
  end
55
71
  end
56
72
 
73
+ # ----- Connection status ----------------------------------------
74
+
57
75
  attr_reader :connection_status
58
76
 
59
- # Marked as connected for in-process testing without a real WS connection.
60
- # Production wiring overrides this from the I/O thread once the gateway
61
- # confirms the handshake.
62
- def mark_connected!
63
- @connection_status = "connected"
64
- end
77
+ # ----- Lifecycle ------------------------------------------------
65
78
 
66
79
  def start
67
- Smplkit.debug("websocket", "starting shared WebSocket (Ruby SDK initial release: in-memory only)")
68
- # Live wiring is deferred. Behave as if the handshake succeeded so the
69
- # rest of the runtime can proceed - listeners still fire for any events
70
- # other code dispatches into this instance.
71
- mark_connected!
80
+ return if @ws_thread&.alive?
81
+
82
+ Smplkit.debug("websocket", "starting shared WebSocket background thread")
83
+ @closed = false
84
+ @connection_status = "connecting"
85
+ @ws_thread = Thread.new { run_reactor }
86
+ @ws_thread.name = "smplkit-shared-ws" if @ws_thread.respond_to?(:name=)
72
87
  end
73
88
 
74
89
  def stop
90
+ Smplkit.debug("websocket", "stopping shared WebSocket")
75
91
  @closed = true
76
92
  @connection_status = "disconnected"
93
+ close_active_connection
94
+ thread = @ws_thread
95
+ @ws_thread = nil
96
+ return unless thread
97
+
98
+ thread.join(2.0)
99
+ thread.kill if thread.alive?
77
100
  end
78
101
 
102
+ # ----- URL builder ----------------------------------------------
103
+
79
104
  def build_ws_url
80
105
  url = @app_base_url.dup
81
- ws_url = if url.start_with?("https://")
82
- "wss://#{url[("https://".length)..]}"
83
- elsif url.start_with?("http://")
84
- "ws://#{url[("http://".length)..]}"
85
- else
86
- "wss://#{url}"
87
- end
106
+ ws_url =
107
+ if url.start_with?("https://")
108
+ "wss://#{url[("https://".length)..]}"
109
+ elsif url.start_with?("http://")
110
+ "ws://#{url[("http://".length)..]}"
111
+ else
112
+ "wss://#{url}"
113
+ end
88
114
  ws_url = ws_url.chomp("/")
89
115
  "#{ws_url}/api/ws/v1/events?api_key=#{@api_key}"
90
116
  end
117
+
118
+ # ----- Inbound message handling (extracted for tests) -----------
119
+
120
+ # Process a single inbound text frame the way the live reactor does:
121
+ # +"ping"+ → call +send_pong+ with +"pong"+; otherwise parse JSON and,
122
+ # if a +"event"+ key is present, dispatch to listeners.
123
+ #
124
+ # Returns one of +:ping+, +:event+, +:no_event+, +:unparseable+ for the
125
+ # caller to log/observe; the live reactor ignores the return value.
126
+ def handle_inbound(text, send_pong:)
127
+ if text == "ping"
128
+ send_pong.call("pong")
129
+ return :ping
130
+ end
131
+
132
+ data =
133
+ begin
134
+ JSON.parse(text)
135
+ rescue JSON::ParserError
136
+ return :unparseable
137
+ end
138
+
139
+ event = data["event"]
140
+ if event
141
+ dispatch(event, data)
142
+ :event
143
+ else
144
+ :no_event
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def run_reactor
151
+ Sync do |task|
152
+ ws_main(task)
153
+ end
154
+ rescue StandardError => e
155
+ Smplkit.debug("websocket", "shared WebSocket thread exited unexpectedly: #{e.class}: #{e.message}")
156
+ end
157
+
158
+ def ws_main(task)
159
+ connect(task)
160
+ rescue StandardError => e
161
+ return if @closed
162
+
163
+ Smplkit.debug(
164
+ "websocket",
165
+ "connection failed on startup (url: #{safe_url}): #{e.class}: #{e.message}"
166
+ )
167
+ reconnect(task)
168
+ end
169
+
170
+ def connect(task)
171
+ url = build_ws_url
172
+ @connection_status = "connecting"
173
+ Smplkit.debug("websocket", "connecting to #{safe_url}")
174
+
175
+ endpoint = Async::HTTP::Endpoint.parse(url)
176
+ headers = { "user-agent" => USER_AGENT }
177
+ connection = Async::WebSocket::Client.connect(endpoint, headers: headers)
178
+ @connection_lock.synchronize { @connection = connection }
179
+ Smplkit.debug("websocket", "WebSocket connected, waiting for confirmation")
180
+
181
+ raw = connection.read
182
+ data = JSON.parse(message_to_string(raw))
183
+ if data["type"] == "error"
184
+ err = data["message"]
185
+ Smplkit.debug("websocket", "connection error from server: #{err.inspect}")
186
+ raise "Connection error: #{err}"
187
+ end
188
+
189
+ @connection_status = "connected"
190
+ @metrics&.record_gauge("platform.websocket_connections", 1, unit: "connections")
191
+ receive_loop(task, connection)
192
+ end
193
+
194
+ def receive_loop(task, connection)
195
+ until @closed
196
+ message = connection.read
197
+ break if message.nil?
198
+
199
+ text = message_to_string(message)
200
+ handle_inbound(text, send_pong: ->(reply) { connection.write(reply) })
201
+ end
202
+ rescue StandardError => e
203
+ return if @closed
204
+
205
+ Smplkit.debug("websocket", "receive loop error: #{e.class}: #{e.message}")
206
+ @connection_status = "reconnecting"
207
+ @metrics&.record_gauge("platform.websocket_connections", 0, unit: "connections")
208
+ reconnect(task)
209
+ end
210
+
211
+ def reconnect(task)
212
+ attempt = 0
213
+ until @closed
214
+ delay = BACKOFF_SCHEDULE[[attempt, BACKOFF_SCHEDULE.length - 1].min]
215
+ Smplkit.debug("websocket", "reconnecting in #{delay}s (attempt #{attempt + 1})")
216
+ task.sleep(delay)
217
+ return if @closed
218
+
219
+ begin
220
+ connect(task)
221
+ return
222
+ rescue StandardError => e
223
+ Smplkit.debug("websocket", "reconnect attempt #{attempt + 1} failed: #{e.class}: #{e.message}")
224
+ attempt += 1
225
+ end
226
+ end
227
+ end
228
+
229
+ def message_to_string(message)
230
+ return message if message.is_a?(String)
231
+ return message.to_str if message.respond_to?(:to_str)
232
+
233
+ message.to_s
234
+ end
235
+
236
+ def close_active_connection
237
+ conn = @connection_lock.synchronize do
238
+ c = @connection
239
+ @connection = nil
240
+ c
241
+ end
242
+ return unless conn
243
+
244
+ begin
245
+ conn.close
246
+ rescue StandardError => e
247
+ Smplkit.debug("websocket", "close raised: #{e.class}: #{e.message}")
248
+ end
249
+ end
250
+
251
+ def safe_url
252
+ build_ws_url.split("?", 2).first
253
+ end
91
254
  end
92
255
  end
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: 1.0.9
4
+ version: 1.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC
@@ -9,6 +9,48 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.39'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.39'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.95'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.95'
40
+ - !ruby/object:Gem::Dependency
41
+ name: async-websocket
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.30'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.30'
12
54
  - !ruby/object:Gem::Dependency
13
55
  name: concurrent-ruby
14
56
  requirement: !ruby/object:Gem::Requirement