observable 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08d7169aa0bffabba61651eb5fd9d5b798f0d17a3adbcc0406471d1138ca84c5'
4
- data.tar.gz: e0ab04bad74667894f89368ca2f3bfedb3040a51010d6b62ee91beb63fcb84b4
3
+ metadata.gz: b257d27b8a651f747109ea43b983befa02c2e10d34d13a49cf025a5f46eb7592
4
+ data.tar.gz: '068c6c31c24e4473682c9d5072842d995cd43a0fae9dee2ca2749a7313bc69a5'
5
5
  SHA512:
6
- metadata.gz: 244a0c1bf4902d735d38a51c236f0bbde64d63d27b630eb13d8ffe07436b56f4dff651707cd7bd5850bcabc851b024082f6077ee801b714f03a12eea762669bd
7
- data.tar.gz: ac2f872738807294c54aa5e36c7c91760939dcf749066406e13bafd5053cf2b8acb74dc5535976556de513da7b2ecfadf9866a56c5bd267d9dfd80ce3a53d157
6
+ metadata.gz: 3d52b07648bd401f2494aa735e988a067c01bca1a443c1ee4ec609cebf582fc3eb3c59939c0b85a9880f0ad53a72b29495a249cea6bf92ee3dc8a114c674e340
7
+ data.tar.gz: eeb0349b60211293afe11f445789a3598e8ad2af6d577e53cf08958ec7237aa6c36799e047df11c0cb931ec6a960a085e14c2c96f79c5f504b6e0cf824ae03e2
data/.standard.yml CHANGED
@@ -3,3 +3,5 @@
3
3
  ruby_version: 3.4
4
4
  fix: true
5
5
  parallel: true
6
+ ignore:
7
+ - Gemfile.lock
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  ## [Unreleased]
2
2
 
3
+
4
+
5
+
6
+
7
+ ## [0.1.4] - 2025-09-08
8
+
9
+ ### Changed
10
+ - Update version bumping logic in bump script
11
+ - v0.1.3-beta
12
+ - Update gem build process and release script
13
+ - v0.1.2-beta
14
+
15
+
16
+ ## [0.1.2-beta] - 2025-09-08
17
+
18
+ ### Changed
19
+ - v0.1.2-beta
20
+
21
+
22
+ ## [0.1.2-alpha] - 2025-09-08
23
+
24
+ ### Added
25
+ - Add support for bumping alpha and beta pre-releases
26
+
27
+ ### Changed
28
+ - Update version bumping logic for bump gem
29
+ - Delegate all version bumping to bump gem
30
+ - Extract configuration into class
31
+ - Update gemspec and test setup for OTLP exporter
32
+
33
+
34
+ ## [0.1.2-alpha] - 2025-09-08
35
+
36
+ ### Added
37
+ - Add support for bumping alpha and beta pre-releases
38
+
39
+ ### Fixed
40
+ - Update version to 0.1.2
41
+
42
+ ### Changed
43
+ - Delegate all version bumping to bump gem
44
+ - v0.1.2-alpha
45
+ - Extract configuration into class
46
+ - Update gemspec and test setup for OTLP exporter
47
+ - Add 'ignore' section to configuration file
48
+
49
+
50
+ ## [0.1.2] - 2025-09-07
51
+
52
+ ### Changed
53
+ - Add Ruby 3.2.x versions to GitHub Actions matrix for better CI coverage
54
+
3
55
  ## [0.1.0] - 2024-04-22
4
56
 
5
57
  - Initial release
data/CLAUDE.md CHANGED
@@ -56,12 +56,34 @@ bin/console
56
56
  bundle exec rake build
57
57
  ```
58
58
 
59
- ### Release Process
59
+ ### Version Management
60
60
  ```bash
61
- # Update version in lib/observable/version.rb
62
- bundle exec rake release
61
+ # Bump patch version (0.1.1 -> 0.1.2)
62
+ bin/bump
63
+
64
+ # Bump minor version (0.1.1 -> 0.2.0)
65
+ bin/bump --minor
66
+
67
+ # Bump major version (0.1.1 -> 1.0.0)
68
+ bin/bump --major
63
69
  ```
64
70
 
71
+ ### Release Process
72
+
73
+ The project uses automated releases via GitHub Actions:
74
+
75
+ 1. **Manual Release**: Use `bin/bump` to increment version, update CHANGELOG.md, then commit and push to `main`
76
+ 2. **Automated Release**: When a version change is detected in a push to `main`, GitHub Actions will:
77
+ - Run tests across multiple Ruby versions
78
+ - Run linting checks
79
+ - Build the gem
80
+ - Create a GitHub release with changelog
81
+ - Publish to RubyGems (requires `RUBYGEMS_API_KEY` secret)
82
+
83
+ **Prerequisites for automated release:**
84
+ - Set `RUBYGEMS_API_KEY` secret in GitHub repository settings
85
+ - Ensure CHANGELOG.md is updated with meaningful changes
86
+
65
87
  ## Configuration System
66
88
 
67
89
  The gem uses Dry::Configurable with these key settings:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- observable (0.1.1)
4
+ observable (0.1.4)
5
5
  dry-configurable (>= 0.13.0, < 2.0)
6
6
  dry-struct (~> 1.4)
7
7
  opentelemetry-sdk (~> 1.5)
@@ -10,8 +10,15 @@ GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
12
  ast (2.4.3)
13
+ attr_extras (7.1.0)
13
14
  bigdecimal (3.2.3)
15
+ bump (0.10.0)
14
16
  concurrent-ruby (1.3.5)
17
+ date (3.4.1)
18
+ debug (1.11.0)
19
+ irb (~> 1.10)
20
+ reline (>= 0.3.8)
21
+ diff-lcs (1.6.2)
15
22
  docile (1.4.1)
16
23
  dry-configurable (1.3.0)
17
24
  dry-core (~> 1.1)
@@ -38,15 +45,40 @@ GEM
38
45
  dry-inflector (~> 1.0)
39
46
  dry-logic (~> 1.4)
40
47
  zeitwerk (~> 2.6)
48
+ erb (5.0.2)
49
+ google-protobuf (4.32.0)
50
+ bigdecimal
51
+ rake (>= 13)
52
+ google-protobuf (4.32.0-arm64-darwin)
53
+ bigdecimal
54
+ rake (>= 13)
55
+ googleapis-common-protos-types (1.21.0)
56
+ google-protobuf (~> 4.26)
41
57
  ice_nine (0.11.2)
58
+ io-console (0.8.1)
59
+ irb (1.15.2)
60
+ pp (>= 0.6.0)
61
+ rdoc (>= 4.0.0)
62
+ reline (>= 0.4.2)
42
63
  json (2.13.2)
43
64
  language_server-protocol (3.17.0.5)
44
65
  lint_roller (1.1.0)
45
66
  logger (1.7.0)
67
+ m (1.6.2)
68
+ method_source (>= 0.6.7)
69
+ rake (>= 0.9.2.2)
70
+ method_source (1.1.0)
46
71
  minitest (5.25.5)
47
72
  opentelemetry-api (1.6.0)
48
73
  opentelemetry-common (0.22.0)
49
74
  opentelemetry-api (~> 1.0)
75
+ opentelemetry-exporter-otlp (0.30.0)
76
+ google-protobuf (>= 3.18)
77
+ googleapis-common-protos-types (~> 1.3)
78
+ opentelemetry-api (~> 1.1)
79
+ opentelemetry-common (~> 0.20)
80
+ opentelemetry-sdk (~> 1.2)
81
+ opentelemetry-semantic_conventions
50
82
  opentelemetry-registry (0.4.0)
51
83
  opentelemetry-api (~> 1.1)
52
84
  opentelemetry-sdk (1.8.1)
@@ -56,15 +88,29 @@ GEM
56
88
  opentelemetry-semantic_conventions
57
89
  opentelemetry-semantic_conventions (1.11.0)
58
90
  opentelemetry-api (~> 1.0)
91
+ optimist (3.2.1)
59
92
  parallel (1.27.0)
60
93
  parser (3.3.9.0)
61
94
  ast (~> 2.4.1)
62
95
  racc
96
+ patience_diff (1.2.0)
97
+ optimist (~> 3.0)
98
+ pp (0.6.2)
99
+ prettyprint
100
+ prettyprint (0.2.0)
63
101
  prism (1.4.0)
102
+ psych (5.2.6)
103
+ date
104
+ stringio
64
105
  racc (1.8.1)
65
106
  rainbow (3.1.1)
66
107
  rake (13.3.0)
108
+ rdoc (6.14.2)
109
+ erb
110
+ psych (>= 4.0.0)
67
111
  regexp_parser (2.11.2)
112
+ reline (0.6.2)
113
+ io-console (~> 0.5)
68
114
  rubocop (1.75.8)
69
115
  json (~> 2.3)
70
116
  language_server-protocol (~> 3.17.0.2)
@@ -102,6 +148,11 @@ GEM
102
148
  standard-performance (1.8.0)
103
149
  lint_roller (~> 1.1)
104
150
  rubocop-performance (~> 1.25.0)
151
+ stringio (3.1.7)
152
+ super_diff (0.16.0)
153
+ attr_extras (>= 6.2.4)
154
+ diff-lcs
155
+ patience_diff
105
156
  unicode-display_width (3.1.5)
106
157
  unicode-emoji (~> 4.0, >= 4.0.4)
107
158
  unicode-emoji (4.0.4)
@@ -112,11 +163,16 @@ PLATFORMS
112
163
  ruby
113
164
 
114
165
  DEPENDENCIES
166
+ bump (~> 0.10)
167
+ debug (~> 1.8)
168
+ m (~> 1.6)
115
169
  minitest (~> 5.14)
116
170
  observable!
171
+ opentelemetry-exporter-otlp (~> 0.30)
117
172
  rake (~> 13.0)
118
173
  simplecov (~> 0.22)
119
174
  standard (~> 1.40)
175
+ super_diff (~> 0.9)
120
176
 
121
177
  BUNDLED WITH
122
178
  2.6.3
@@ -0,0 +1,17 @@
1
+ require "dry/configurable"
2
+
3
+ module Observable
4
+ class Configuration
5
+ extend Dry::Configurable
6
+
7
+ setting :tracer_name, default: "observable"
8
+ setting :transport, default: :otel
9
+ setting :app_namespace, default: "app"
10
+ setting :attribute_namespace, default: "app"
11
+ setting :formatters, default: {default: :to_h}
12
+ setting :pii_filters, default: []
13
+ setting :serialization_depth, default: {default: 2}
14
+ setting :track_return_values, default: true
15
+ setting :custom_error_converters, default: {}
16
+ end
17
+ end
@@ -1,22 +1,9 @@
1
1
  require "opentelemetry/sdk"
2
- require "dry/configurable"
2
+ require_relative "configuration"
3
3
 
4
4
  module Observable
5
- class Configuration
6
- extend Dry::Configurable
7
-
8
- setting :tracer_name, default: "observable"
9
- setting :transport, default: :otel
10
- setting :app_namespace, default: "app"
11
- setting :attribute_namespace, default: "app"
12
- setting :formatters, default: {default: :to_h}
13
- setting :pii_filters, default: []
14
- setting :serialization_depth, default: {default: 2}
15
- setting :track_return_values, default: true
16
- end
17
-
18
5
  class Instrumenter
19
- attr_reader :last_captured_method_name, :last_captured_namespace, :config
6
+ attr_reader :last_captured_namespace, :config
20
7
 
21
8
  def initialize(tracer: nil, config: nil)
22
9
  @config = config || Configuration.config
@@ -31,7 +18,6 @@ module Observable
31
18
 
32
19
  private
33
20
 
34
- # Extracts all information about the calling method in one place
35
21
  def extract_caller_information
36
22
  caller_location = find_caller_location
37
23
  namespace = extract_actual_class_name
@@ -105,17 +91,19 @@ module Observable
105
91
  separator = caller_info.is_class_method ? "." : "#"
106
92
 
107
93
  if caller_info.method_name.include?("#") || caller_info.method_name.include?(".")
108
- span_name = caller_info.method_name
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}"
109
96
  method_name_only = caller_info.method_name.split(/[#.]/).last
110
97
  else
111
98
  span_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name}"
99
+ function_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name}"
112
100
  method_name_only = caller_info.method_name
113
101
  end
114
102
 
115
103
  @last_captured_method_name = method_name_only
116
104
  @last_captured_namespace = caller_info.namespace
117
105
  @tracer.in_span(span_name) do |span|
118
- set_span_attributes(span, caller_info)
106
+ set_span_attributes(span, caller_info, function_name)
119
107
 
120
108
  begin
121
109
  result = block.call
@@ -124,16 +112,20 @@ module Observable
124
112
  result
125
113
  rescue => e
126
114
  span.set_attribute("error", true)
127
- span.set_attribute("error.type", e.class.name)
128
- span.set_attribute("error.message", e.message)
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)
129
121
  span.status = OpenTelemetry::Trace::Status.error(e.message)
130
122
  raise
131
123
  end
132
124
  end
133
125
  end
134
126
 
135
- def set_span_attributes(span, caller_info)
136
- span.set_attribute("code.function", caller_info.method_name)
127
+ def set_span_attributes(span, caller_info, function_name)
128
+ span.set_attribute("code.function", function_name)
137
129
  span.set_attribute("code.namespace", caller_info.namespace)
138
130
  span.set_attribute("code.filepath", caller_info.filepath)
139
131
  span.set_attribute("code.lineno", caller_info.line_number)
@@ -158,8 +150,10 @@ module Observable
158
150
  end
159
151
 
160
152
  case value
161
- when String, Numeric, TrueClass, FalseClass, NilClass
153
+ when String, Numeric, TrueClass, FalseClass
162
154
  span.set_attribute(attribute_prefix, value)
155
+ when NilClass
156
+ span.set_attribute(attribute_prefix, "nil")
163
157
  when Hash
164
158
  serialize_hash(span, attribute_prefix, value, depth)
165
159
  when Array
@@ -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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observable
4
+ class StructuredError < StandardError
5
+ attr_reader :context, :type
6
+
7
+ def initialize(message, type: self.class.name, context: {})
8
+ @context = context.to_h
9
+ @type = (type || self.class.name).to_s
10
+ super(message)
11
+ end
12
+
13
+ def to_h
14
+ {message: message}.merge(@context)
15
+ end
16
+
17
+ def inspect
18
+ "#<#{self.class.name}: #{message}, type=#{type}, context=#{@context}>"
19
+ end
20
+
21
+ def pretty_print
22
+ "#<#{self.class.name}: message: #{message}, type: #{type}, context: #{@context}>"
23
+ end
24
+
25
+ def self.from_error(error)
26
+ custom_converter_result = try_custom_converter(error)
27
+ if custom_converter_result
28
+ new(
29
+ custom_converter_result[:message],
30
+ type: custom_converter_result[:type],
31
+ context: custom_converter_result[:context]
32
+ )
33
+ else
34
+ new(
35
+ safe_message(error),
36
+ type: safe_type(error),
37
+ context: safe_context(error)
38
+ )
39
+ end
40
+ end
41
+
42
+ def self.safe_message(error)
43
+ if error.respond_to?(:message)
44
+ error.message.to_s
45
+ else
46
+ ""
47
+ end
48
+ rescue
49
+ ""
50
+ end
51
+
52
+ def self.safe_type(error)
53
+ return "NilClass" if error.nil?
54
+
55
+ if error.respond_to?(:type)
56
+ type_value = error.type
57
+ return type_value.to_s unless type_value.nil? || type_value.to_s.empty?
58
+ end
59
+
60
+ if error.respond_to?(:name)
61
+ name_value = error.name
62
+ return name_value.to_s unless name_value.nil? || name_value.to_s.empty?
63
+ end
64
+
65
+ error.class.name
66
+ rescue
67
+ error.class.name
68
+ end
69
+
70
+ def self.safe_context(error)
71
+ if error.nil?
72
+ {}
73
+ elsif error.respond_to?(:to_h)
74
+ error.to_h
75
+ elsif error.respond_to?(:context)
76
+ error.context
77
+ else
78
+ {}
79
+ end
80
+ rescue
81
+ {}
82
+ end
83
+
84
+ def self.try_custom_converter(error)
85
+ return nil if error.nil?
86
+
87
+ converters = Configuration.config.custom_error_converters
88
+ return nil if converters.empty?
89
+
90
+ converter = converters[error.class.name]
91
+ return nil unless converter
92
+
93
+ result = begin
94
+ converter.call(error)
95
+ rescue
96
+ nil
97
+ end
98
+
99
+ if result&.is_a?(Hash) &&
100
+ result.key?(:message) &&
101
+ result.key?(:type) &&
102
+ result.key?(:context)
103
+ result
104
+ end
105
+ end
106
+
107
+ private_class_method :safe_message, :safe_type, :safe_context, :try_custom_converter
108
+ end
109
+ end
@@ -0,0 +1,51 @@
1
+ require_relative "persistence/trace_repo"
2
+ require_relative "persistence/span_repo"
3
+
4
+ module Observable
5
+ module TracingTestHelper
6
+ def traces
7
+ Observable::Persistence::TraceRepo.new(spans: finished_spans)
8
+ end
9
+
10
+ def spans
11
+ Observable::Persistence::SpanRepo.new(spans: finished_spans)
12
+ end
13
+
14
+ def setup_observable_data!
15
+ @open_telemetry_exporter = setup_opentelemetry_for_tests
16
+ end
17
+
18
+ def teardown_observable_data!
19
+ reset_observable_data!
20
+ @open_telemetry_exporter = nil
21
+ end
22
+
23
+ def reset_observable_data!
24
+ raise NoOpenTelemetryExporter if @open_telemetry_exporter.nil?
25
+
26
+ @open_telemetry_exporter.reset
27
+ end
28
+
29
+ private
30
+
31
+ def finished_spans
32
+ raise NoOpenTelemetryExporter if @open_telemetry_exporter.nil?
33
+
34
+ @open_telemetry_exporter.finished_spans
35
+ end
36
+
37
+ def setup_opentelemetry_for_tests
38
+ require "opentelemetry/sdk"
39
+
40
+ exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
41
+ span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
42
+
43
+ # Configure the tracer provider with our span processor
44
+ tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
45
+ tracer_provider.add_span_processor(span_processor)
46
+ OpenTelemetry.tracer_provider = tracer_provider
47
+
48
+ exporter
49
+ end
50
+ end
51
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Observable
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.4"
5
5
  end
data/lib/observable.rb CHANGED
@@ -1,13 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "observable/version"
4
+ require_relative "observable/configuration"
4
5
  require_relative "observable/instrumenter"
6
+ require_relative "observable/structured_error"
7
+ require_relative "observable/persistence/span"
8
+ require_relative "observable/persistence/span_repo"
9
+ require_relative "observable/persistence/trace"
10
+ require_relative "observable/persistence/trace_repo"
5
11
 
6
12
  module Observable
7
13
  class Error < StandardError; end
8
14
 
9
- # Convenience method to create a new instrumenter instance
15
+ class NoOpenTelemetryExporter < Error
16
+ def message
17
+ "No OpenTelemetry exporter set up. Call `Observable::TracingTestHelper#use_in_memory_otel_exporter!` to set up the exporter."
18
+ end
19
+ end
20
+
21
+ class NotFound < Error; end
22
+
10
23
  def self.instrumenter(config: nil)
