Scope check
Due diligence
What problem does this solve?
Three observability gems hook into RubyLLM today, and all three monkey-patch:
sinaptia/ruby_llm-instrumentation — includes modules into Chat, Embedding, Image, Transcription, Moderation and aliases the originals.
thoughtbot/opentelemetry-instrumentation-ruby_llm — prepends RubyLLM::Chat and RubyLLM::Embedding.
sergey-homenko/llm_cost_tracker (mine) — prepends RubyLLM::Provider#complete, #embed, #transcribe.
Three pinch points, three different strategies. Load order between them is unstable and aliases can collide. As a developer writing one of these gems, the experience is: pick a class, hope no other gem patches the same method, ship, and break on the next RubyLLM release that reshapes internals.
#414 has been open for a year asking for an observability seam. #525 (AS::Notifications) and #556 (OTel) both proposed shapes you closed in favor of "instrumentation lives outside RubyLLM" — which I agree with. The thing that's still missing is a way for those external gems to attach without monkey-patching.
Proposed solution
Expose the Faraday middleware stack that Connection#setup_middleware already builds. About nine lines of production code:
lib/ruby_llm/configuration.rb:
option :faraday_middleware, -> { [] }
lib/ruby_llm/connection.rb:
def setup_middleware(faraday)
faraday.request :multipart
faraday.request :json
faraday.response :json
faraday.adapter :net_http
faraday.use :llm_errors, provider: @provider
apply_user_middleware(faraday)
end
def apply_user_middleware(faraday)
Array(@config.faraday_middleware).each do |entry|
entry.respond_to?(:call) ? entry.call(faraday) : faraday.use(*entry)
end
end
Usage from a third-party gem:
RubyLLM.configure do |config|
config.faraday_middleware = [
[MyTracingMiddleware, tags: { app: "myapp" }],
->(faraday) { faraday.use MyOtherMiddleware }
]
end
User middleware runs last in the stack, so on the response path it sees raw bodies first and can observe non-2xx responses before :llm_errors raises. On the request path it runs after JSON encoding, just before the adapter.
Default is [], so existing users see no behavior change. Happy to send a PR with the diff above plus specs in spec/ruby_llm/connection_middleware_spec.rb (array-form, callable-form, empty-default) and a short README paragraph.
Why this belongs in RubyLLM
The seam has to live where the Faraday connection is built. Connection#setup_middleware is private, @connection is created inside Provider#initialize and not reassignable from outside, and there's no public method to register middleware against an existing connection. There's no way for application code or a third-party gem to inject middleware without reaching into RubyLLM's internals — which is what all three observability gems above are doing.
This isn't adding an instrumentation API to RubyLLM. It's exposing one line of Faraday's existing contract — the same Faraday whose request_timeout, max_retries, http_proxy you already surface as configuration options. Tracing-format opinions, payload shapes, and notification names stay entirely in the external gems.
Scope check
Due diligence
What problem does this solve?
Three observability gems hook into RubyLLM today, and all three monkey-patch:
sinaptia/ruby_llm-instrumentation — includes modules into Chat, Embedding, Image, Transcription, Moderation and aliases the originals.
thoughtbot/opentelemetry-instrumentation-ruby_llm — prepends
RubyLLM::ChatandRubyLLM::Embedding.sergey-homenko/llm_cost_tracker (mine) — prepends
RubyLLM::Provider#complete,#embed,#transcribe.Three pinch points, three different strategies. Load order between them is unstable and aliases can collide. As a developer writing one of these gems, the experience is: pick a class, hope no other gem patches the same method, ship, and break on the next RubyLLM release that reshapes internals.
#414 has been open for a year asking for an observability seam. #525 (AS::Notifications) and #556 (OTel) both proposed shapes you closed in favor of "instrumentation lives outside RubyLLM" — which I agree with. The thing that's still missing is a way for those external gems to attach without monkey-patching.
Proposed solution
Expose the Faraday middleware stack that
Connection#setup_middlewarealready builds. About nine lines of production code:lib/ruby_llm/configuration.rb:
lib/ruby_llm/connection.rb:
Usage from a third-party gem:
User middleware runs last in the stack, so on the response path it sees raw bodies first and can observe non-2xx responses before
:llm_errorsraises. On the request path it runs after JSON encoding, just before the adapter.Default is
[], so existing users see no behavior change. Happy to send a PR with the diff above plus specs inspec/ruby_llm/connection_middleware_spec.rb(array-form, callable-form, empty-default) and a short README paragraph.Why this belongs in RubyLLM
The seam has to live where the Faraday connection is built.
Connection#setup_middlewareis private,@connectionis created insideProvider#initializeand not reassignable from outside, and there's no public method to register middleware against an existing connection. There's no way for application code or a third-party gem to inject middleware without reaching into RubyLLM's internals — which is what all three observability gems above are doing.This isn't adding an instrumentation API to RubyLLM. It's exposing one line of Faraday's existing contract — the same Faraday whose
request_timeout,max_retries,http_proxyyou already surface as configuration options. Tracing-format opinions, payload shapes, and notification names stay entirely in the external gems.