observable 0.1.0 → 0.1.1

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: c9b28e1e941ebdedee663e71a947dd92ec6e07e849092e7789cac95aa523bd80
4
- data.tar.gz: 658d6e07ac41ce13f30c14f61e69d2c9d59f79cc1008fac8d2471e040e7f028e
3
+ metadata.gz: '08d7169aa0bffabba61651eb5fd9d5b798f0d17a3adbcc0406471d1138ca84c5'
4
+ data.tar.gz: e0ab04bad74667894f89368ca2f3bfedb3040a51010d6b62ee91beb63fcb84b4
5
5
  SHA512:
6
- metadata.gz: 971679e05648c3d2edf158604d1bfdff48afc68f814753b4bd452d38ed2b7fc6f623903c85dda63e65e592498fa4e541449b018b56ae7e42321329f62bd4ecb9
7
- data.tar.gz: cd8e357e76207915a044cbb38d541413d06da9548bc8f40ab0d39636f6ee49317f344f2aa95d2f0cfdcd0d37e54b2319e478b671b15767c5296e347a73119499
6
+ metadata.gz: 244a0c1bf4902d735d38a51c236f0bbde64d63d27b630eb13d8ffe07436b56f4dff651707cd7bd5850bcabc851b024082f6077ee801b714f03a12eea762669bd
7
+ data.tar.gz: ac2f872738807294c54aa5e36c7c91760939dcf749066406e13bafd5053cf2b8acb74dc5535976556de513da7b2ecfadf9866a56c5bd267d9dfd80ce3a53d157
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(ruby:*)"
5
+ ],
6
+ "deny": [],
7
+ "ask": []
8
+ }
9
+ }
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.1
data/.standard.yml CHANGED
@@ -1,3 +1,5 @@
1
1
  # For available configuration options, see:
2
2
  # https://github.com/testdouble/standard
3
- ruby_version: 2.6
3
+ ruby_version: 3.4
4
+ fix: true
5
+ parallel: true
data/CLAUDE.md ADDED
@@ -0,0 +1,113 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ This is the Observable gem - a Ruby library that provides OpenTelemetry instrumentation for method calls with configurable serialization, PII filtering, and argument tracking. It automatically captures method invocation details, arguments, return values, and exceptions as OpenTelemetry spans.
8
+
9
+ ## Core Architecture
10
+
11
+ ### Main Components
12
+
13
+ - **Observable::Instrumenter** (`lib/observable/instrumenter.rb`) - The core instrumentation engine that wraps method calls with OpenTelemetry spans
14
+ - **Observable::Configuration** (`lib/observable/instrumenter.rb`) - Uses Dry::Configurable for flexible configuration management
15
+ - **ArgumentExtractor** (`lib/observable/instrumenter.rb`) - Extracts method arguments from Ruby bindings using introspection
16
+ - **CallerInformation** (`lib/observable/instrumenter.rb`) - Value object containing method metadata (name, namespace, filepath, line number, arguments)
17
+
18
+ ### Key Features
19
+
20
+ - **Automatic method instrumentation** - Wrap any method call with `instrumenter.instrument(binding) { ... }`
21
+ - **Argument tracking** - Captures method parameters with PII filtering capabilities
22
+ - **Return value serialization** - Configurable tracking of method return values
23
+ - **Exception handling** - Automatically captures and records exceptions in spans
24
+ - **Class/instance method detection** - Distinguishes between static (`.`) and instance (`#`) methods
25
+ - **Configurable serialization** - Supports custom formatters and max depth limits
26
+
27
+ ## Development Commands
28
+
29
+ ### Running Tests
30
+ ```bash
31
+ bundle exec rake test
32
+ ```
33
+
34
+ ### Running Individual Tests
35
+ ```bash
36
+ ruby -Itest test/unit/instrumenter_test.rb
37
+ ```
38
+
39
+ ### Linting
40
+ ```bash
41
+ bundle exec standardrb
42
+ ```
43
+
44
+ ### Dependencies
45
+ ```bash
46
+ bundle install
47
+ ```
48
+
49
+ ### Interactive Console
50
+ ```bash
51
+ bin/console
52
+ ```
53
+
54
+ ### Build Gem
55
+ ```bash
56
+ bundle exec rake build
57
+ ```
58
+
59
+ ### Release Process
60
+ ```bash
61
+ # Update version in lib/observable/version.rb
62
+ bundle exec rake release
63
+ ```
64
+
65
+ ## Configuration System
66
+
67
+ The gem uses Dry::Configurable with these key settings:
68
+
69
+ - `transport` - Transport mechanism (default: `:otel`)
70
+ - `app_namespace` - Application namespace for spans
71
+ - `attribute_namespace` - Attribute namespace prefix
72
+ - `tracer_names` - OpenTelemetry tracer configuration
73
+ - `formatters` - Object serialization methods (default: `:to_h`)
74
+ - `pii_filters` - Regex patterns to filter sensitive data
75
+ - `track_return_values` - Enable/disable return value capture (default: true)
76
+
77
+ ## Usage Pattern
78
+
79
+ The primary usage pattern involves:
80
+
81
+ 1. Create an instrumenter instance: `Observable::Instrumenter.new`
82
+ 2. Wrap method calls: `instrumenter.instrument(binding) { method_logic }`
83
+ 3. The instrumenter automatically:
84
+ - Extracts method name, class, and arguments from `binding`
85
+ - Creates OpenTelemetry spans with standardized naming (`Class#method` or `Class.method`)
86
+ - Serializes arguments and return values with PII filtering
87
+ - Handles exceptions and sets appropriate span status
88
+
89
+ ## Testing Structure
90
+
91
+ - **Unit tests** in `test/unit/` - Test individual components
92
+ - **Support helpers** in `test/support/` - Test utilities and mocks
93
+ - Uses Minitest framework
94
+ - Custom tracing test helpers for OpenTelemetry span verification
95
+
96
+ ## Dependencies
97
+
98
+ **Runtime:**
99
+ - `opentelemetry-sdk` ~> 1.5 - OpenTelemetry instrumentation
100
+ - `dry-configurable` >= 0.13.0, < 2.0 - Configuration management
101
+ - `dry-struct` ~> 1.4 - Value objects
102
+
103
+ **Development:**
104
+ - `minitest` ~> 5.14 - Testing framework
105
+ - `standard` ~> 1.40 - Ruby linting and formatting
106
+ - `rake` ~> 13.0 - Build automation
107
+
108
+ ## Code Style
109
+
110
+ - Uses Standard Ruby for linting and formatting
111
+ - Ruby version: 3.4+ (configured in `.ruby-version` and `.standard.yml`)
112
+ - Frozen string literals enforced
113
+ - Follows Ruby community conventions for method naming and structure
data/Gemfile CHANGED
@@ -4,9 +4,3 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in observable.gemspec
6
6
  gemspec