11
24
  Instrumenter.new(config: config)
12
25
  end
26
+
27
+ def self.configure
28
+ yield Configuration.config
29
+ end
13
30
  end
data/observable.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "A Ruby gem that provides automated OpenTelemetry instrumentation for method calls with configurable serialization, PII filtering, and argument tracking"
13
13
  spec.homepage = "https://github.com/JoyfulProgramming/observable"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.0.0"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
16
 
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
18
 
@@ -37,11 +37,13 @@ Gem::Specification.new do |spec|
37
37
  spec.add_dependency "dry-struct", "~> 1.4"
38
38
 
39
39
  # Development dependencies
40
+ spec.add_development_dependency "m", "~> 1.6"
41
+ spec.add_development_dependency "bump", "~> 0.10"
40
42
  spec.add_development_dependency "minitest", "~> 5.14"
43
+ spec.add_development_dependency "opentelemetry-exporter-otlp", "~> 0.30"
41
44
  spec.add_development_dependency "rake", "~> 13.0"
42
45
  spec.add_development_dependency "simplecov", "~> 0.22"
43
46
  spec.add_development_dependency "standard", "~> 1.40"
44
-
45
- # For more information and examples about making a new gem, check out our
46
- # guide at: https://bundler.io/guides/creating_gem.html
47
+ spec.add_development_dependency "super_diff", "~> 0.9"
48
+ spec.add_development_dependency "debug", "~> 1.8"
47
49
  end
