Skip to content

[FEATURE] First-class per-call metadata context for observability attribution #807

Description

@rainerborene

Scope check

  • This is core LLM communication (not application logic)
  • This benefits most users (not just my use case)
  • This can't be solved in application code with current RubyLLM
  • I read the Contributing Guide

Due diligence

  • I searched existing issues
  • I checked the documentation

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:

  1. Introduce a small value object, e.g. RubyLLM::Metadata = Data.define(:namespace, :tags) (or a plain Hash if you prefer maximum flexibility).
  2. Add attr_reader :metadata and with_metadata(namespace: nil, tags: {}) to Chat (and parallel entry points where instrumentation already fires).
  3. Merge metadata into instrumentation payloads explicitly, e.g. metadata: chat.metadata, so subscribers do not need to reach into arbitrary ivars on core objects.
  4. Optionally support with_metadata(tags: { ... }) to merge/append tags when chaining multiple calls.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions