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 +4 -4
- data/.standard.yml +2 -0
- data/CHANGELOG.md +52 -0
- data/CLAUDE.md +25 -3
- data/Gemfile.lock +57 -1
- data/lib/observable/configuration.rb +17 -0
- data/lib/observable/instrumenter.rb +17 -23
- 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 +18 -1
- data/observable.gemspec +6 -4
- data/pretty.rb +12 -0
- metadata +81 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b257d27b8a651f747109ea43b983befa02c2e10d34d13a49cf025a5f46eb7592
|
4
|
+
data.tar.gz: '068c6c31c24e4473682c9d5072842d995cd43a0fae9dee2ca2749a7313bc69a5'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3d52b07648bd401f2494aa735e988a067c01bca1a443c1ee4ec609cebf582fc3eb3c59939c0b85a9880f0ad53a72b29495a249cea6bf92ee3dc8a114c674e340
|
7
|
+
data.tar.gz: eeb0349b60211293afe11f445789a3598e8ad2af6d577e53cf08958ec7237aa6c36799e047df11c0cb931ec6a960a085e14c2c96f79c5f504b6e0cf824ae03e2
|
data/.standard.yml
CHANGED
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
|
-
###
|
59
|
+
### Version Management
|
60
60
|
```bash
|
61
|
-
#
|
62
|
-
|
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.
|
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
|
-
|
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 :
|
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("
|
128
|
-
span.set_attribute("
|
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",
|
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
|
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
|
data/lib/observable/version.rb
CHANGED
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
|
-
|
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.
|
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
|
-
|
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.
|
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-
|
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.
|
234
|
+
version: 3.2.0
|
157
235
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
158
236
|
requirements:
|
159
237
|
- - ">="
|