data/pretty.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "pp"
2
+
3
+ data = {
4
+ users: [
5
+ {name: "Alice", email: "alice@example.com", roles: ["admin", "user"], active: true, created_at: Time.now},
6
+ {name: "Bob", email: "bob@example.com", roles: ["user"], active: false, created_at: Time.now},
7
+ {name: "Charlie", email: "charlie@example.com", roles: ["moderator", "user"], active: true, created_at: Time.now}
8
+ ],
9
+ settings: {theme: "dark", notifications: true, language: "en"}
10
+ }
11
+
12
+ puts data
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: observable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Gallagher
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-09-07 00:00:00.000000000 Z
10
+ date: 2025-10-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: opentelemetry-sdk
@@ -57,6 +57,34 @@ dependencies:
57
57
  - - "~>"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '1.4'
60
+ - !ruby/object:Gem::Dependency
61
+ name: m
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '1.6'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '1.6'
74
+ - !ruby/object:Gem::Dependency
75
+ name: bump
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.10'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '0.10'
60
88
  - !ruby/object:Gem::Dependency
61
89
  name: minitest
62
90
  requirement: !ruby/object:Gem::Requirement
@@ -71,6 +99,20 @@ dependencies:
71
99
  - - "~>"
72
100
  - !ruby/object:Gem::Version
