opentelemetry-instrumentation-ruby_llm 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9e85590961ea3d4ac30828dbc035d692040a2a66758ba6dab9decab2114af3f3
4
+ data.tar.gz: a8a701fa288ec93109bfb6a4d3fdcc5962c40abc98d9a9269d12a9e430848f78
5
+ SHA512:
6
+ metadata.gz: 60672653bad15a0e24a97a60ea94433f82df8300370987f656f135a5eb1339b731afbf3644dddbe4a867b3f9970d2b757e06ca7862a4b7d0d36798dff3f9aa58
7
+ data.tar.gz: d4536fbcba4b6439df8c8a4a30198072eea8cc61265ee1572a5c2b0dbd6f775260381d727d06ebc13604d755dc98f44f24f7ee552d218bb50b2695aec6ad3873
@@ -0,0 +1,19 @@
1
+ name: update-templates
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ paths:
8
+ - README.md
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ update-templates:
13
+ permissions:
14
+ contents: write
15
+ pull-requests: write
16
+ pages: write
17
+ uses: thoughtbot/templates/.github/workflows/dynamic-readme.yaml@main
18
+ secrets:
19
+ token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,19 @@
1
+ name: update-security
2
+
3
+ on:
4
+ push:
5
+ paths:
6
+ - SECURITY.md
7
+ branches:
8
+ - main
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ update-security:
13
+ permissions:
14
+ contents: write
15
+ pull-requests: write
16
+ pages: write
17
+ uses: thoughtbot/templates/.github/workflows/dynamic-security.yaml@main
18
+ secrets:
19
+ token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,30 @@
1
+ name: Ruby
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ name: Ruby ${{ matrix.ruby }}
13
+ strategy:
14
+ matrix:
15
+ ruby:
16
+ - '3.4'
17
+ - '3.3'
18
+ - '3.2'
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ with:
23
+ persist-credentials: false
24
+ - name: Set up Ruby
25
+ uses: ruby/setup-ruby@v1
26
+ with:
27
+ ruby-version: ${{ matrix.ruby }}
28
+ bundler-cache: true
29
+ - name: Run tests
30
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ *.gem
data/CODEOWNERS ADDED
@@ -0,0 +1,15 @@
1
+ # Lines starting with '#' are comments.
2
+ # Each line is a file pattern followed by one or more owners.
3
+
4
+ # More details are here: https://help.github.com/articles/about-codeowners/
5
+
6
+ # The '*' pattern is global owners.
7
+
8
+ # Order is important. The last matching pattern has the most precedence.
9
+ # The folders are ordered as follows:
10
+
11
+ # In each subsection folders are ordered first by depth, then alphabetically.
12
+ # This should make it easy to add new rules without breaking existing ones.
13
+
14
+ # Global rule:
15
+ * @clarissalimab
@@ -0,0 +1,6 @@
1
+ # Code of conduct
2
+
3
+ By participating in this project, you agree to abide by the
4
+ [thoughtbot code of conduct][1].
5
+
6
+ [1]: https://thoughtbot.com/open-source-code-of-conduct
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "ruby_llm"
8
+ gem "opentelemetry-sdk"
9
+ gem "opentelemetry-exporter-otlp"
10
+
11
+ group :test do
12
+ gem "minitest"
13
+ gem "webmock"
14
+ gem "rake"
15
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,105 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ opentelemetry-instrumentation-ruby_llm (0.1.0)
5
+ opentelemetry-api (~> 1.0)
6
+ opentelemetry-instrumentation-base (~> 0.23)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.8)
12
+ public_suffix (>= 2.0.2, < 8.0)
13
+ base64 (0.3.0)
14
+ bigdecimal (4.0.1)
15
+ crack (1.0.1)
16
+ bigdecimal
17
+ rexml
18
+ event_stream_parser (1.0.0)
19
+ faraday (2.14.0)
20
+ faraday-net_http (>= 2.0, < 3.5)
21
+ json
22
+ logger
23
+ faraday-multipart (1.2.0)
24
+ multipart-post (~> 2.0)
25
+ faraday-net_http (3.4.2)
26
+ net-http (~> 0.5)
27
+ faraday-retry (2.4.0)
28
+ faraday (~> 2.0)
29
+ google-protobuf (4.33.4)
30
+ bigdecimal
31
+ rake (>= 13)
32
+ google-protobuf (4.33.4-arm64-darwin)
33
+ bigdecimal
34
+ rake (>= 13)
35
+ googleapis-common-protos-types (1.22.0)
36
+ google-protobuf (~> 4.26)
37
+ hashdiff (1.2.1)
38
+ json (2.18.0)
39
+ logger (1.7.0)
40
+ marcel (1.1.0)
41
+ minitest (6.0.1)
42
+ prism (~> 1.5)
43
+ multipart-post (2.4.1)
44
+ net-http (0.9.1)
45
+ uri (>= 0.11.1)
46
+ opentelemetry-api (1.7.0)
47
+ opentelemetry-common (0.23.0)
48
+ opentelemetry-api (~> 1.0)
49
+ opentelemetry-exporter-otlp (0.31.1)
50
+ google-protobuf (>= 3.18)
51
+ googleapis-common-protos-types (~> 1.3)
52
+ opentelemetry-api (~> 1.1)
53
+ opentelemetry-common (~> 0.20)
54
+ opentelemetry-sdk (~> 1.10)
55
+ opentelemetry-semantic_conventions
56
+ opentelemetry-instrumentation-base (0.25.0)
57
+ opentelemetry-api (~> 1.7)
58
+ opentelemetry-common (~> 0.21)
59
+ opentelemetry-registry (~> 0.1)
60
+ opentelemetry-registry (0.4.0)
61
+ opentelemetry-api (~> 1.1)
62
+ opentelemetry-sdk (1.10.0)
63
+ opentelemetry-api (~> 1.1)
64
+ opentelemetry-common (~> 0.20)
65
+ opentelemetry-registry (~> 0.2)
66
+ opentelemetry-semantic_conventions
67
+ opentelemetry-semantic_conventions (1.36.0)
68
+ opentelemetry-api (~> 1.0)
69
+ prism (1.9.0)
70
+ public_suffix (7.0.2)
71
+ rake (13.3.1)
72
+ rexml (3.4.4)
73
+ ruby_llm (1.11.0)
74
+ base64
75
+ event_stream_parser (~> 1)
76
+ faraday (>= 1.10.0)
77
+ faraday-multipart (>= 1)
78
+ faraday-net_http (>= 1)
79
+ faraday-retry (>= 1)
80
+ marcel (~> 1.0)
81
+ ruby_llm-schema (~> 0.2.1)
82
+ zeitwerk (~> 2)
83
+ ruby_llm-schema (0.2.5)
84
+ uri (1.1.1)
85
+ webmock (3.26.1)
86
+ addressable (>= 2.8.0)
87
+ crack (>= 0.3.2)
88
+ hashdiff (>= 0.4.0, < 2.0.0)
89
+ zeitwerk (2.7.4)
90
+
91
+ PLATFORMS
92
+ arm64-darwin-24
93
+ ruby
94
+
95
+ DEPENDENCIES
96
+ minitest
97
+ opentelemetry-exporter-otlp
98
+ opentelemetry-instrumentation-ruby_llm!
99
+ opentelemetry-sdk
100
+ rake
101
+ ruby_llm
102
+ webmock
103
+
104
+ BUNDLED WITH
105
+ 2.7.1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Clarissa Borges and thoughtbot, inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # OpenTelemetry RubyLLM Instrumentation
2
+
3
+ OpenTelemetry instrumentation for [RubyLLM](https://rubyllm.com).
4
+
5
+ ## How do I get started?
6
+
7
+ Install the gem using:
8
+
9
+ ```sh
10
+ gem opentelemetry-instrumentation-ruby_llm
11
+ ```
12
+
13
+ Or, if you use [bundler](https://bundler.io/), include `opentelemetry-instrumentation-ruby_llm` in your `Gemfile`.
14
+
15
+ ## Usage
16
+
17
+ To use the instrumentation, call `use` with the name of the instrumentation:
18
+
19
+ ```ruby
20
+ OpenTelemetry::SDK.configure do |c|
21
+ c.use 'OpenTelemetry::Instrumentation::RubyLLM'
22
+ end
23
+ ```
24
+
25
+ Alternatively, you can also call `use_all` to install all the available instrumentation.
26
+
27
+ ```ruby
28
+ OpenTelemetry::SDK.configure do |c|
29
+ c.use_all
30
+ end
31
+ ```
32
+
33
+ ## What's traced?
34
+
35
+ | Feature | Status |
36
+ |---------|--------|
37
+ | Chat completions | Supported |
38
+ | Tool calls | Supported |
39
+ | Error handling | Supported |
40
+ | Conversation tracking (`gen_ai.conversation.id`) | Planned |
41
+ | Opt-in input/output content capture | Planned |
42
+ | System instructions capture | Planned |
43
+ | Embeddings | Planned |
44
+ | Streaming | Planned |
45
+
46
+ This gem follows the [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/).
47
+
48
+ ## License
49
+
50
+ Copyright (c) Clarissa Borges and thoughtbot, inc.
51
+
52
+ This gem is free software and may be redistributed under the terms specified in the [LICENSE](LICENSE) file.
53
+
54
+ <!-- START /templates/footer.md -->
55
+ ## About thoughtbot
56
+
57
+ ![thoughtbot](https://thoughtbot.com/thoughtbot-logo-for-readmes.svg)
58
+
59
+ This repo is maintained and funded by thoughtbot, inc.
60
+ The names and logos for thoughtbot are trademarks of thoughtbot, inc.
61
+
62
+ We love open source software!
63
+ See [our other projects][community].
64
+ We are [available for hire][hire].
65
+
66
+ [community]: https://thoughtbot.com/community?utm_source=github
67
+ [hire]: https://thoughtbot.com/hire-us?utm_source=github
68
+
69
+ <!-- END /templates/footer.md -->
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "test"
5
+ t.pattern = "test/**/*_test.rb"
6
+ end
7
+
8
+ task default: :test
data/SECURITY.md ADDED
@@ -0,0 +1,20 @@
1
+ <!-- START /templates/security.md -->
2
+ # Security Policy
3
+
4
+ ## Supported Versions
5
+
6
+ Only the the latest version of this project is supported at a given time. If
7
+ you find a security issue with an older version, please try updating to the
8
+ latest version first.
9
+
10
+ If for some reason you can't update to the latest version, please let us know
11
+ your reasons so that we can have a better understanding of your situation.
12
+
13
+ ## Reporting a Vulnerability
14
+
15
+ For security inquiries or vulnerability reports, visit
16
+ <https://thoughtbot.com/security>.
17
+
18
+ If you have any suggestions to improve this policy, visit <https://thoughtbot.com/security>.
19
+
20
+ <!-- END /templates/security.md -->
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ gemfile(true) do
6
+ source "https://rubygems.org"
7
+ gem "ruby_llm"
8
+ gem "opentelemetry-api"
9
+ gem "opentelemetry-sdk"
10
+ gem "opentelemetry-instrumentation-ruby_llm", path: "../"
11
+ end
12
+
13
+ ENV["OTEL_TRACES_EXPORTER"] ||= "console"
14
+
15
+ OpenTelemetry::SDK.configure do |c|
16
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
17
+ end
18
+
19
+ RubyLLM.configure do |c|
20
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
21
+ c.default_model = "gpt-5-nano"
22
+ end
23
+
24
+ chat = RubyLLM.chat
25
+ response = chat.ask("What is the meaning of life?")
26
+ puts "\nResponse: #{response.content}"
27
+
28
+ # This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
29
+ OpenTelemetry.tracer_provider.force_flush
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ gemfile(true) do
6
+ source "https://rubygems.org"
7
+ gem "ruby_llm"
8
+ gem "opentelemetry-api"
9
+ gem "opentelemetry-sdk"
10
+ gem "opentelemetry-exporter-otlp"
11
+ gem "opentelemetry-instrumentation-ruby_llm", path: "../"
12
+ gem "base64"
13
+ end
14
+
15
+ require "base64"
16
+
17
+ credentials = Base64.strict_encode64("#{ENV['LANGFUSE_PUBLIC_KEY']}:#{ENV['LANGFUSE_SECRET_KEY']}")
18
+
19
+ OpenTelemetry::SDK.configure do |c|
20
+ c.service_name = "ruby_llm-demo"
21
+ c.add_span_processor(
22
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
23
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
24
+ endpoint: "https://us.cloud.langfuse.com/api/public/otel/v1/traces",
25
+ headers: { "Authorization" => "Basic #{credentials}" }
26
+ )
27
+ )
28
+ )
29
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
30
+ end
31
+
32
+ RubyLLM.configure do |c|
33
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
34
+ c.default_model = "gpt-5-nano"
35
+ end
36
+
37
+ chat = RubyLLM.chat
38
+ response = chat.ask("What is the meaning of life?")
39
+ puts "\nResponse: #{response.content}"
40
+
41
+ # This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
42
+ OpenTelemetry.tracer_provider.force_flush
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ gemfile(true) do
6
+ source "https://rubygems.org"
7
+ gem "ruby_llm"
8
+ gem "opentelemetry-api"
9
+ gem "opentelemetry-sdk"
10
+ gem "opentelemetry-exporter-otlp"
11
+ gem "opentelemetry-instrumentation-ruby_llm", path: "../"
12
+ gem "base64"
13
+ end
14
+
15
+ require "base64"
16
+
17
+ credentials = Base64.strict_encode64("#{ENV['LANGFUSE_PUBLIC_KEY']}:#{ENV['LANGFUSE_SECRET_KEY']}")
18
+
19
+ OpenTelemetry::SDK.configure do |c|
20
+ c.service_name = "ruby_llm-demo"
21
+ c.add_span_processor(
22
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
23
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
24
+ endpoint: "https://us.cloud.langfuse.com/api/public/otel/v1/traces",
25
+ headers: { "Authorization" => "Basic #{credentials}" }
26
+ )
27
+ )
28
+ )
29
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
30
+ end
31
+
32
+ RubyLLM.configure do |c|
33
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
34
+ c.default_model = "gpt-5-nano"
35
+ end
36
+
37
+ class Calculator < RubyLLM::Tool
38
+ description "Performs basic math calculations"
39
+ param :expression, type: "string", desc: "Math expression to evaluate"
40
+
41
+ def execute(expression:)
42
+ eval(expression).to_s
43
+ end
44
+ end
45
+
46
+ chat = RubyLLM.chat
47
+ chat.with_tool(Calculator)
48
+ response = chat.ask("Use the calculator tool to compute 123 * 456")
49
+ puts "\nResponse: #{response.content}"
50
+
51
+ # This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
52
+ OpenTelemetry.tracer_provider.force_flush
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ gemfile(true) do
6
+ source "https://rubygems.org"
7
+ gem "ruby_llm"
8
+ gem "opentelemetry-api"
9
+ gem "opentelemetry-sdk"
10
+ gem "opentelemetry-instrumentation-ruby_llm", path: "../"
11
+ end
12
+
13
+ ENV["OTEL_TRACES_EXPORTER"] ||= "console"
14
+
15
+ OpenTelemetry::SDK.configure do |c|
16
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
17
+ end
18
+
19
+ RubyLLM.configure do |c|
20
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
21
+ c.default_model = "gpt-5-nano"
22
+ end
23
+
24
+ class Calculator < RubyLLM::Tool
25
+ description "Performs basic math calculations"
26
+ param :expression, type: "string", desc: "Math expression to evaluate"
27
+
28
+ def execute(expression:)
29
+ eval(expression).to_s
30
+ end
31
+ end
32
+
33
+ chat = RubyLLM.chat
34
+ chat.with_tool(Calculator)
35
+ response = chat.ask("What is 123 * 456?")
36
+ puts "\nResponse: #{response.content}"
37
+
38
+ # This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
39
+ OpenTelemetry.tracer_provider.force_flush
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTelemetry
4
+ module Instrumentation
5
+ module RubyLLM
6
+ class Instrumentation < OpenTelemetry::Instrumentation::Base
7
+ instrumentation_name "OpenTelemetry::Instrumentation::RubyLLM"
8
+ instrumentation_version VERSION
9
+
10
+ present do
11
+ defined?(::RubyLLM)
12
+ end
13
+
14
+ install do |_config|
15
+ require_relative "patches/chat"
16
+ ::RubyLLM::Chat.prepend(Patches::Chat)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTelemetry
4
+ module Instrumentation
5
+ module RubyLLM
6
+ module Patches
7
+ module Chat
8
+ def ask(message, &block)
9
+ provider = @model&.provider || "unknown"
10
+ model_id = @model&.id || "unknown"
11
+
12
+ attributes = {
13
+ "gen_ai.operation.name" => "chat",
14
+ "gen_ai.provider.name" => provider,
15
+ "gen_ai.request.model" => model_id,
16
+ }
17
+
18
+ tracer.in_span("chat #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
19
+ begin
20
+ result = super
21
+
22
+ if @messages.last
23
+ response = @messages.last
24
+ span.set_attribute("gen_ai.response.model", response.model_id) if response.model_id
25
+ span.set_attribute("gen_ai.usage.input_tokens", response.input_tokens) if response.input_tokens
26
+ span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
27
+ span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
28
+ end
29
+
30
+ result
31
+ rescue => e
32
+ span.record_exception(e)
33
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
34
+ span.set_attribute("error.type", e.class.name)
35
+ raise
36
+ end
37
+ end
38
+ rescue StandardError => e
39
+ OpenTelemetry.handle_error(exception: e)
40
+ super
41
+ end
42
+
43
+ def execute_tool(tool_call)
44
+ attributes = {
45
+ "gen_ai.tool.name" => tool_call.name,
46
+ "gen_ai.tool.call.id" => tool_call.id,
47
+ "gen_ai.tool.call.arguments" => tool_call.arguments.to_json,
48
+ "gen_ai.tool.type" => "function"
49
+ }
50
+
51
+ tracer.in_span("execute_tool #{tool_call.name}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::INTERNAL) do |span|
52
+ result = super
53
+ result_str = result.is_a?(::RubyLLM::Tool::Halt) ? result.content.to_s : result.to_s
54
+ span.set_attribute("gen_ai.tool.call.result", result_str[0..500])
55
+ result
56
+ end
57
+ rescue StandardError => e
58
+ OpenTelemetry.handle_error(exception: e)
59
+ super
60
+ end
61
+
62
+ private
63
+
64
+ def tracer
65
+ RubyLLM::Instrumentation.instance.tracer
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTelemetry
4
+ module Instrumentation
5
+ module RubyLLM
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ require "opentelemetry-api"
2
+ require "opentelemetry-instrumentation-base"
3
+
4
+ require_relative "opentelemetry/instrumentation/ruby_llm/version"
5
+ require_relative "opentelemetry/instrumentation/ruby_llm/instrumentation"
@@ -0,0 +1,23 @@
1
+ require_relative "lib/opentelemetry/instrumentation/ruby_llm/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "opentelemetry-instrumentation-ruby_llm"
5
+ spec.version = OpenTelemetry::Instrumentation::RubyLLM::VERSION
6
+ spec.authors = ["Clarissa Borges"]
7
+ spec.email = ["cborges@thoughtbot.com"]
8
+ spec.license = "MIT"
9
+
10
+ spec.summary = "OpenTelemetry instrumentation for RubyLLM"
11
+ spec.description = "Adds OpenTelemetry tracing to RubyLLM chat operations"
12
+ spec.homepage = "https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm"
13
+
14
+ spec.required_ruby_version = ">= 3.2.0"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+
18
+ spec.files = `git ls-files`.split("\n")
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "opentelemetry-api", "~> 1.0"
22
+ spec.add_dependency "opentelemetry-instrumentation-base", "~> 0.23"
23
+ end
@@ -0,0 +1,231 @@
1
+ require "test_helper"
2
+
3
+ class InstrumentationTest < Minitest::Test
4
+ def setup
5
+ EXPORTER.reset
6
+
7
+ RubyLLM.configure do |c|
8
+ c.openai_api_key = "fake-key-for-testing"
9
+ end
10
+ end
11
+
12
+ def test_creates_span_with_attributes
13
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
14
+ .to_return(
15
+ status: 200,
16
+ headers: { "Content-Type" => "application/json" },
17
+ body: {
18
+ id: "chatcmpl-123",
19
+ object: "chat.completion",
20
+ model: "gpt-4o-mini",
21
+ choices: [
22
+ {
23
+ index: 0,
24
+ message: { role: "assistant", content: "Hello, world!" },
25
+ finish_reason: "stop"
26
+ }
27
+ ],
28
+ usage: {
29
+ prompt_tokens: 10,
30
+ completion_tokens: 5,
31
+ total_tokens: 15
32
+ }
33
+ }.to_json
34
+ )
35
+
36
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
37
+ chat.ask("Hi")
38
+
39
+ spans = EXPORTER.finished_spans
40
+ assert_equal 1, spans.length
41
+
42
+ span = spans.first
43
+ assert_equal OpenTelemetry::Trace::SpanKind::CLIENT, span.kind
44
+ assert_equal "chat gpt-4o-mini", span.name
45
+ assert_equal "openai", span.attributes["gen_ai.provider.name"]
46
+ assert_equal "gpt-4o-mini", span.attributes["gen_ai.request.model"]
47
+ assert_equal "chat", span.attributes["gen_ai.operation.name"]
48
+ assert_equal 10, span.attributes["gen_ai.usage.input_tokens"]
49
+ assert_equal 5, span.attributes["gen_ai.usage.output_tokens"]
50
+ end
51
+
52
+ def test_records_error_on_api_failure
53
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
54
+ .to_return(status: 500, body: "Internal Server Error")
55
+
56
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
57
+
58
+ assert_raises do
59
+ chat.ask("Hi")
60
+ end
61
+
62
+ spans = EXPORTER.finished_spans
63
+ span = spans.last
64
+
65
+ assert_equal "chat gpt-4o-mini", span.name
66
+ assert span.attributes["error.type"]
67
+ assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
68
+ end
69
+
70
+ def test_ask_still_works_when_instrumentation_fails
71
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
72
+ .to_return(
73
+ status: 200,
74
+ headers: { "Content-Type" => "application/json" },
75
+ body: {
76
+ id: "chatcmpl-123",
77
+ object: "chat.completion",
78
+ model: "gpt-4o-mini",
79
+ choices: [{
80
+ index: 0,
81
+ message: { role: "assistant", content: "Hello!" },
82
+ finish_reason: "stop"
83
+ }],
84
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
85
+ }.to_json
86
+ )
87
+
88
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
89
+ chat.define_singleton_method(:tracer) { raise StandardError, "instrumentation bug" }
90
+
91
+ response = chat.ask("Hi")
92
+ assert_equal "Hello!", response.content
93
+ end
94
+
95
+ def test_creates_span_for_tool_call
96
+ calculator = Class.new(RubyLLM::Tool) do
97
+ def self.name = "calculator"
98
+ description "Performs math"
99
+ param :expression, type: "string", desc: "Math expression"
100
+
101
+ def execute(expression:)
102
+ eval(expression).to_s
103
+ end
104
+ end
105
+
106
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
107
+ .to_return(
108
+ {
109
+ status: 200,
110
+ headers: { "Content-Type" => "application/json" },
111
+ body: {
112
+ id: "chatcmpl-123",
113
+ object: "chat.completion",
114
+ model: "gpt-4o-mini",
115
+ choices: [{
116
+ index: 0,
117
+ message: {
118
+ role: "assistant",
119
+ content: nil,
120
+ tool_calls: [{
121
+ id: "call_abc123",
122
+ type: "function",
123
+ function: { name: "calculator", arguments: '{"expression":"2+2"}' }
124
+ }]
125
+ },
126
+ finish_reason: "tool_calls"
127
+ }],
128
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
129
+ }.to_json
130
+ },
131
+ {
132
+ status: 200,
133
+ headers: { "Content-Type" => "application/json" },
134
+ body: {
135
+ id: "chatcmpl-456",
136
+ object: "chat.completion",
137
+ model: "gpt-4o-mini",
138
+ choices: [{
139
+ index: 0,
140
+ message: { role: "assistant", content: "The answer is 4" },
141
+ finish_reason: "stop"
142
+ }],
143
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }
144
+ }.to_json
145
+ }
146
+ )
147
+
148
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
149
+ chat.with_tool(calculator)
150
+ chat.ask("What is 2+2?")
151
+
152
+ spans = EXPORTER.finished_spans
153
+
154
+ tool_spans = spans.select { |s| s.name.start_with?("execute_tool ") }
155
+ chat_spans = spans.select { |s| s.name.include?("chat ") }
156
+
157
+ assert_equal 1, tool_spans.length
158
+ assert_equal 1, chat_spans.length
159
+
160
+ tool_span = tool_spans.first
161
+ assert_equal OpenTelemetry::Trace::SpanKind::INTERNAL, tool_span.kind
162
+ assert_equal "execute_tool calculator", tool_span.name
163
+ assert_equal "calculator", tool_span.attributes["gen_ai.tool.name"]
164
+ assert_equal '{"expression":"2+2"}', tool_span.attributes["gen_ai.tool.call.arguments"]
165
+ assert_equal "4", tool_span.attributes["gen_ai.tool.call.result"]
166
+ assert_equal "call_abc123", tool_span.attributes["gen_ai.tool.call.id"]
167
+ assert_equal "function", tool_span.attributes["gen_ai.tool.type"]
168
+ end
169
+
170
+ def test_execute_tool_still_works_when_instrumentation_fails
171
+ calculator = Class.new(RubyLLM::Tool) do
172
+ def self.name = "calculator"
173
+ description "Performs math"
174
+ param :expression, type: "string", desc: "Math expression"
175
+
176
+ def execute(expression:)
177
+ eval(expression).to_s
178
+ end
179
+ end
180
+
181
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
182
+ .to_return(
183
+ {
184
+ status: 200,
185
+ headers: { "Content-Type" => "application/json" },
186
+ body: {
187
+ id: "chatcmpl-123",
188
+ object: "chat.completion",
189
+ model: "gpt-4o-mini",
190
+ choices: [{
191
+ index: 0,
192
+ message: {
193
+ role: "assistant",
194
+ content: nil,
195
+ tool_calls: [{
196
+ id: "call_abc123",
197
+ type: "function",
198
+ function: { name: "calculator", arguments: '{"expression":"2+2"}' }
199
+ }]
200
+ },
201
+ finish_reason: "tool_calls"
202
+ }],
203
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
204
+ }.to_json
205
+ },
206
+ {
207
+ status: 200,
208
+ headers: { "Content-Type" => "application/json" },
209
+ body: {
210
+ id: "chatcmpl-456",
211
+ object: "chat.completion",
212
+ model: "gpt-4o-mini",
213
+ choices: [{
214
+ index: 0,
215
+ message: { role: "assistant", content: "The answer is 4" },
216
+ finish_reason: "stop"
217
+ }],
218
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }
219
+ }.to_json
220
+ }
221
+ )
222
+
223
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
224
+ chat.with_tool(calculator)
225
+
226
+ chat.define_singleton_method(:tracer) { raise StandardError, "instrumentation bug" }
227
+
228
+ response = chat.ask("What is 2+2?")
229
+ assert_equal "The answer is 4", response.content
230
+ end
231
+ end
@@ -0,0 +1,15 @@
1
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
2
+
3
+ require "minitest/autorun"
4
+ require "webmock/minitest"
5
+ require "ruby_llm"
6
+ require "opentelemetry/sdk"
7
+ require "opentelemetry-instrumentation-ruby_llm"
8
+
9
+ EXPORTER = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
10
+ span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(EXPORTER)
11
+
12
+ OpenTelemetry::SDK.configure do |c|
13
+ c.add_span_processor(span_processor)
14
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
15
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: opentelemetry-instrumentation-ruby_llm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Clarissa Borges
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: opentelemetry-api
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: opentelemetry-instrumentation-base
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.23'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.23'
40
+ description: Adds OpenTelemetry tracing to RubyLLM chat operations
41
+ email:
42
+ - cborges@thoughtbot.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - ".github/workflows/dynamic-readme.yml"
48
+ - ".github/workflows/dynamic-security.yml"
49
+ - ".github/workflows/main.yml"
50
+ - ".gitignore"
51
+ - CODEOWNERS
52
+ - CODE_OF_CONDUCT.md
53
+ - Gemfile
54
+ - Gemfile.lock
55
+ - LICENSE
56
+ - README.md
57
+ - Rakefile
58
+ - SECURITY.md
59
+ - example/trace_demonstration.rb
60
+ - example/trace_demonstration_with_langfuse.rb
61
+ - example/trace_demonstration_with_langfuse_and_tools.rb
62
+ - example/trace_demonstration_with_tools.rb
63
+ - lib/opentelemetry-instrumentation-ruby_llm.rb
64
+ - lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb
65
+ - lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb
66
+ - lib/opentelemetry/instrumentation/ruby_llm/version.rb
67
+ - opentelemetry-instrumentation-ruby_llm.gemspec
68
+ - test/instrumentation_test.rb
69
+ - test/test_helper.rb
70
+ homepage: https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.2.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.7.1
90
+ specification_version: 4
91
+ summary: OpenTelemetry instrumentation for RubyLLM
92
+ test_files: []