7
-
8
- gem "rake", "~> 13.0"
9
-
10
- gem "minitest", "~> 5.0"
11
-
12
- gem "standard", "~> 1.3"
data/Gemfile.lock ADDED
@@ -0,0 +1,122 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ observable (0.1.1)
5
+ dry-configurable (>= 0.13.0, < 2.0)
6
+ dry-struct (~> 1.4)
7
+ opentelemetry-sdk (~> 1.5)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ ast (2.4.3)
13
+ bigdecimal (3.2.3)
14
+ concurrent-ruby (1.3.5)
15
+ docile (1.4.1)
16
+ dry-configurable (1.3.0)
17
+ dry-core (~> 1.1)
18
+ zeitwerk (~> 2.6)
19
+ dry-core (1.1.0)
20
+ concurrent-ruby (~> 1.0)
21
+ logger
22
+ zeitwerk (~> 2.6)
23
+ dry-inflector (1.2.0)
24
+ dry-logic (1.6.0)
25
+ bigdecimal
26
+ concurrent-ruby (~> 1.0)
27
+ dry-core (~> 1.1)
28
+ zeitwerk (~> 2.6)
29
+ dry-struct (1.8.0)
30
+ dry-core (~> 1.1)
31
+ dry-types (~> 1.8, >= 1.8.2)
32
+ ice_nine (~> 0.11)
33
+ zeitwerk (~> 2.6)
34
+ dry-types (1.8.3)
35
+ bigdecimal (~> 3.0)
36
+ concurrent-ruby (~> 1.0)
37
+ dry-core (~> 1.0)
38
+ dry-inflector (~> 1.0)
39
+ dry-logic (~> 1.4)
40
+ zeitwerk (~> 2.6)
41
+ ice_nine (0.11.2)
42
+ json (2.13.2)
43
+ language_server-protocol (3.17.0.5)
44
+ lint_roller (1.1.0)
45
+ logger (1.7.0)
46
+ minitest (5.25.5)
47
+ opentelemetry-api (1.6.0)
48
+ opentelemetry-common (0.22.0)
49
+ opentelemetry-api (~> 1.0)
50
+ opentelemetry-registry (0.4.0)
51
+ opentelemetry-api (~> 1.1)
52
+ opentelemetry-sdk (1.8.1)
53
+ opentelemetry-api (~> 1.1)
54
+ opentelemetry-common (~> 0.20)
55
+ opentelemetry-registry (~> 0.2)
56
+ opentelemetry-semantic_conventions
57
+ opentelemetry-semantic_conventions (1.11.0)
58
+ opentelemetry-api (~> 1.0)
59
+ parallel (1.27.0)
60
+ parser (3.3.9.0)
61
+ ast (~> 2.4.1)
62
+ racc
63
+ prism (1.4.0)
64
+ racc (1.8.1)
65
+ rainbow (3.1.1)
66
+ rake (13.3.0)
67
+ regexp_parser (2.11.2)
68
+ rubocop (1.75.8)
69
+ json (~> 2.3)
70
+ language_server-protocol (~> 3.17.0.2)
71
+ lint_roller (~> 1.1.0)
72
+ parallel (~> 1.10)
73
+ parser (>= 3.3.0.2)
74
+ rainbow (>= 2.2.2, < 4.0)
75
+ regexp_parser (>= 2.9.3, < 3.0)
76
+ rubocop-ast (>= 1.44.0, < 2.0)
77
+ ruby-progressbar (~> 1.7)
78
+ unicode-display_width (>= 2.4.0, < 4.0)
79
+ rubocop-ast (1.46.0)
80
+ parser (>= 3.3.7.2)
81
+ prism (~> 1.4)
82
+ rubocop-performance (1.25.0)
83
+ lint_roller (~> 1.1)
84
+ rubocop (>= 1.75.0, < 2.0)
85
+ rubocop-ast (>= 1.38.0, < 2.0)
86
+ ruby-progressbar (1.13.0)
87
+ simplecov (0.22.0)
88
+ docile (~> 1.1)
89
+ simplecov-html (~> 0.11)
90
+ simplecov_json_formatter (~> 0.1)
91
+ simplecov-html (0.13.2)
92
+ simplecov_json_formatter (0.1.4)
93
+ standard (1.50.0)
94
+ language_server-protocol (~> 3.17.0.2)
95
+ lint_roller (~> 1.0)
96
+ rubocop (~> 1.75.5)
97
+ standard-custom (~> 1.0.0)
98
+ standard-performance (~> 1.8)
99
+ standard-custom (1.0.2)
100
+ lint_roller (~> 1.0)
101
+ rubocop (~> 1.50)
102
+ standard-performance (1.8.0)
103
+ lint_roller (~> 1.1)
104
+ rubocop-performance (~> 1.25.0)
105
+ unicode-display_width (3.1.5)
106
+ unicode-emoji (~> 4.0, >= 4.0.4)
107
+ unicode-emoji (4.0.4)
108
+ zeitwerk (2.7.3)
109
+
110
+ PLATFORMS
111
+ arm64-darwin-23
112
+ ruby
113
+
114
+ DEPENDENCIES
115
+ minitest (~> 5.14)
116
+ observable!
117
+ rake (~> 13.0)
118
+ simplecov (~> 0.22)
119
+ standard (~> 1.40)
120
+
121
+ BUNDLED WITH
122
+ 2.6.3
data/README.md CHANGED
@@ -1,37 +1,168 @@
1
1
  # Observable
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/observable`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Automatic OpenTelemetry instrumentation for Ruby methods with configurable serialization, PII filtering, and argument tracking.
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ ## Getting Started
6
6
 
7
- ## Installation
7
+ ```bash
8
+ bundle add observable
9
+ ```
8
10
 
9
- Install the gem and add to the application's Gemfile by executing:
11
+ Or
10
12
 
11
- $ bundle add observable
13
+ ```ruby
14
+ # Gemfile
15
+ gem 'observable'
16
+ ```
12
17
 
13
- If bundler is not being used to manage dependencies, install the gem by executing:
18
+ Basic usage:
14
19
 
15
- $ gem install observable
20
+ ```ruby
21
+ require 'observable'
16
22
 
