Scope check
Due diligence
What problem does this solve?
RubyLLM already exposes great instrumentation hooks (chat.ruby_llm, request.ruby_llm, embedding.ruby_llm, etc.) and includes the originating object in the payload (e.g. chat: self). That is enough to observe LLM calls, but not enough to attribute them in a consistent, library-native way.
In production apps, every LLM call needs caller context for observability: which feature triggered it, what tags to attach to metrics, which trace/span to correlate with, etc. Today there is no first-class place to attach that context on a Chat (or other entry points) and have it travel with the object through instrumentation.
We currently work around this by monkey-patching RubyLLM::Chat:
module RubyLLM::Telemetry
Context = Data.define(:namespace, :tags)
attr_reader :telemetry
def with_telemetry(namespace, tags: {})
@telemetry = Context.new(namespace:, tags:)
self
end
end
RubyLLM::Chat.include RubyLLM::Telemetry
Usage at call sites:
RubyLLM.chat
.with_telemetry(:chat_session)
.with_instructions(instructions)
.ask(message)
Ai::SummarizeAgent.chat
.with_telemetry(:summary, tags: { type: summarizable_type })
.ask(text)
And in an ActiveSupport::Subscriber:
def chat(event)
telemetry = event.payload[:chat].telemetry
return unless telemetry
Appsignal.increment_counter("#{telemetry.namespace}_requests", 1, telemetry.tags)
Appsignal.add_distribution_value("#{telemetry.namespace}_input_tokens", event.payload[:input_tokens], telemetry.tags)
end
This works, but it is fragile:
- It relies on reopening
RubyLLM::Chat, which can break across gem upgrades.
- It only covers
Chat; Embedding, Image, Transcription, Moderation, and Agent have no equivalent hook.
- It is easy to forget in tests/fakes (we also include the module in test doubles).
- The name collides conceptually with
RubyLLM::Context / Chat#with_context, which already mean configuration scoping — not caller metadata.
Related: #414 (observability) and #799 (extensibility) were closed with instrumentation improvements, but there is still no supported way to attach per-call metadata without patching core classes.
Proposed solution
Add a first-class, opt-in shared metadata API on LLM entry points, following the existing fluent with_* builder style.
Suggested API (names open to bikeshedding — metadata, tags, or telemetry all work):
chat
.with_metadata(namespace: :chat_session, tags: { academy_id: 42 })
.with_instructions(instructions)
.ask(message)
Implementation sketch:
- Introduce a small value object, e.g.
RubyLLM::Metadata = Data.define(:namespace, :tags) (or a plain Hash if you prefer maximum flexibility).
- Add
attr_reader :metadata and with_metadata(namespace: nil, tags: {}) to Chat (and parallel entry points where instrumentation already fires).
- Merge metadata into instrumentation payloads explicitly, e.g.
metadata: chat.metadata, so subscribers do not need to reach into arbitrary ivars on core objects.
- Optionally support
with_metadata(tags: { ... }) to merge/append tags when chaining multiple calls.
- Document the intended use: observability attribution, not provider request params (distinct from
with_params / with_safety_identifier).
Example subscriber after the change:
def chat(event)
metadata = event.payload[:metadata]
return unless metadata
Appsignal.increment_counter("#{metadata.namespace}_requests", 1, metadata.tags)
end
If you want a narrower first iteration, Chat alone would already remove the most common monkey-patch.
Why this belongs in RubyLLM
Per-call attribution metadata is a cross-cutting concern of LLM communication — the same category as instrumentation itself. RubyLLM already owns the instrumentation contract and includes the chat object in event payloads; extending that contract with an official metadata slot is a small, library-level addition that prevents every app from patching RubyLLM::Chat.
This is not application business logic. The values (which namespace, which tags) are app-specific, but the mechanism for carrying them through the library belongs in RubyLLM — just like RubyLLM::Context carries per-call configuration, and instrumentation carries per-call telemetry events.
Without first-class support, teams either monkey-patch core classes or build wrapper objects around RubyLLM.chat, both of which fight the library's fluent API and miss non-Chat entry points.
Scope check
Due diligence
What problem does this solve?
RubyLLM already exposes great instrumentation hooks (
chat.ruby_llm,request.ruby_llm,embedding.ruby_llm, etc.) and includes the originating object in the payload (e.g.chat: self). That is enough to observe LLM calls, but not enough to attribute them in a consistent, library-native way.In production apps, every LLM call needs caller context for observability: which feature triggered it, what tags to attach to metrics, which trace/span to correlate with, etc. Today there is no first-class place to attach that context on a
Chat(or other entry points) and have it travel with the object through instrumentation.We currently work around this by monkey-patching
RubyLLM::Chat:Usage at call sites:
And in an
ActiveSupport::Subscriber:This works, but it is fragile:
RubyLLM::Chat, which can break across gem upgrades.Chat;Embedding,Image,Transcription,Moderation, andAgenthave no equivalent hook.RubyLLM::Context/Chat#with_context, which already mean configuration scoping — not caller metadata.Related: #414 (observability) and #799 (extensibility) were closed with instrumentation improvements, but there is still no supported way to attach per-call metadata without patching core classes.
Proposed solution
Add a first-class, opt-in shared metadata API on LLM entry points, following the existing fluent
with_*builder style.Suggested API (names open to bikeshedding —
metadata,tags, ortelemetryall work):Implementation sketch:
RubyLLM::Metadata = Data.define(:namespace, :tags)(or a plain Hash if you prefer maximum flexibility).attr_reader :metadataandwith_metadata(namespace: nil, tags: {})toChat(and parallel entry points where instrumentation already fires).metadata: chat.metadata, so subscribers do not need to reach into arbitrary ivars on core objects.with_metadata(tags: { ... })to merge/append tags when chaining multiple calls.with_params/with_safety_identifier).Example subscriber after the change:
If you want a narrower first iteration,
Chatalone would already remove the most common monkey-patch.Why this belongs in RubyLLM
Per-call attribution metadata is a cross-cutting concern of LLM communication — the same category as instrumentation itself. RubyLLM already owns the instrumentation contract and includes the chat object in event payloads; extending that contract with an official metadata slot is a small, library-level addition that prevents every app from patching
RubyLLM::Chat.This is not application business logic. The values (which namespace, which tags) are app-specific, but the mechanism for carrying them through the library belongs in RubyLLM — just like
RubyLLM::Contextcarries per-call configuration, and instrumentation carries per-call telemetry events.Without first-class support, teams either monkey-patch core classes or build wrapper objects around
RubyLLM.chat, both of which fight the library's fluent API and miss non-Chat entry points.