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.
@@ -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