17
- ## Usage
18
-
19
- TODO: Write usage instructions here
20
-
21
- ## Development
22
-
23
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
24
-
25
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
26
-
27
- ## Contributing
28
-
29
- Bug reports and pull requests are welcome on GitHub at https://github.com/johngallagher/observable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/johngallagher/observable/blob/main/CODE_OF_CONDUCT.md).
23
+ class UserService
24
+ def initialize
25
+ @instrumenter = Observable::Instrumenter.new
26
+ end
27
+
28
+ def create_user(name, email)
29
+ @instrumenter.instrument(binding) do
30
+ User.create(name: name, email: email)
31
+ end
32
+ end
33
+ end
34
+ ```
35
+
36
+ OpenTelemetry spans are automatically created with method names, arguments, return values, and exceptions.
37
+
38
+ ## Configuration
39
+
40
+ Configure globally or per-instrumenter:
41
+
42
+ ```ruby
43
+ # Global configuration
44
+ Observable::Configuration.configure do |config|
45
+ config.tracer_name = "my_app"
46
+ config.transport = :otel
47
+ config.app_namespace = "my_app"
48
+ config.attribute_namespace = "my_app"
49
+ config.track_return_values = true
50
+ config.serialization_depth = {default: 2, "MyClass" => 3}
51
+ config.formatters = {default: :to_h, "MyClass" => :to_formatted_h}
52
+ config.pii_filters = [/password/i, /secret/i]
53
+ end
54
+
55
+ # Per-instrumenter configuration
56
+ config = Observable::Configuration.new
57
+ config.track_return_values = false
58
+ instrumenter = Observable::Instrumenter.new(config: config)
59
+ ```
60
+
61
+ ### Configuration Options
62
+
63
+ - `tracer_name`: `"observable"` - Name for the OpenTelemetry tracer
64
+ - `transport`: `:otel` - Uses OpenTelemetry SDK
65
+ - `app_namespace`: `"app"` - Namespace for application-specific attributes
66
+ - `attribute_namespace`: `"app"` - Namespace for span attributes
67
+ - `track_return_values`: `true` - Captures method return values in spans
68
+ - `serialization_depth`: `{default: 2}` - Per-class serialization depth limits (Hash or Integer for backward compatibility)
69
+ - `formatters`: `{default: :to_h}` - Object serialization methods by class name
70
+ - `pii_filters`: `[]` - Regex patterns to filter sensitive data from spans
71
+
72
+ ## OpenTelemetry Integration
73
+
74
+ This library seamlessly integrates with OpenTelemetry, the industry-standard observability framework. Spans are automatically created with standardized naming (`Class#method` or `Class.method`) and include rich metadata about method invocations, making your Ruby applications immediately observable without manual instrumentation.
75
+
76
+ ## Custom Formatters
77
+
78
+ Control how domain objects are serialized in spans by configuring custom formatters.
79
+
80
+ ```ruby
81
+ Observable::Configuration.configure do |config|
82
+ config.formatters = {
83
+ default: :to_h,
84
+ 'YourCustomClass' => :to_formatted_h
85
+ }
86
+ config.serialization_depth = {
87
+ default: 2,
88
+ 'YourCustomClass' => 3
89
+ }
90
+ end
91
+ ```
92
+
93
+ ## Example
94
+
95
+ A domain object `Customer` has an `Invoice`.
96
+
97
+ ### Objective
98
+
99
+ Only send the invoice ID to the trace to save data.
100
+
101
+ ### Background
102
+
103
+ Imagine domain objects are `Dry::Struct` value objects:
104
+
105
+ ```ruby
106
+ class Customer < Dry::Struct
107
+ attribute :id, Dry.Types::String
108
+ attribute :name, Dry.Types::String
109
+ attribute :Invoice, Invoice
110
+ end
111
+
112
+ class Invoice < Dry::Struct
113
+ attribute :id, Dry.Types::String
114
+ attribute :status, Dry.Types::String
115
+ attribute :line_items, Dry.Types::Array
116
+ end
117
+ ```
118
+
119
+ ### Solution
120
+
121
+ 1. Define custom formatting method - `#to_formatted_h`
122
+
123
+ ```diff
124
+ class Customer < Dry::Struct
125
+ attribute :id, Dry.Types::String
126
+ attribute :name, Dry.Types::String
127
+ attribute :Invoice, Invoice
128
+
129
+ + def to_formatted_h
130
+ + {
131
+ + id: id,
132
+ + name: name,
133
+ + invoice: {
134
+ + id: invoice.id
135
+ + }
136
+ + }
137
+ + end
138
+ end
139
+ ```
140
+
141
+ 2. Configure observable:
142
+
143
+ ```ruby
144
+ Observable::Configuration.configure do |config|
145
+ config.formatters = {
146
+ default: :to_h,
147
+ 'Customer' => :to_formatted_h
148
+ }
149
+ config.serialization_depth = {
150
+ default: 2,
151
+ 'Customer' => 3
152
+ }
153
+ end
154
+ ```
155
+
156
+ The instrumenter tries class-specific formatters first, then falls back to the default formatter, then `to_s`.
157
+
158
+ ## Benefits
159
+
160
+ Why use this library? Why not write Otel attributes manually?
161
+
162
+ * **Zero-touch instrumentation** - Wrap any method call without modifying existing code or manually creating spans
163
+ * **Production-ready safety** - Built-in PII filtering, serialization depth limits, and exception handling prevent common observability pitfalls
164
+ * **Standardized telemetry** - Consistent span naming, attribute structure, and OpenTelemetry compliance across your entire application
30
165
 
