brainzlab 0.1.10 → 0.1.12
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/README.md +220 -4
- data/lib/brainzlab/beacon/client.rb +21 -1
- data/lib/brainzlab/configuration.rb +51 -2
- data/lib/brainzlab/cortex/client.rb +21 -1
- data/lib/brainzlab/debug.rb +305 -0
- data/lib/brainzlab/dendrite/client.rb +21 -1
- data/lib/brainzlab/development/logger.rb +150 -0
- data/lib/brainzlab/development/store.rb +121 -0
- data/lib/brainzlab/development.rb +72 -0
- data/lib/brainzlab/devtools/assets/devtools.css +326 -103
- data/lib/brainzlab/devtools/assets/devtools.js +79 -5
- data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +11 -0
- data/lib/brainzlab/errors.rb +490 -0
- data/lib/brainzlab/nerve/client.rb +21 -1
- data/lib/brainzlab/pulse/client.rb +66 -5
- data/lib/brainzlab/pulse.rb +17 -4
- data/lib/brainzlab/recall/client.rb +74 -6
- data/lib/brainzlab/recall.rb +19 -2
- data/lib/brainzlab/reflex/client.rb +66 -5
- data/lib/brainzlab/reflex.rb +40 -8
- data/lib/brainzlab/sentinel/client.rb +21 -1
- data/lib/brainzlab/synapse/client.rb +21 -1
- data/lib/brainzlab/testing/event_store.rb +377 -0
- data/lib/brainzlab/testing/helpers.rb +650 -0
- data/lib/brainzlab/testing/matchers.rb +391 -0
- data/lib/brainzlab/testing.rb +327 -0
- data/lib/brainzlab/utilities/circuit_breaker.rb +32 -3
- data/lib/brainzlab/vault/client.rb +21 -1
- data/lib/brainzlab/version.rb +1 -1
- data/lib/brainzlab/vision/client.rb +53 -6
- data/lib/brainzlab.rb +42 -0
- metadata +24 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
# Debug module for SDK operation logging
|
|
5
|
+
#
|
|
6
|
+
# Provides pretty-printed debug output for all SDK operations when debug mode is enabled.
|
|
7
|
+
# Includes timing information and request/response details.
|
|
8
|
+
#
|
|
9
|
+
# @example Enable debug mode
|
|
10
|
+
# BrainzLab.configure do |config|
|
|
11
|
+
# config.debug = true
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Use custom logger
|
|
15
|
+
# BrainzLab.configure do |config|
|
|
16
|
+
# config.debug = true
|
|
17
|
+
# config.logger = Logger.new(STDOUT)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example Manual debug logging
|
|
21
|
+
# BrainzLab::Debug.log("Custom message", level: :info)
|
|
22
|
+
#
|
|
23
|
+
module Debug
|
|
24
|
+
COLORS = {
|
|
25
|
+
reset: "\e[0m",
|
|
26
|
+
bold: "\e[1m",
|
|
27
|
+
dim: "\e[2m",
|
|
28
|
+
red: "\e[31m",
|
|
29
|
+
green: "\e[32m",
|
|
30
|
+
yellow: "\e[33m",
|
|
31
|
+
blue: "\e[34m",
|
|
32
|
+
magenta: "\e[35m",
|
|
33
|
+
cyan: "\e[36m",
|
|
34
|
+
white: "\e[37m",
|
|
35
|
+
gray: "\e[90m"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
LEVEL_COLORS = {
|
|
39
|
+
debug: :gray,
|
|
40
|
+
info: :cyan,
|
|
41
|
+
warn: :yellow,
|
|
42
|
+
error: :red,
|
|
43
|
+
fatal: :red
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
LEVEL_LABELS = {
|
|
47
|
+
debug: 'DEBUG',
|
|
48
|
+
info: 'INFO',
|
|
49
|
+
warn: 'WARN',
|
|
50
|
+
error: 'ERROR',
|
|
51
|
+
fatal: 'FATAL'
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Log a debug message if debug mode is enabled
|
|
56
|
+
#
|
|
57
|
+
# @param message [String] The message to log
|
|
58
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
|
|
59
|
+
# @param data [Hash] Optional additional data to include
|
|
60
|
+
# @return [void]
|
|
61
|
+
def log(message, level: :info, **data)
|
|
62
|
+
return unless enabled?
|
|
63
|
+
|
|
64
|
+
output = format_message(message, level: level, **data)
|
|
65
|
+
write_output(output, level: level)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Log an outgoing request
|
|
69
|
+
#
|
|
70
|
+
# @param service [String, Symbol] The service name (e.g., :recall, :reflex)
|
|
71
|
+
# @param method [String] HTTP method
|
|
72
|
+
# @param path [String] Request path
|
|
73
|
+
# @param data [Hash] Request payload summary
|
|
74
|
+
# @return [void]
|
|
75
|
+
def log_request(service, method, path, data: nil)
|
|
76
|
+
return unless enabled?
|
|
77
|
+
|
|
78
|
+
data_summary = summarize_data(data) if data
|
|
79
|
+
message = data_summary ? "#{method} #{path} #{data_summary}" : "#{method} #{path}"
|
|
80
|
+
|
|
81
|
+
output = format_arrow_message(:out, service, message)
|
|
82
|
+
write_output(output, level: :info)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Log an incoming response
|
|
86
|
+
#
|
|
87
|
+
# @param service [String, Symbol] The service name
|
|
88
|
+
# @param status [Integer] HTTP status code
|
|
89
|
+
# @param duration_ms [Float] Request duration in milliseconds
|
|
90
|
+
# @param error [String, nil] Error message if request failed
|
|
91
|
+
# @return [void]
|
|
92
|
+
def log_response(service, status, duration_ms, error: nil)
|
|
93
|
+
return unless enabled?
|
|
94
|
+
|
|
95
|
+
status_text = status_message(status)
|
|
96
|
+
duration_text = format_duration(duration_ms)
|
|
97
|
+
|
|
98
|
+
message = if error
|
|
99
|
+
"#{status} #{status_text} (#{duration_text}) - #{error}"
|
|
100
|
+
else
|
|
101
|
+
"#{status} #{status_text} (#{duration_text})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
level = status >= 400 ? :error : :info
|
|
105
|
+
output = format_arrow_message(:in, service, message, level: level)
|
|
106
|
+
write_output(output, level: level)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Log an SDK operation with timing
|
|
110
|
+
#
|
|
111
|
+
# @param service [String, Symbol] The service name
|
|
112
|
+
# @param operation [String] Operation description
|
|
113
|
+
# @param data [Hash] Operation data
|
|
114
|
+
# @return [void]
|
|
115
|
+
def log_operation(service, operation, **data)
|
|
116
|
+
return unless enabled?
|
|
117
|
+
|
|
118
|
+
data_summary = data.empty? ? '' : " (#{format_data_inline(data)})"
|
|
119
|
+
message = "#{operation}#{data_summary}"
|
|
120
|
+
|
|
121
|
+
output = format_arrow_message(:out, service, message)
|
|
122
|
+
write_output(output, level: :info)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Measure and log execution time of a block
|
|
126
|
+
#
|
|
127
|
+
# @param service [String, Symbol] The service name
|
|
128
|
+
# @param operation [String] Operation description
|
|
129
|
+
# @yield Block to measure
|
|
130
|
+
# @return [Object] Result of the block
|
|
131
|
+
def measure(service, operation)
|
|
132
|
+
return yield unless enabled?
|
|
133
|
+
|
|
134
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
log_operation(service, operation)
|
|
136
|
+
|
|
137
|
+
result = yield
|
|
138
|
+
|
|
139
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
140
|
+
log("#{operation} completed", level: :debug, duration_ms: duration_ms, service: service.to_s)
|
|
141
|
+
|
|
142
|
+
result
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
145
|
+
log("#{operation} failed: #{e.message}", level: :error, duration_ms: duration_ms, service: service.to_s)
|
|
146
|
+
raise
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check if debug mode is enabled
|
|
150
|
+
#
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def enabled?
|
|
153
|
+
BrainzLab.configuration.debug?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Check if colorized output should be used
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean]
|
|
159
|
+
def colorize?
|
|
160
|
+
return false unless enabled?
|
|
161
|
+
return @colorize if defined?(@colorize)
|
|
162
|
+
|
|
163
|
+
@colorize = $stdout.tty?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Reset colorize detection (useful for testing)
|
|
167
|
+
def reset_colorize!
|
|
168
|
+
remove_instance_variable(:@colorize) if defined?(@colorize)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def format_message(message, level:, **data)
|
|
174
|
+
timestamp = format_timestamp
|
|
175
|
+
prefix = colorize("[BrainzLab]", :bold, :blue)
|
|
176
|
+
level_badge = format_level(level)
|
|
177
|
+
data_str = data.empty? ? '' : " #{format_data(data)}"
|
|
178
|
+
|
|
179
|
+
"#{prefix} #{timestamp} #{level_badge} #{message}#{data_str}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def format_arrow_message(direction, service, message, level: :info)
|
|
183
|
+
timestamp = format_timestamp
|
|
184
|
+
prefix = colorize("[BrainzLab]", :bold, :blue)
|
|
185
|
+
arrow = direction == :out ? colorize("->", :cyan) : colorize("<-", :green)
|
|
186
|
+
service_name = colorize(service.to_s.capitalize, :magenta)
|
|
187
|
+
|
|
188
|
+
"#{prefix} #{timestamp} #{arrow} #{service_name} #{message}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def format_timestamp
|
|
192
|
+
time = Time.now.strftime('%H:%M:%S')
|
|
193
|
+
colorize(time, :dim)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def format_level(level)
|
|
197
|
+
label = LEVEL_LABELS[level] || level.to_s.upcase
|
|
198
|
+
color = LEVEL_COLORS[level] || :white
|
|
199
|
+
colorize("[#{label}]", color)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def format_duration(ms)
|
|
203
|
+
if ms < 1
|
|
204
|
+
colorize("#{(ms * 1000).round(0)}us", :green)
|
|
205
|
+
elsif ms < 100
|
|
206
|
+
colorize("#{ms.round(1)}ms", :green)
|
|
207
|
+
elsif ms < 1000
|
|
208
|
+
colorize("#{ms.round(0)}ms", :yellow)
|
|
209
|
+
else
|
|
210
|
+
colorize("#{(ms / 1000.0).round(2)}s", :red)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def format_data(data)
|
|
215
|
+
pairs = data.map { |k, v| "#{k}: #{format_value(v)}" }
|
|
216
|
+
colorize("(#{pairs.join(', ')})", :dim)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def format_data_inline(data)
|
|
220
|
+
data.map { |k, v| "#{k}: #{format_value(v)}" }.join(', ')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def format_value(value)
|
|
224
|
+
case value
|
|
225
|
+
when String
|
|
226
|
+
value.length > 50 ? "#{value[0..47]}..." : value
|
|
227
|
+
when Hash
|
|
228
|
+
"{#{value.keys.join(', ')}}"
|
|
229
|
+
when Array
|
|
230
|
+
"[#{value.length} items]"
|
|
231
|
+
else
|
|
232
|
+
value.to_s
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def summarize_data(data)
|
|
237
|
+
return nil unless data.is_a?(Hash)
|
|
238
|
+
|
|
239
|
+
summary_parts = []
|
|
240
|
+
summary_parts << "\"#{truncate(data[:message] || data['message'], 30)}\"" if data[:message] || data['message']
|
|
241
|
+
|
|
242
|
+
other_keys = data.keys.reject { |k| %i[message timestamp level].include?(k.to_sym) }
|
|
243
|
+
if other_keys.any?
|
|
244
|
+
key_summary = other_keys.take(3).map { |k| "#{k}: #{format_value(data[k])}" }.join(', ')
|
|
245
|
+
key_summary += ", ..." if other_keys.length > 3
|
|
246
|
+
summary_parts << "(#{key_summary})"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
summary_parts.join(' ')
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def truncate(str, length)
|
|
253
|
+
return '' unless str
|
|
254
|
+
|
|
255
|
+
str = str.to_s
|
|
256
|
+
str.length > length ? "#{str[0..length - 4]}..." : str
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def status_message(status)
|
|
260
|
+
case status
|
|
261
|
+
when 200 then 'OK'
|
|
262
|
+
when 201 then 'Created'
|
|
263
|
+
when 204 then 'No Content'
|
|
264
|
+
when 400 then 'Bad Request'
|
|
265
|
+
when 401 then 'Unauthorized'
|
|
266
|
+
when 403 then 'Forbidden'
|
|
267
|
+
when 404 then 'Not Found'
|
|
268
|
+
when 422 then 'Unprocessable Entity'
|
|
269
|
+
when 429 then 'Too Many Requests'
|
|
270
|
+
when 500 then 'Internal Server Error'
|
|
271
|
+
when 502 then 'Bad Gateway'
|
|
272
|
+
when 503 then 'Service Unavailable'
|
|
273
|
+
else ''
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def colorize(text, *colors)
|
|
278
|
+
return text unless colorize?
|
|
279
|
+
|
|
280
|
+
color_codes = colors.map { |c| COLORS[c] }.compact.join
|
|
281
|
+
"#{color_codes}#{text}#{COLORS[:reset]}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def write_output(output, level:)
|
|
285
|
+
config = BrainzLab.configuration
|
|
286
|
+
|
|
287
|
+
if config.logger
|
|
288
|
+
case level
|
|
289
|
+
when :debug then config.logger.debug(strip_colors(output))
|
|
290
|
+
when :info then config.logger.info(strip_colors(output))
|
|
291
|
+
when :warn then config.logger.warn(strip_colors(output))
|
|
292
|
+
when :error, :fatal then config.logger.error(strip_colors(output))
|
|
293
|
+
else config.logger.info(strip_colors(output))
|
|
294
|
+
end
|
|
295
|
+
else
|
|
296
|
+
$stderr.puts output
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def strip_colors(text)
|
|
301
|
+
text.gsub(/\e\[\d+m/, '')
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -223,7 +223,27 @@ module BrainzLab
|
|
|
223
223
|
end
|
|
224
224
|
|
|
225
225
|
def log_error(operation, error)
|
|
226
|
-
|
|
226
|
+
structured_error = ErrorHandler.wrap(error, service: 'Dendrite', operation: operation)
|
|
227
|
+
BrainzLab.debug_log("[Dendrite::Client] #{operation} failed: #{structured_error.message}")
|
|
228
|
+
|
|
229
|
+
# Call on_error callback if configured
|
|
230
|
+
if @config.on_error
|
|
231
|
+
@config.on_error.call(structured_error, { service: 'Dendrite', operation: operation })
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def handle_response_error(response, operation)
|
|
236
|
+
return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated) || response.is_a?(Net::HTTPNoContent) || response.is_a?(Net::HTTPAccepted)
|
|
237
|
+
|
|
238
|
+
structured_error = ErrorHandler.from_response(response, service: 'Dendrite', operation: operation)
|
|
239
|
+
BrainzLab.debug_log("[Dendrite::Client] #{operation} failed: #{structured_error.message}")
|
|
240
|
+
|
|
241
|
+
# Call on_error callback if configured
|
|
242
|
+
if @config.on_error
|
|
243
|
+
@config.on_error.call(structured_error, { service: 'Dendrite', operation: operation })
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
structured_error
|
|
227
247
|
end
|
|
228
248
|
end
|
|
229
249
|
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module BrainzLab
|
|
6
|
+
module Development
|
|
7
|
+
# Pretty-prints development mode events to stdout
|
|
8
|
+
class Logger
|
|
9
|
+
# ANSI color codes
|
|
10
|
+
COLORS = {
|
|
11
|
+
reset: "\e[0m",
|
|
12
|
+
bold: "\e[1m",
|
|
13
|
+
dim: "\e[2m",
|
|
14
|
+
# Services
|
|
15
|
+
recall: "\e[36m", # Cyan
|
|
16
|
+
reflex: "\e[31m", # Red
|
|
17
|
+
pulse: "\e[33m", # Yellow
|
|
18
|
+
flux: "\e[35m", # Magenta
|
|
19
|
+
signal: "\e[32m", # Green
|
|
20
|
+
vault: "\e[34m", # Blue
|
|
21
|
+
vision: "\e[95m", # Light magenta
|
|
22
|
+
cortex: "\e[96m", # Light cyan
|
|
23
|
+
beacon: "\e[92m", # Light green
|
|
24
|
+
nerve: "\e[93m", # Light yellow
|
|
25
|
+
dendrite: "\e[94m", # Light blue
|
|
26
|
+
sentinel: "\e[91m", # Light red
|
|
27
|
+
synapse: "\e[97m", # White
|
|
28
|
+
# Log levels
|
|
29
|
+
debug: "\e[37m", # Gray
|
|
30
|
+
info: "\e[32m", # Green
|
|
31
|
+
warn: "\e[33m", # Yellow
|
|
32
|
+
error: "\e[31m", # Red
|
|
33
|
+
fatal: "\e[35m" # Magenta
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def initialize(output: $stdout, colors: nil)
|
|
37
|
+
@output = output
|
|
38
|
+
@colors = colors.nil? ? tty? : colors
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Log an event to stdout in a readable format
|
|
42
|
+
# @param service [Symbol] :recall, :reflex, :pulse, etc.
|
|
43
|
+
# @param event_type [String] type of event
|
|
44
|
+
# @param payload [Hash] event data
|
|
45
|
+
def log(service:, event_type:, payload:)
|
|
46
|
+
timestamp = Time.now.strftime('%H:%M:%S.%L')
|
|
47
|
+
service_color = COLORS[service] || COLORS[:reset]
|
|
48
|
+
|
|
49
|
+
# Build the log line
|
|
50
|
+
parts = []
|
|
51
|
+
parts << colorize("[#{timestamp}]", :dim)
|
|
52
|
+
parts << colorize("[#{service.to_s.upcase}]", service_color, bold: true)
|
|
53
|
+
parts << colorize(event_type, :bold)
|
|
54
|
+
|
|
55
|
+
# Add message or name depending on event type
|
|
56
|
+
message = extract_message(payload, event_type)
|
|
57
|
+
parts << message if message
|
|
58
|
+
|
|
59
|
+
# Print the main line
|
|
60
|
+
@output.puts parts.join(' ')
|
|
61
|
+
|
|
62
|
+
# Print additional details indented
|
|
63
|
+
print_details(payload, event_type)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def tty?
|
|
69
|
+
@output.respond_to?(:tty?) && @output.tty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def colorize(text, color, bold: false)
|
|
73
|
+
return text unless @colors
|
|
74
|
+
|
|
75
|
+
color_code = color.is_a?(Symbol) ? COLORS[color] : color
|
|
76
|
+
prefix = bold ? "#{COLORS[:bold]}#{color_code}" : color_code.to_s
|
|
77
|
+
"#{prefix}#{text}#{COLORS[:reset]}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_message(payload, event_type)
|
|
81
|
+
case event_type
|
|
82
|
+
when 'log'
|
|
83
|
+
level = payload[:level]&.to_sym
|
|
84
|
+
level_color = COLORS[level] || COLORS[:info]
|
|
85
|
+
msg = "#{colorize("[#{level&.upcase}]", level_color)} #{payload[:message]}"
|
|
86
|
+
msg
|
|
87
|
+
when 'error'
|
|
88
|
+
"#{payload[:error_class]}: #{payload[:message]}"
|
|
89
|
+
when 'trace'
|
|
90
|
+
duration = payload[:duration_ms] ? "(#{payload[:duration_ms]}ms)" : ''
|
|
91
|
+
"#{payload[:name]} #{duration}"
|
|
92
|
+
when 'metric'
|
|
93
|
+
"#{payload[:name]} = #{payload[:value]}"
|
|
94
|
+
when 'span'
|
|
95
|
+
duration = payload[:duration_ms] ? "(#{payload[:duration_ms]}ms)" : ''
|
|
96
|
+
"#{payload[:name]} #{duration}"
|
|
97
|
+
else
|
|
98
|
+
payload[:message] || payload[:name]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def print_details(payload, event_type)
|
|
103
|
+
details = extract_details(payload, event_type)
|
|
104
|
+
return if details.empty?
|
|
105
|
+
|
|
106
|
+
details.each do |key, value|
|
|
107
|
+
formatted_value = format_value(value)
|
|
108
|
+
@output.puts " #{colorize(key.to_s, :dim)}: #{formatted_value}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extract_details(payload, event_type)
|
|
113
|
+
# Fields to exclude from details (already shown in main line)
|
|
114
|
+
excluded = %i[timestamp message level name kind]
|
|
115
|
+
|
|
116
|
+
case event_type
|
|
117
|
+
when 'log'
|
|
118
|
+
payload.except(*excluded, :environment, :service, :host)
|
|
119
|
+
when 'error'
|
|
120
|
+
payload.slice(:error_class, :environment, :request_id, :user, :tags).compact
|
|
121
|
+
when 'trace'
|
|
122
|
+
payload.slice(:request_method, :request_path, :status, :db_ms, :view_ms, :spans).compact
|
|
123
|
+
when 'metric'
|
|
124
|
+
payload.slice(:kind, :tags).compact
|
|
125
|
+
else
|
|
126
|
+
payload.except(*excluded)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def format_value(value)
|
|
131
|
+
case value
|
|
132
|
+
when Hash
|
|
133
|
+
if value.size <= 3
|
|
134
|
+
value.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
|
|
135
|
+
else
|
|
136
|
+
"\n #{JSON.pretty_generate(value).gsub("\n", "\n ")}"
|
|
137
|
+
end
|
|
138
|
+
when Array
|
|
139
|
+
if value.size <= 3 && value.all? { |v| v.is_a?(String) || v.is_a?(Numeric) }
|
|
140
|
+
value.inspect
|
|
141
|
+
else
|
|
142
|
+
"\n #{JSON.pretty_generate(value).gsub("\n", "\n ")}"
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
value.inspect
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module BrainzLab
|
|
8
|
+
module Development
|
|
9
|
+
# SQLite-backed store for development mode events
|
|
10
|
+
class Store
|
|
11
|
+
DEFAULT_PATH = 'tmp/brainzlab.sqlite3'
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
@db_path = config.development_db_path || DEFAULT_PATH
|
|
16
|
+
@db = nil
|
|
17
|
+
ensure_database!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Insert an event into the store
|
|
21
|
+
# @param service [Symbol] :recall, :reflex, :pulse, etc.
|
|
22
|
+
# @param event_type [String] type of event
|
|
23
|
+
# @param payload [Hash] event data
|
|
24
|
+
def insert(service:, event_type:, payload:)
|
|
25
|
+
db.execute(
|
|
26
|
+
'INSERT INTO events (service, event_type, payload, created_at) VALUES (?, ?, ?, ?)',
|
|
27
|
+
[service.to_s, event_type.to_s, JSON.generate(payload), Time.now.utc.iso8601(3)]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Query events from the store
|
|
32
|
+
# @param service [Symbol, nil] filter by service
|
|
33
|
+
# @param event_type [String, nil] filter by event type
|
|
34
|
+
# @param since [Time, nil] filter events after this time
|
|
35
|
+
# @param limit [Integer] max number of events to return
|
|
36
|
+
# @return [Array<Hash>] matching events
|
|
37
|
+
def query(service: nil, event_type: nil, since: nil, limit: 100)
|
|
38
|
+
conditions = []
|
|
39
|
+
params = []
|
|
40
|
+
|
|
41
|
+
if service
|
|
42
|
+
conditions << 'service = ?'
|
|
43
|
+
params << service.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if event_type
|
|
47
|
+
conditions << 'event_type = ?'
|
|
48
|
+
params << event_type.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if since
|
|
52
|
+
conditions << 'created_at >= ?'
|
|
53
|
+
params << since.utc.iso8601(3)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
|
57
|
+
params << limit
|
|
58
|
+
|
|
59
|
+
sql = "SELECT id, service, event_type, payload, created_at FROM events #{where_clause} ORDER BY created_at DESC LIMIT ?"
|
|
60
|
+
|
|
61
|
+
db.execute(sql, params).map do |row|
|
|
62
|
+
{
|
|
63
|
+
id: row[0],
|
|
64
|
+
service: row[1].to_sym,
|
|
65
|
+
event_type: row[2],
|
|
66
|
+
payload: JSON.parse(row[3], symbolize_names: true),
|
|
67
|
+
created_at: Time.parse(row[4])
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get event counts by service
|
|
73
|
+
def stats
|
|
74
|
+
results = db.execute('SELECT service, COUNT(*) as count FROM events GROUP BY service')
|
|
75
|
+
results.to_h { |row| [row[0].to_sym, row[1]] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Clear all events
|
|
79
|
+
def clear!
|
|
80
|
+
db.execute('DELETE FROM events')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Close the database connection
|
|
84
|
+
def close
|
|
85
|
+
@db&.close
|
|
86
|
+
@db = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def db
|
|
92
|
+
@db ||= begin
|
|
93
|
+
SQLite3::Database.new(@db_path).tap do |database|
|
|
94
|
+
database.results_as_hash = false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_database!
|
|
100
|
+
# Ensure the directory exists
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(@db_path))
|
|
102
|
+
|
|
103
|
+
# Create the events table if it doesn't exist
|
|
104
|
+
db.execute(<<~SQL)
|
|
105
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
106
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
107
|
+
service TEXT NOT NULL,
|
|
108
|
+
event_type TEXT NOT NULL,
|
|
109
|
+
payload TEXT NOT NULL,
|
|
110
|
+
created_at TEXT NOT NULL
|
|
111
|
+
)
|
|
112
|
+
SQL
|
|
113
|
+
|
|
114
|
+
# Create indexes for common queries
|
|
115
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_events_service ON events(service)')
|
|
116
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type)')
|
|
117
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at)')
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'development/store'
|
|
4
|
+
require_relative 'development/logger'
|
|
5
|
+
|
|
6
|
+
module BrainzLab
|
|
7
|
+
# Development mode support for offline SDK usage
|
|
8
|
+
# Logs all events to stdout and stores them locally in SQLite
|
|
9
|
+
module Development
|
|
10
|
+
class << self
|
|
11
|
+
# Check if development mode is enabled
|
|
12
|
+
def enabled?
|
|
13
|
+
BrainzLab.configuration.mode == :development
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get the store instance
|
|
17
|
+
def store
|
|
18
|
+
@store ||= Store.new(BrainzLab.configuration)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get the development logger
|
|
22
|
+
def logger
|
|
23
|
+
@logger ||= Logger.new(output: BrainzLab.configuration.development_log_output || $stdout)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Record an event from any service
|
|
27
|
+
# @param service [Symbol] :recall, :reflex, :pulse, etc.
|
|
28
|
+
# @param event_type [String] type of event (log, error, trace, metric, etc.)
|
|
29
|
+
# @param payload [Hash] event data
|
|
30
|
+
def record(service:, event_type:, payload:)
|
|
31
|
+
return unless enabled?
|
|
32
|
+
|
|
33
|
+
# Log to stdout
|
|
34
|
+
logger.log(service: service, event_type: event_type, payload: payload)
|
|
35
|
+
|
|
36
|
+
# Store in SQLite
|
|
37
|
+
store.insert(service: service, event_type: event_type, payload: payload)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Query stored events
|
|
41
|
+
# @param service [Symbol, nil] filter by service
|
|
42
|
+
# @param event_type [String, nil] filter by event type
|
|
43
|
+
# @param since [Time, nil] filter events after this time
|
|
44
|
+
# @param limit [Integer] max number of events to return (default: 100)
|
|
45
|
+
# @return [Array<Hash>] matching events
|
|
46
|
+
def events(service: nil, event_type: nil, since: nil, limit: 100)
|
|
47
|
+
return [] unless enabled?
|
|
48
|
+
|
|
49
|
+
store.query(service: service, event_type: event_type, since: since, limit: limit)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Clear all stored events
|
|
53
|
+
def clear!
|
|
54
|
+
store.clear! if enabled?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reset the development module (for testing)
|
|
58
|
+
def reset!
|
|
59
|
+
@store&.close
|
|
60
|
+
@store = nil
|
|
61
|
+
@logger = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get event counts by service
|
|
65
|
+
def stats
|
|
66
|
+
return {} unless enabled?
|
|
67
|
+
|
|
68
|
+
store.stats
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|