73
101
  version: '5.14'
102
+ - !ruby/object:Gem::Dependency
103
+ name: opentelemetry-exporter-otlp
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '0.30'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '0.30'
74
116
  - !ruby/object:Gem::Dependency
75
117
  name: rake
76
118
  requirement: !ruby/object:Gem::Requirement
@@ -113,6 +155,34 @@ dependencies:
113
155
  - - "~>"
114
156
  - !ruby/object:Gem::Version
115
157
  version: '1.40'
158
+ - !ruby/object:Gem::Dependency
159
+ name: super_diff
160
+ requirement: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: '0.9'
165
+ type: :development
166
+ prerelease: false
167
+ version_requirements: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - "~>"
170
+ - !ruby/object:Gem::Version
171
+ version: '0.9'
172
+ - !ruby/object:Gem::Dependency
173
+ name: debug
174
+ requirement: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - "~>"
177
+ - !ruby/object:Gem::Version
178
+ version: '1.8'
179
+ type: :development
180
+ prerelease: false
181
+ version_requirements: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - "~>"
184
+ - !ruby/object:Gem::Version
185
+ version: '1.8'
116
186
  description: A Ruby gem that provides automated OpenTelemetry instrumentation for
117
187
  method calls with configurable serialization, PII filtering, and argument tracking
118
188
  email:
@@ -134,9 +204,17 @@ files:
134
204
  - Rakefile
135
205
  - example.rb
136
206
  - lib/observable.rb
207
+ - lib/observable/configuration.rb
137
208
  - lib/observable/instrumenter.rb
209
+ - lib/observable/persistence/span.rb
210
+ - lib/observable/persistence/span_repo.rb
211
+ - lib/observable/persistence/trace.rb
212
+ - lib/observable/persistence/trace_repo.rb
213
+ - lib/observable/structured_error.rb
214
+ - lib/observable/tracing_test_helper.rb
138
215
  - lib/observable/version.rb
139
216
  - observable.gemspec
217
+ - pretty.rb
140
218
  - sig/observable.rbs
141
219
  homepage: https://github.com/JoyfulProgramming/observable
142
220
  licenses:
@@ -153,7 +231,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
153
231
  requirements:
154
232
  - - ">="
155
233
  - !ruby/object:Gem::Version
156
- version: 3.0.0
234
+ version: 3.2.0
157
235
  required_rubygems_version: !ruby/object:Gem::Requirement
158
236
  requirements:
159
237
  - - ">="