31
166
  ## License
32
167
 
33
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
34
-
35
- ## Code of Conduct
36
-
37
- Everyone interacting in the Observable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/johngallagher/observable/blob/main/CODE_OF_CONDUCT.md).
168
+ MIT License. See LICENSE file for details.
data/Rakefile CHANGED
@@ -6,9 +6,7 @@ require "rake/testtask"
6
6
  Rake::TestTask.new(:test) do |t|
7
7
  t.libs << "test"
8
8
  t.libs << "lib"
9
- t.test_files = FileList["test/**/test_*.rb"]
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
10
  end
11
11
 
12
- require "standard/rake"
13
-
14
- task default: %i[test standard]
12
+ task default: %i[test]
data/example.rb ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "lib/observable"
4
+
5
+ # Configure the instrumenter
6
+ Observable::Configuration.configure do |config|
7
+ config.app_namespace = "example_app"
8
+ config.track_return_values = true
9
+ config.pii_filters = [/password/, /token/, /secret/]
10
+ end
11
+
12
+ class ExampleService
13
+ def initialize
14
+ @instrumenter = Observable::Instrumenter.new
15
+ end
16
+
17
+ def process_order(order_id, customer_email, amount)
18
+ @instrumenter.instrument(binding) do
19
+ # Simulate some processing
20
+ puts "Processing order #{order_id} for #{customer_email}, amount: #{amount}"
21
+
22
+ # Return a result hash
23
+ {
24
+ order_id: order_id,
25
+ status: "processed",
26
+ processed_at: Time.now.to_i,
27
+ amount: amount
28
+ }
29
+ end
30
+ end
31
+
32
+ def self.static_method(data)
33
+ instrumenter = Observable::Instrumenter.new
34
+ instrumenter.instrument(binding) do
35
+ puts "Processing data: #{data.inspect}"
36
+ data.transform_values(&:upcase) if data.is_a?(Hash)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Example usage
42
+ puts "=== Observable Example ==="
43
+ puts
44
+
45
+ service = ExampleService.new
46
+ result = service.process_order("ORD-123", "customer@example.com", 99.99)
47
+ puts "Result: #{result}"
48
+
49
+ puts
50
+
51
+ static_result = ExampleService.static_method({name: "john", city: "nyc"})
52
+ puts "Static result: #{static_result}"
53
+
54
+ puts "\n=== Check OpenTelemetry spans were created ==="
55
+ # In a real application, these spans would be exported to your observability platform
@@ -0,0 +1,388 @@
1
+ require "opentelemetry/sdk"
2
+ require "dry/configurable"
3
+
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
+ class Instrumenter
19
+ attr_reader :last_captured_method_name, :last_captured_namespace, :config
20
+
21
+ def initialize(tracer: nil, config: nil)
22
+ @config = config || Configuration.config
23
+ @tracer = tracer || OpenTelemetry.tracer_provider.tracer(@config.tracer_name)
24
+ end
25
+
26
+ def instrument(caller_binding = nil, &block)
27
+ @caller_binding = caller_binding
28
+ caller_info = extract_caller_information
29
+ create_instrumented_span(caller_info, &block)
30
+ end
31
+
32
+ private
33
+
34
+ # Extracts all information about the calling method in one place
35
+ def extract_caller_information
36
+ caller_location = find_caller_location
37
+ namespace = extract_actual_class_name
38
+ is_class_method = determine_if_class_method
39
+
40
+ CallerInformation.new(
41
+ method_name: caller_location.label,
42
+ namespace: namespace,
43
+ filepath: caller_location.path,
44
+ line_number: caller_location.lineno,
45
+ arguments: extract_arguments_from_caller(caller_location),
46
+ is_class_method: is_class_method
47
+ )
48
+ end
49
+
50
+ def find_caller_location
51
+ locations = caller_locations(1, 10)
52
+ raise InstrumentationError, "Unable to determine caller location" if locations.nil? || locations.empty?
53
+
54
+ instrumenter_file = __FILE__
55
+ caller_location = locations.find { |loc| loc.path != instrumenter_file }
56
+
57
+ caller_location || locations.first
58
+ rescue => e
59
+ raise InstrumentationError, "Error finding caller location: #{e.message}"
60
+ end
61
+
62
+ def extract_namespace_from_path(file_path)
63
+ return "UnknownClass" if file_path.nil? || file_path.empty?
64
+
65
+ file_name = File.basename(file_path, ".*")
66
+ return "UnknownClass" if file_name.empty?
67
+
68
+ file_name.split("_").map(&:capitalize).join
69
+ rescue
70
+ "UnknownClass"
71
+ end
72
+
73
+ def extract_actual_class_name
74
+ if @caller_binding
75
+ begin
76
+ caller_self = @caller_binding.eval("self")
77
+ if caller_self.is_a?(Class)
78
+ caller_self.name
79
+ else
80
+ caller_self.class.name
81
+ end
82
+ rescue
83
+ extract_namespace_from_path(@caller_binding.eval("__FILE__"))
84
+ end
85
+ else
86
+ caller_location = find_caller_location
87
+ extract_namespace_from_path(caller_location.path)
88
+ end
89
+ rescue
90
+ "UnknownClass"
91
+ end
92
+
93
+ def determine_if_class_method
94
+ return false unless @caller_binding
95
+
96
+ begin
97
+ caller_self = @caller_binding.eval("self")
98
+ caller_self.is_a?(Class)
99
+ rescue
100
+ false
101
+ end
102
+ end
103
+
104
+ def create_instrumented_span(caller_info, &block)
105
+ separator = caller_info.is_class_method ? "." : "#"
106
+
107
+ if caller_info.method_name.include?("#") || caller_info.method_name.include?(".")
108
+ span_name = caller_info.method_name
109
+ method_name_only = caller_info.method_name.split(/[#.]/).last
110
+ else
111
+ span_name = "#{caller_info.namespace}#{separator}#{caller_info.method_name}"
112
+ method_name_only = caller_info.method_name
113
+ end
114
+
115
+ @last_captured_method_name = method_name_only
116
+ @last_captured_namespace = caller_info.namespace
117
+ @tracer.in_span(span_name) do |span|
118
+ set_span_attributes(span, caller_info)
119
+
120
+ begin
121
+ result = block.call
122
+ span.set_attribute("error", false)
123
+ serialize_return_value(span, result)
124
+ result
125
+ rescue => e
126
+ span.set_attribute("error", true)
127
+ span.set_attribute("error.type", e.class.name)
128
+ span.set_attribute("error.message", e.message)
129
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
130
+ raise
131
+ end
132
+ end
133
+ end
134
+
135
+ def set_span_attributes(span, caller_info)
136
+ span.set_attribute("code.function", caller_info.method_name)
137
+ span.set_attribute("code.namespace", caller_info.namespace)
138
+ span.set_attribute("code.filepath", caller_info.filepath)
139
+ span.set_attribute("code.lineno", caller_info.line_number)
140
+
141
+ if @config.app_namespace
142
+ span.set_attribute("app.namespace", @config.app_namespace)
143
+ end
144
+
145
+ caller_info.arguments.each do |param_index, param_data|
146
+ if param_data.is_a?(Hash) && param_data.key?(:value) && param_data.key?(:param_name)
147
+ serialize_argument(span, "code.arguments.#{param_index}", param_data[:value], param_data[:param_name])
148
+ else
149
+ serialize_argument(span, "code.arguments.#{param_index}", param_data, param_index)
150
+ end
151
+ end
152
+ end
153
+
154
+ def serialize_argument(span, attribute_prefix, value, param_name = nil, depth = 0)
155
+ if param_name && should_filter_pii?(param_name)
156
+ span.set_attribute(attribute_prefix, "[FILTERED]")
157
+ return
158
+ end
159
+
160
+ case value
161
+ when String, Numeric, TrueClass, FalseClass, NilClass
162
+ span.set_attribute(attribute_prefix, value)
163
+ when Hash
164
+ serialize_hash(span, attribute_prefix, value, depth)
165
+ when Array
166
+ serialize_array(span, attribute_prefix, value, depth)
167
+ else
168
+ serialize_object(span, attribute_prefix, value, depth)
169
+ end
170
+ end
171
+
172
+ def serialize_argument_with_custom_depth(span, attribute_prefix, value, custom_max_depth, param_name = nil, depth = 0)
173
+ if param_name && should_filter_pii?(param_name)
174
+ span.set_attribute(attribute_prefix, "[FILTERED]")
175
+ return
176
+ end
177
+
178
+ case value
179
+ when String, Numeric, TrueClass, FalseClass, NilClass
180
+ span.set_attribute(attribute_prefix, value)
181
+ when Hash
182
+ serialize_hash_with_custom_depth(span, attribute_prefix, value, custom_max_depth, depth)
183
+ when Array
184
+ serialize_array_with_custom_depth(span, attribute_prefix, value, custom_max_depth, depth)
185
+ else
186
+ serialize_object_with_custom_depth(span, attribute_prefix, value, custom_max_depth, depth)
187
+ end
188
+ end
189
+
190
+ def serialize_return_value(span, value)
191
+ return unless @config.track_return_values
192
+
193
+ case value
194
+ when NilClass
195
+ span.set_attribute("code.return", "nil")
196
+ when String, Numeric, TrueClass, FalseClass
197
+ span.set_attribute("code.return", value)
198
+ when Hash
199
+ serialize_hash(span, "code.return", value, 2)
200
+ when Array
201
+ serialize_array(span, "code.return", value, 2)
202
+ else
203
+ serialize_object(span, "code.return", value, 2)
204
+ end
205
+ end
206
+
207
+ def serialize_hash(span, prefix, hash, depth = 0)
208
+ max_depth = get_serialization_depth_for_class("Hash")
209
+ return if depth > max_depth
210
+
211
+ hash.each do |key, value|
212
+ key_str = key.to_s
213
+ next if should_filter_pii?(key_str)
214
+ serialize_argument(span, "#{prefix}.#{key_str}", value, nil, depth + 1)
215
+ end
216
+ end
217
+
218
+ def serialize_hash_with_custom_depth(span, prefix, hash, custom_max_depth, depth = 0)
219
+ return if depth > custom_max_depth
220
+
221
+ hash.each do |key, value|
222
+ key_str = key.to_s
223
+ next if should_filter_pii?(key_str)
224
+ serialize_argument_with_custom_depth(span, "#{prefix}.#{key_str}", value, custom_max_depth, nil, depth + 1)
225
+ end
226
+ end
227
+
228
+ def serialize_array_with_custom_depth(span, prefix, array, custom_max_depth, depth = 0)
229
+ return if depth > custom_max_depth
230
+
231
+ items_to_process = [array.length, 10].min
232
+ array.first(items_to_process).each_with_index do |item, index|
233
+ serialize_argument_with_custom_depth(span, "#{prefix}.#{index}", item, custom_max_depth, nil, depth + 1)
234
+ end
235
+ end
236
+
237
+ def serialize_object_with_custom_depth(span, prefix, obj, custom_max_depth, depth = 0)
238
+ if depth >= custom_max_depth
239
+ span.set_attribute("#{prefix}.class", obj.class.name)
240
+ span.set_attribute(prefix, obj.to_s)
241
+ return
242
+ end
243
+
244
+ span.set_attribute("#{prefix}.class", obj.class.name)
245
+
246
+ formatter = find_formatter_for_class(obj.class.name)
247
+
248
+ if formatter && obj.respond_to?(formatter)
249
+ result = obj.send(formatter)
250
+ if result.is_a?(Hash)
251
+ serialize_hash_with_custom_depth(span, prefix, result, custom_max_depth, depth)
252
+ return
253
+ end
254
+ end
255
+
256
+ default_formatter = @config.formatters[:default]
257
+ if default_formatter && obj.respond_to?(default_formatter)
258
+ result = obj.send(default_formatter)
259
+ if result.is_a?(Hash)
260
+ serialize_hash_with_custom_depth(span, prefix, result, custom_max_depth, depth)
261
+ return
262
+ end
263
+ end
264
+
265
+ span.set_attribute(prefix, obj.to_s)
266
+ end
267
+
268
+ def serialize_array(span, prefix, array, depth = 0)
269
+ max_depth = get_serialization_depth_for_class("Array")
270
+ return if depth > max_depth
271
+
272
+ items_to_process = [array.length, 10].min
273
+ array.first(items_to_process).each_with_index do |item, index|
274
+ serialize_argument(span, "#{prefix}.#{index}", item, nil, depth + 1)
275
+ end
276
+ end
277
+
278
+ def serialize_object(span, prefix, obj, depth = 0)
279
+ max_depth = get_serialization_depth_for_class(obj.class.name)
280
+
281
+ # If we've reached the depth limit for this class, just set the class name
282
+ if depth >= max_depth
283
+ span.set_attribute("#{prefix}.class", obj.class.name)
284
+ span.set_attribute(prefix, obj.to_s)
285
+ return
286
+ end
287
+
288
+ span.set_attribute("#{prefix}.class", obj.class.name)
289
+
290
+ formatter = find_formatter_for_class(obj.class.name)
291
+
292
+ if formatter && obj.respond_to?(formatter)
293
+ result = obj.send(formatter)
294
+ if result.is_a?(Hash)
295
+ # When object converts to hash, the hash starts fresh at depth 0
296
+ # but we need to enforce the object's depth limit
297
+ serialize_hash_with_custom_depth(span, prefix, result, max_depth, 0)
298
+ return
299
+ end
300
+ end
301
+
302
+ default_formatter = @config.formatters[:default]
303
+ if default_formatter && obj.respond_to?(default_formatter)
304
+ result = obj.send(default_formatter)
305
+ if result.is_a?(Hash)
306
+ # When object converts to hash, the hash starts fresh at depth 0
307
+ # but we need to enforce the object's depth limit
308
+ serialize_hash_with_custom_depth(span, prefix, result, max_depth, 0)
309
+ return
310
+ end
311
+ end
312
+
313
+ span.set_attribute(prefix, obj.to_s)
314
+ end
315
+
316
+ def find_formatter_for_class(class_name)
317
+ @config.formatters[class_name]
318
+ end
319
+
320
+ def get_serialization_depth_for_class(class_name)
321
+ if @config.serialization_depth.is_a?(Integer)
322
+ return @config.serialization_depth
323
+ end
324
+
325
+ @config.serialization_depth[class_name] || @config.serialization_depth[:default] || @config.serialization_depth["default"] || 2
326
+ end
327
+
328
+ def should_filter_pii?(param_name)
329
+ return false if @config.pii_filters.empty?
330
+
331
+ param_name_str = param_name.to_s.downcase
332
+ @config.pii_filters.any? { |pattern| pattern.match?(param_name_str) }
333
+ end
334
+
335
+ def extract_arguments_from_caller(caller_location)
336
+ ArgumentExtractor.new(caller_location, @caller_binding).extract
337
+ end
338
+
339
+ CallerInformation = Struct.new(:method_name, :namespace, :filepath, :line_number, :arguments, :is_class_method, keyword_init: true)
340
+
341
+ class ArgumentExtractor
342
+ def initialize(caller_location, caller_binding = nil)
343
+ @caller_location = caller_location
344
+ @caller_binding = caller_binding
345
+ @method_name = caller_location.label
346
+ end
347
+
348
+ def extract
349
+ return {} unless @caller_binding
350
+ extract_from_binding
351
+ end
352
+
353
+ private
354
+
355
+ def extract_from_binding
356
+ @caller_binding.local_variables
357
+ args = {}
358
+
359
+ extract_local_variables_with_numeric_indexing(args)
360
+
361
+ args
362
+ rescue
363
+ {}
364
+ end
365
+
366
+ def extract_local_variables_with_numeric_indexing(args)
367
+ local_vars = @caller_binding.local_variables
368
+ positional_index = 0
369
+
370
+ local_vars.each do |var_name|
371
+ var_name_str = var_name.to_s
372
+
373
+ next if var_name_str.start_with?("_") || var_name == :instrumenter
374
+
375
+ value = @caller_binding.local_variable_get(var_name)
376
+
377
+ args[positional_index.to_s] = {
378
+ value: value,
379
+ param_name: var_name_str
380
+ }
381
+ positional_index += 1
382
+ end
383
+ end
384
+ end
385
+
386
+ class InstrumentationError < StandardError; end
387
+ end
388
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Observable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/observable.rb CHANGED
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "observable/version"
4
+ require_relative "observable/instrumenter"
4
5
 
5
6
  module Observable
6
7
  class Error < StandardError; end
7
- # Your code goes here...
8
+
9
+ # Convenience method to create a new instrumenter instance
10
+ def self.instrumenter(config: nil)
11
+ Instrumenter.new(config: config)
12
+ end
8
13
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/observable/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "observable"
7
+ spec.version = Observable::VERSION
8
+ spec.authors = ["John Gallagher"]
9
+ spec.email = ["john@synapticmishap.co.uk"]
10
+
11
+ spec.summary = "OpenTelemetry instrumentation library for Ruby methods"
12
+ spec.description = "A Ruby gem that provides automated OpenTelemetry instrumentation for method calls with configurable serialization, PII filtering, and argument tracking"
13
+ spec.homepage = "https://github.com/JoyfulProgramming/observable"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/JoyfulProgramming/observable"
21
+ spec.metadata["changelog_uri"] = "https://github.com/JoyfulProgramming/observable/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ # Runtime dependencies
35
+ spec.add_dependency "opentelemetry-sdk", "~> 1.5"
36
+ spec.add_dependency "dry-configurable", ">= 0.13.0", "< 2.0"
37
+ spec.add_dependency "dry-struct", "~> 1.4"
38
+
39
+ # Development dependencies
40
+ spec.add_development_dependency "minitest", "~> 5.14"
41
+ spec.add_development_dependency "rake", "~> 13.0"
42
+ spec.add_development_dependency "simplecov", "~> 0.22"
43
+ 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
+ end
metadata CHANGED
@@ -1,31 +1,142 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: observable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Gallagher
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-04-22 00:00:00.000000000 Z
12
- dependencies: []
13
- description: Observe your app using Open Telemetry
10
+ date: 2025-09-07 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: opentelemetry-sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dry-configurable
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.13.0
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 0.13.0
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: dry-struct
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.4'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.4'
60
+ - !ruby/object:Gem::Dependency
61
+ name: minitest
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '5.14'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '5.14'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rake
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '13.0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '13.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: simplecov
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '0.22'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '0.22'
102
+ - !ruby/object:Gem::Dependency
103
+ name: standard
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '1.40'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '1.40'
116
+ description: A Ruby gem that provides automated OpenTelemetry instrumentation for
117
+ method calls with configurable serialization, PII filtering, and argument tracking
14
118
  email:
15
119
  - john@synapticmishap.co.uk
16
120
  executables: []
17
121
  extensions: []
18
122
  extra_rdoc_files: []
19
123
  files:
124
+ - ".claude/settings.local.json"
125
+ - ".ruby-version"
20
126
  - ".standard.yml"
21
127
  - CHANGELOG.md
128
+ - CLAUDE.md
22
129
  - CODE_OF_CONDUCT.md
23
130
  - Gemfile
131
+ - Gemfile.lock
24
132
  - LICENSE.txt
25
133
  - README.md
26
134
  - Rakefile
135
+ - example.rb
27
136
  - lib/observable.rb
137
+ - lib/observable/instrumenter.rb
28
138
  - lib/observable/version.rb
139
+ - observable.gemspec
29
140
  - sig/observable.rbs
30
141
  homepage: https://github.com/JoyfulProgramming/observable
31
142
  licenses:
@@ -35,7 +146,6 @@ metadata:
35
146
  homepage_uri: https://github.com/JoyfulProgramming/observable
36
147
  source_code_uri: https://github.com/JoyfulProgramming/observable
37
148
  changelog_uri: https://github.com/JoyfulProgramming/observable/CHANGELOG.md
38
- post_install_message:
39
149
  rdoc_options: []
40
150
  require_paths:
41
151
  - lib
@@ -43,15 +153,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
43
153
  requirements:
44
154
  - - ">="
45
155
  - !ruby/object:Gem::Version
46
- version: 2.6.0
156
+ version: 3.0.0
47
157
  required_rubygems_version: !ruby/object:Gem::Requirement
48
158
  requirements:
49
159
  - - ">="
50
160
  - !ruby/object:Gem::Version
51
161
  version: '0'
52
162
  requirements: []
53
- rubygems_version: 3.3.7
54
- signing_key:
163
+ rubygems_version: 3.6.2
55
164
  specification_version: 4
56
- summary: Observe your app using Open Telemetry
165
+ summary: OpenTelemetry instrumentation library for Ruby methods
57
166
  test_files: []