observable 0.1.0 → 0.1.4
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/.claude/settings.local.json +9 -0
- data/.ruby-version +1 -0
- data/.standard.yml +5 -1
- data/CHANGELOG.md +52 -0
- data/CLAUDE.md +135 -0
- data/Gemfile +0 -6
- data/Gemfile.lock +178 -0
- data/README.md +156 -25
- data/Rakefile +2 -4
- data/example.rb +55 -0
- data/lib/observable/configuration.rb +17 -0
- data/lib/observable/instrumenter.rb +382 -0
- data/lib/observable/persistence/span.rb +119 -0
- data/lib/observable/persistence/span_repo.rb +142 -0
- data/lib/observable/persistence/trace.rb +22 -0
- data/lib/observable/persistence/trace_repo.rb +43 -0
- data/lib/observable/structured_error.rb +109 -0
- data/lib/observable/tracing_test_helper.rb +51 -0
- data/lib/observable/version.rb +1 -1
- data/lib/observable.rb +23 -1
- data/observable.gemspec +49 -0
- data/pretty.rb +12 -0
- metadata +197 -10
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
require "opentelemetry/sdk"
|
|
2
|
+
require_relative "configuration"
|
|
3
|
+
|
|
4
|
+
module Observable
|
|
5
|
+
class Instrumenter
|
|
6
|
+
attr_reader :last_captured_namespace, :config
|
|
7
|
+
|
|
8
|
+
def initialize(tracer: nil, config: nil)
|
|
9
|
+
@config = config || Configuration.config
|
|
10
|
+
@tracer = tracer || OpenTelemetry.tracer_provider.tracer(@config.tracer_name)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def instrument(caller_binding = nil, &block)
|
|
14
|
+
@caller_binding = caller_binding
|
|
15
|
+
caller_info = extract_caller_information
|
|
16
|
+
create_instrumented_span(caller_info, &block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def extract_caller_information
|
|
22
|
+
caller_location = find_caller_location
|
|
23
|
+
namespace = extract_actual_class_name
|
|
24
|
+
is_class_method = determine_if_class_method
|
|
25
|
+
|
|
26
|
+
CallerInformation.new(
|
|
27
|
+
method_name: caller_location.label,
|
|
28
|
+
namespace: namespace,
|
|
29
|
+
filepath: caller_location.path,
|
|
30
|
+
line_number: caller_location.lineno,
|
|
31
|
+
arguments: extract_arguments_from_caller(caller_location),
|
|
32
|
+
is_class_method: is_class_method
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find_caller_location
|
|
37
|
+
locations = caller_locations(1, 10)
|
|
38
|
+
raise InstrumentationError, "Unable to determine caller location" if locations.nil? || locations.empty?
|
|
39
|
+
|
|
40
|
+
instrumenter_file = __FILE__
|
|
41
|
+
caller_location = locations.find { |loc| loc.path != instrumenter_file }
|
|
42
|
+
|
|
43
|
+
caller_location || locations.first
|
|
44
|
+
rescue => e
|
|
45
|
+
raise InstrumentationError, "Error finding caller location: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_namespace_from_path(file_path)
|
|
49
|
+
return "UnknownClass" if file_path.nil? || file_path.empty?
|
|
50
|
+
|
|
51
|
+
file_name = File.basename(file_path, ".*")
|
|
52
|
+
return "UnknownClass" if file_name.empty?
|
|
53
|
+
|
|
54
|
+
file_name.split("_").map(&:capitalize).join
|
|
55
|
+
rescue
|
|
56
|
+
"UnknownClass"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_actual_class_name
|
|
60
|
+
if @caller_binding
|
|
61
|
+
begin
|
|
62
|
+
caller_self = @caller_binding.eval("self")
|
|
63
|
+
if caller_self.is_a?(Class)
|
|
64
|
+
caller_self.name
|
|
65
|
+
else
|
|
66
|
+
caller_self.class.name
|
|
67
|
+
end
|
|
68
|
+
rescue
|
|
69
|
+
extract_namespace_from_path(@caller_binding.eval("__FILE__"))
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
caller_location = find_caller_location
|
|
73
|
+
extract_namespace_from_path(caller_location.path)
|
|
74
|
+
end
|
|
75
|
+
rescue
|
|
76
|
+
"UnknownClass"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def determine_if_class_method
|
|
80
|
+
return false unless @caller_binding
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
caller_self = @caller_binding.eval("self")
|
|
84
|
+
caller_self.is_a?(Class)
|
|
85
|
+
rescue
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def create_instrumented_span(caller_info, &block)
|
|
91
|
+
separator = caller_info.is_class_method ? "." : "#"
|
|
92
|
+
|
|
93
|
+
if caller_info.method_name.include?("#") || caller_info.method_name.include?(".")
|
|
94
|
+
span_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name.split(/[#.]/).last}"
|
|
95
|
+
function_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name.split(/[#.]/).last}"
|
|
96
|
+
method_name_only = caller_info.method_name.split(/[#.]/).last
|
|
97
|
+
else
|
|
98
|
+
span_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name}"
|
|
99
|
+
function_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name}"
|
|
100
|
+
method_name_only = caller_info.method_name
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@last_captured_method_name = method_name_only
|
|
104
|
+
@last_captured_namespace = caller_info.namespace
|
|
105
|
+
@tracer.in_span(span_name) do |span|
|
|
106
|
+
set_span_attributes(span, caller_info, function_name)
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
result = block.call
|
|
110
|
+
span.set_attribute("error", false)
|
|
111
|
+
serialize_return_value(span, result)
|
|
112
|
+
result
|
|
113
|
+
rescue => e
|
|
114
|
+
span.set_attribute("error", true)
|
|
115
|
+
span.set_attribute("exception.type", e.respond_to?(:type) ? e.type : e.class.name)
|
|
116
|
+
span.set_attribute("exception.message", e.message)
|
|
117
|
+
span.set_attribute("exception.stacktrace", e.full_message(highlight: false))
|
|
118
|
+
latest_backtrace_line = e.backtrace.find { |line| line.include?(caller_info.filepath) }
|
|
119
|
+
span.set_attribute("code.lineno", latest_backtrace_line.split(":")[1].to_i) if latest_backtrace_line
|
|
120
|
+
serialize_argument(span, "exception.context", e.to_h.except(:message)) if e.respond_to?(:to_h)
|
|
121
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
122
|
+
raise
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def set_span_attributes(span, caller_info, function_name)
|
|
128
|
+
span.set_attribute("code.function", function_name)
|
|
129
|
+
span.set_attribute("code.namespace", caller_info.namespace)
|
|
130
|
+
span.set_attribute("code.filepath", caller_info.filepath)
|
|
131
|
+
span.set_attribute("code.lineno", caller_info.line_number)
|
|
132
|
+
|
|
133
|
+
if @config.app_namespace
|
|
134
|
+
span.set_attribute("app.namespace", @config.app_namespace)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
caller_info.arguments.each do |param_index, param_data|
|
|
138
|
+
if param_data.is_a?(Hash) && param_data.key?(:value) && param_data.key?(:param_name)
|
|
139
|
+
serialize_argument(span, "code.arguments.#{param_index}", param_data[:value], param_data[:param_name])
|
|
140
|
+
else
|
|
141
|
+
serialize_argument(span, "code.arguments.#{param_index}", param_data, param_index)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def serialize_argument(span, attribute_prefix, value, param_name = nil, depth = 0)
|
|
147
|
+
if param_name && should_filter_pii?(param_name)
|
|
148
|
+
span.set_attribute(attribute_prefix, "[FILTERED]")
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
case value
|
|
153
|
+
when String, Numeric, TrueClass, FalseClass
|
|
154
|
+
span.set_attribute(attribute_prefix, value)
|
|
155
|
+
when NilClass
|
|
156
|
+
span.set_attribute(attribute_prefix, "nil")
|
|
157
|
+
when Hash
|
|
158
|
+
serialize_hash(span, attribute_prefix, value, depth)
|
|
159
|
+
when Array
|
|
160
|
+
serialize_array(span, attribute_prefix, value, depth)
|
|
161
|
+
else
|
|
162
|
+
serialize_object(span, attribute_prefix, value, depth)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def serialize_argument_with_custom_depth(span, attribute_prefix, value, custom_max_depth, param_name = nil, depth = 0)
|
|
167
|
+
if param_name && should_filter_pii?(param_name)
|
|
168
|
+
span.set_attribute(attribute_prefix, "[FILTERED]")
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
case value
|
|
173
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
174
|
+
span.set_attribute(attribute_prefix, value)
|
|
175
|
+
when Hash
|
|
176
|
+
serialize_hash_with_custom_depth(span, attribute_prefix, value, custom_max_depth, depth)
|
|
177
|
+
when Array
|
|
178
|
+
serialize_array_with_custom_depth(span, attribute_prefix, value, custom_max_depth, depth)
|
|
179
|
+
else
|
|
180
|
+
serialize_object_with_custom_depth(span, attribute_prefix, value, custom_max_depth, depth)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def serialize_return_value(span, value)
|
|
185
|
+
return unless @config.track_return_values
|
|
186
|
+
|
|
187
|
+
case value
|
|
188
|
+
when NilClass
|
|
189
|
+
span.set_attribute("code.return", "nil")
|
|
190
|
+
when String, Numeric, TrueClass, FalseClass
|
|
191
|
+
span.set_attribute("code.return", value)
|
|
192
|
+
when Hash
|
|
193
|
+
serialize_hash(span, "code.return", value, 2)
|
|
194
|
+
when Array
|
|
195
|
+
serialize_array(span, "code.return", value, 2)
|
|
196
|
+
else
|
|
197
|
+
serialize_object(span, "code.return", value, 2)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def serialize_hash(span, prefix, hash, depth = 0)
|
|
202
|
+
max_depth = get_serialization_depth_for_class("Hash")
|
|
203
|
+
return if depth > max_depth
|
|
204
|
+
|
|
205
|
+
hash.each do |key, value|
|
|
206
|
+
key_str = key.to_s
|
|
207
|
+
next if should_filter_pii?(key_str)
|
|
208
|
+
serialize_argument(span, "#{prefix}.#{key_str}", value, nil, depth + 1)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def serialize_hash_with_custom_depth(span, prefix, hash, custom_max_depth, depth = 0)
|
|
213
|
+
return if depth > custom_max_depth
|
|
214
|
+
|
|
215
|
+
hash.each do |key, value|
|
|
216
|
+
key_str = key.to_s
|
|
217
|
+
next if should_filter_pii?(key_str)
|
|
218
|
+
serialize_argument_with_custom_depth(span, "#{prefix}.#{key_str}", value, custom_max_depth, nil, depth + 1)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def serialize_array_with_custom_depth(span, prefix, array, custom_max_depth, depth = 0)
|
|
223
|
+
return if depth > custom_max_depth
|
|
224
|
+
|
|
225
|
+
items_to_process = [array.length, 10].min
|
|
226
|
+
array.first(items_to_process).each_with_index do |item, index|
|
|
227
|
+
serialize_argument_with_custom_depth(span, "#{prefix}.#{index}", item, custom_max_depth, nil, depth + 1)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def serialize_object_with_custom_depth(span, prefix, obj, custom_max_depth, depth = 0)
|
|
232
|
+
if depth >= custom_max_depth
|
|
233
|
+
span.set_attribute("#{prefix}.class", obj.class.name)
|
|
234
|
+
span.set_attribute(prefix, obj.to_s)
|
|
235
|
+
return
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
span.set_attribute("#{prefix}.class", obj.class.name)
|
|
239
|
+
|
|
240
|
+
formatter = find_formatter_for_class(obj.class.name)
|
|
241
|
+
|
|
242
|
+
if formatter && obj.respond_to?(formatter)
|
|
243
|
+
result = obj.send(formatter)
|
|
244
|
+
if result.is_a?(Hash)
|
|
245
|
+
serialize_hash_with_custom_depth(span, prefix, result, custom_max_depth, depth)
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
default_formatter = @config.formatters[:default]
|
|
251
|
+
if default_formatter && obj.respond_to?(default_formatter)
|
|
252
|
+
result = obj.send(default_formatter)
|
|
253
|
+
if result.is_a?(Hash)
|
|
254
|
+
serialize_hash_with_custom_depth(span, prefix, result, custom_max_depth, depth)
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
span.set_attribute(prefix, obj.to_s)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def serialize_array(span, prefix, array, depth = 0)
|
|
263
|
+
max_depth = get_serialization_depth_for_class("Array")
|
|
264
|
+
return if depth > max_depth
|
|
265
|
+
|
|
266
|
+
items_to_process = [array.length, 10].min
|
|
267
|
+
array.first(items_to_process).each_with_index do |item, index|
|
|
268
|
+
serialize_argument(span, "#{prefix}.#{index}", item, nil, depth + 1)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def serialize_object(span, prefix, obj, depth = 0)
|
|
273
|
+
max_depth = get_serialization_depth_for_class(obj.class.name)
|
|
274
|
+
|
|
275
|
+
# If we've reached the depth limit for this class, just set the class name
|
|
276
|
+
if depth >= max_depth
|
|
277
|
+
span.set_attribute("#{prefix}.class", obj.class.name)
|
|
278
|
+
span.set_attribute(prefix, obj.to_s)
|
|
279
|
+
return
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
span.set_attribute("#{prefix}.class", obj.class.name)
|
|
283
|
+
|
|
284
|
+
formatter = find_formatter_for_class(obj.class.name)
|
|
285
|
+
|
|
286
|
+
if formatter && obj.respond_to?(formatter)
|
|
287
|
+
result = obj.send(formatter)
|
|
288
|
+
if result.is_a?(Hash)
|
|
289
|
+
# When object converts to hash, the hash starts fresh at depth 0
|
|
290
|
+
# but we need to enforce the object's depth limit
|
|
291
|
+
serialize_hash_with_custom_depth(span, prefix, result, max_depth, 0)
|
|
292
|
+
return
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
default_formatter = @config.formatters[:default]
|
|
297
|
+
if default_formatter && obj.respond_to?(default_formatter)
|
|
298
|
+
result = obj.send(default_formatter)
|
|
299
|
+
if result.is_a?(Hash)
|
|
300
|
+
# When object converts to hash, the hash starts fresh at depth 0
|
|
301
|
+
# but we need to enforce the object's depth limit
|
|
302
|
+
serialize_hash_with_custom_depth(span, prefix, result, max_depth, 0)
|
|
303
|
+
return
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
span.set_attribute(prefix, obj.to_s)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def find_formatter_for_class(class_name)
|
|
311
|
+
@config.formatters[class_name]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def get_serialization_depth_for_class(class_name)
|
|
315
|
+
if @config.serialization_depth.is_a?(Integer)
|
|
316
|
+
return @config.serialization_depth
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
@config.serialization_depth[class_name] || @config.serialization_depth[:default] || @config.serialization_depth["default"] || 2
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def should_filter_pii?(param_name)
|
|
323
|
+
return false if @config.pii_filters.empty?
|
|
324
|
+
|
|
325
|
+
param_name_str = param_name.to_s.downcase
|
|
326
|
+
@config.pii_filters.any? { |pattern| pattern.match?(param_name_str) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def extract_arguments_from_caller(caller_location)
|
|
330
|
+
ArgumentExtractor.new(caller_location, @caller_binding).extract
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
CallerInformation = Struct.new(:method_name, :namespace, :filepath, :line_number, :arguments, :is_class_method, keyword_init: true)
|
|
334
|
+
|
|
335
|
+
class ArgumentExtractor
|
|
336
|
+
def initialize(caller_location, caller_binding = nil)
|
|
337
|
+
@caller_location = caller_location
|
|
338
|
+
@caller_binding = caller_binding
|
|
339
|
+
@method_name = caller_location.label
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def extract
|
|
343
|
+
return {} unless @caller_binding
|
|
344
|
+
extract_from_binding
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
def extract_from_binding
|
|
350
|
+
@caller_binding.local_variables
|
|
351
|
+
args = {}
|
|
352
|
+
|
|
353
|
+
extract_local_variables_with_numeric_indexing(args)
|
|
354
|
+
|
|
355
|
+
args
|
|
356
|
+
rescue
|
|
357
|
+
{}
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def extract_local_variables_with_numeric_indexing(args)
|
|
361
|
+
local_vars = @caller_binding.local_variables
|
|
362
|
+
positional_index = 0
|
|
363
|
+
|
|
364
|
+
local_vars.each do |var_name|
|
|
365
|
+
var_name_str = var_name.to_s
|
|
366
|
+
|
|
367
|
+
next if var_name_str.start_with?("_") || var_name == :instrumenter
|
|
368
|
+
|
|
369
|
+
value = @caller_binding.local_variable_get(var_name)
|
|
370
|
+
|
|
371
|
+
args[positional_index.to_s] = {
|
|
372
|
+
value: value,
|
|
373
|
+
param_name: var_name_str
|
|
374
|
+
}
|
|
375
|
+
positional_index += 1
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
class InstrumentationError < StandardError; end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require "dry-struct"
|
|
2
|
+
|
|
3
|
+
module Observable
|
|
4
|
+
module Persistence
|
|
5
|
+
class Span < Dry::Struct
|
|
6
|
+
attribute :id, Dry.Types::String
|
|
7
|
+
attribute :name, Dry.Types::String
|
|
8
|
+
attribute :kind, Dry.Types::Symbol
|
|
9
|
+
attribute :trace_id, Dry.Types::String
|
|
10
|
+
attribute :attrs, Dry.Types::Hash
|
|
11
|
+
|
|
12
|
+
# ANSI Color constants
|
|
13
|
+
RESET = "\e[0m"
|
|
14
|
+
BOLD = "\e[1m"
|
|
15
|
+
DIM = "\e[2m"
|
|
16
|
+
CYAN = "\e[36m"
|
|
17
|
+
YELLOW = "\e[33m"
|
|
18
|
+
GREEN = "\e[32m"
|
|
19
|
+
BLUE = "\e[34m"
|
|
20
|
+
MAGENTA = "\e[35m"
|
|
21
|
+
WHITE = "\e[37m"
|
|
22
|
+
|
|
23
|
+
def hex_trace_id
|
|
24
|
+
trace_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hex_span_id
|
|
28
|
+
id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def code_namespace
|
|
32
|
+
attrs["code.namespace"] || attrs["messaging.sidekiq.job_class"] || ""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def producer?
|
|
36
|
+
kind == :producer
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consumer?
|
|
40
|
+
kind == :consumer
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.from_spandata(span_or_spandata)
|
|
44
|
+
new(
|
|
45
|
+
id: span_or_spandata.hex_span_id,
|
|
46
|
+
trace_id: span_or_spandata.hex_trace_id,
|
|
47
|
+
name: span_or_spandata.name,
|
|
48
|
+
kind: span_or_spandata.kind,
|
|
49
|
+
attrs: span_or_spandata.attributes
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from_span_or_spandata(span_or_spandata)
|
|
54
|
+
if span_or_spandata.is_a?(Span)
|
|
55
|
+
span_or_spandata
|
|
56
|
+
else
|
|
57
|
+
from_spandata(span_or_spandata)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def inspect
|
|
62
|
+
ai
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ai
|
|
66
|
+
output = []
|
|
67
|
+
output << " #{colorize(name, CYAN, BOLD)}"
|
|
68
|
+
output << " id: #{colorize(id, WHITE)}"
|
|
69
|
+
|
|
70
|
+
if attrs.any?
|
|
71
|
+
attrs.each do |key, value|
|
|
72
|
+
output << " #{colorize(key, YELLOW)}: #{colorize_value(value)}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
output << ""
|
|
77
|
+
output.join("\n")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def colorize(text, color, style = nil)
|
|
83
|
+
if style
|
|
84
|
+
"#{color}#{style}#{text}#{RESET}"
|
|
85
|
+
else
|
|
86
|
+
"#{color}#{text}#{RESET}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def colorize_kind(kind)
|
|
91
|
+
case kind.to_s
|
|
92
|
+
when "internal"
|
|
93
|
+
colorize(kind, GREEN)
|
|
94
|
+
when "producer"
|
|
95
|
+
colorize(kind, BLUE)
|
|
96
|
+
when "consumer"
|
|
97
|
+
colorize(kind, MAGENTA)
|
|
98
|
+
else
|
|
99
|
+
colorize(kind, WHITE)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def colorize_value(value)
|
|
104
|
+
case value
|
|
105
|
+
when String
|
|
106
|
+
colorize("\"#{value}\"", GREEN)
|
|
107
|
+
when Numeric
|
|
108
|
+
colorize(value.to_s, BLUE)
|
|
109
|
+
when TrueClass, FalseClass
|
|
110
|
+
colorize(value.to_s, MAGENTA)
|
|
111
|
+
when NilClass
|
|
112
|
+
colorize("null", DIM)
|
|
113
|
+
else
|
|
114
|
+
colorize(value.inspect, WHITE)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require_relative "span"
|
|
2
|
+
|
|
3
|
+
module Observable
|
|
4
|
+
module Persistence
|
|
5
|
+
class SpanRepo
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(spans:)
|
|
9
|
+
@spans = spans.map { |span_or_spandata| Span.from_span_or_spandata(span_or_spandata) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each(&)
|
|
13
|
+
@spans.each(&)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def in_code_namespace(namespace)
|
|
17
|
+
select { |span| span.code_namespace == namespace }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def in_root_trace
|
|
21
|
+
result = group_by(&:trace_id).find { |_trace_id, spans| spans.count > 1 }
|
|
22
|
+
if result
|
|
23
|
+
self.class.new(spans: result.last)
|
|
24
|
+
else
|
|
25
|
+
self.class.new(spans: [])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def one_and_only!
|
|
30
|
+
if one?
|
|
31
|
+
first
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError, "Expected 1 span, but found #{count}: #{to_a}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def find_one!(attrs: {}, &block)
|
|
38
|
+
block = to_block(attrs) if attrs.any? && !block_given?
|
|
39
|
+
|
|
40
|
+
if one?(&block)
|
|
41
|
+
find(&block)
|
|
42
|
+
elsif empty?(&block)
|
|
43
|
+
raise_none_found
|
|
44
|
+
else
|
|
45
|
+
raise_too_many_found(&block)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def empty?(&)
|
|
50
|
+
count(&).zero?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def raise_none_found
|
|
54
|
+
raise ArgumentError, "No spans found"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def raise_too_many_found(&)
|
|
58
|
+
matched = select(&)
|
|
59
|
+
raise ArgumentError, "Too many spans found:\n#{matched.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def find_by!(name:)
|
|
63
|
+
span = find { |s| s.name == name }
|
|
64
|
+
span || raise(Observable::NotFound, "No spans found with name: #{name}\n\nSpans:\n#{ai}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def find_by_attrs!(attrs)
|
|
68
|
+
span = find { |s| matches_criteria?(s, attrs) }
|
|
69
|
+
span || raise(Observable::NotFound, "No spans found with attributes: #{attrs}\n\nSpans:\n#{ai}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def where(criteria)
|
|
73
|
+
matching_spans = select { |span| matches_criteria?(span, criteria) }
|
|
74
|
+
self.class.new(spans: matching_spans)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_block(query)
|
|
78
|
+
lambda do |object|
|
|
79
|
+
object.attrs.transform_keys(&:to_s).slice(*query.transform_keys(&:to_s).keys) == query.transform_keys(&:to_s)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inspect
|
|
84
|
+
ai
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ai
|
|
88
|
+
grouped_spans = group_by(&:trace_id)
|
|
89
|
+
output = []
|
|
90
|
+
|
|
91
|
+
grouped_spans.each do |trace_id, spans|
|
|
92
|
+
output << colorize_trace_header(trace_id)
|
|
93
|
+
spans.each do |span|
|
|
94
|
+
output << span.ai
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
output.join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def matches_criteria?(span, criteria)
|
|
104
|
+
criteria.all? do |key, expected_value|
|
|
105
|
+
if expected_value.is_a?(Hash)
|
|
106
|
+
# Handle nested hash syntax like {code: {return: "hello"}}
|
|
107
|
+
nested_hash = get_value(span.attrs, key)
|
|
108
|
+
return false unless nested_hash.is_a?(Hash)
|
|
109
|
+
expected_value.all? do |nested_key, nested_value|
|
|
110
|
+
get_value(nested_hash, nested_key) == nested_value
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
# Handle simple keys and dot notation
|
|
114
|
+
get_value(span.attrs, key) == expected_value
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get_value(hash, key)
|
|
120
|
+
if key.is_a?(String) && key.include?(".")
|
|
121
|
+
# Handle dot notation like "code.return"
|
|
122
|
+
key.split(".").reduce(hash) do |current_hash, nested_key|
|
|
123
|
+
return nil unless current_hash.is_a?(Hash)
|
|
124
|
+
current_hash[nested_key] || current_hash[nested_key.to_s]
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
# Handle simple keys (try both symbol and string)
|
|
128
|
+
hash[key] || hash[key.to_s]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def colorize_trace_header(trace_id)
|
|
133
|
+
# Use the same color constants from Span
|
|
134
|
+
cyan = "\e[36m"
|
|
135
|
+
bold = "\e[1m"
|
|
136
|
+
reset = "\e[0m"
|
|
137
|
+
|
|
138
|
+
"#{cyan}#{bold}#{trace_id}#{reset}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "dry-struct"
|
|
2
|
+
require_relative "span"
|
|
3
|
+
|
|
4
|
+
module Observable
|
|
5
|
+
module Persistence
|
|
6
|
+
class Trace < Dry::Struct
|
|
7
|
+
attribute :id, Dry.Types::String
|
|
8
|
+
attribute :spans, Dry.Types::Array.of(Span)
|
|
9
|
+
|
|
10
|
+
def self.from_id_and_spandatas(id:, spandatas:)
|
|
11
|
+
new(
|
|
12
|
+
id: id,
|
|
13
|
+
spans: spandatas.map { |spandata| Span.from_spandata(spandata) }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def root?
|
|
18
|
+
spans.count > 1
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require_relative "trace"
|
|
2
|
+
|
|
3
|
+
module Observable
|
|
4
|
+
module Persistence
|
|
5
|
+
class TraceRepo
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(spans:)
|
|
9
|
+
@traces = spans.group_by(&:hex_trace_id).map { |id, spandatas| Trace.from_id_and_spandatas(id:, spandatas:) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each(&block)
|
|
13
|
+
@traces.each(&block)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def root!
|
|
17
|
+
find_one!(&:root?)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def empty?(&block)
|
|
21
|
+
count(&block).zero?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_one!(&block)
|
|
25
|
+
if one?(&block)
|
|
26
|
+
find(&block)
|
|
27
|
+
elsif empty?(&block)
|
|
28
|
+
raise_none_found
|
|
29
|
+
else
|
|
30
|
+
raise_too_many_found
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def raise_none_found
|
|
35
|
+
raise ArgumentError, "No traces found"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def raise_too_many_found
|
|
39
|
+
raise ArgumentError, "Multiple traces found"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|