Module: Sashiko::Adapters::Anthropic

Defined in:
lib/sashiko/adapters/anthropic.rb

Overview

Auto-instrumentation for Anthropic Ruby SDK (or any duck-typed client exposing #create on a Messages-like resource).

Attribute names follow OpenTelemetry GenAI semantic conventions:

https://opentelemetry.io/docs/specs/semconv/gen-ai/

Anthropic-specific prompt-cache metrics are namespaced under gen_ai.anthropic.* since the spec does not yet cover them.

Defined Under Namespace

Modules: Wrapper Classes: Price

Constant Summary collapse

DEFAULT_PRICING =

Snapshot as of 2026-04. Anthropic adds and retires models on its own schedule and this table will go stale; override at runtime via ‘Sashiko::Adapters::Anthropic.pricing = { … }` when that happens. The cost attribute is silently skipped for models not in this Hash.

::Ractor.make_shareable({
  "claude-opus-4-7"   => Price.new(input: 15.00, output: 75.00, cache_write: 18.75, cache_read: 1.50),
  "claude-sonnet-4-6" => Price.new(input:  3.00, output: 15.00, cache_write:  3.75, cache_read: 0.30),
  "claude-haiku-4-5"  => Price.new(input:  1.00, output:  5.00, cache_write:  1.25, cache_read: 0.10),
})
RESPONSE_ATTRS =

Map of response key to span attribute name. Iterating this once is clearer than four near-identical case/in stanzas.

{
  id:    "gen_ai.response.id",
  model: "gen_ai.response.model",
}.freeze
USAGE_ATTRS =
{
  input_tokens:                "gen_ai.usage.input_tokens",
  output_tokens:               "gen_ai.usage.output_tokens",
  cache_creation_input_tokens: "gen_ai.anthropic.cache_creation_input_tokens",
  cache_read_input_tokens:     "gen_ai.anthropic.cache_read_input_tokens",
}.freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.pricingObject



31
# File 'lib/sashiko/adapters/anthropic.rb', line 31

def pricing = @pricing ||= DEFAULT_PRICING

Class Method Details

.instrument!(messages_class, tracer: nil) ⇒ Object

Idempotent: prepends Wrapper once per class, then returns the class on every call so callers can chain and so a re-invocation is a safe no-op rather than a silent nil.

Pass tracer: to bind this instrumentation to a specific tracer (e.g. a Ruby::Box-local tracer). Subsequent re-invocations with a different tracer overwrite the previous binding so callers can rebind without re-prepending.



41
42
43
44
45
46
47
48
# File 'lib/sashiko/adapters/anthropic.rb', line 41

def instrument!(messages_class, tracer: nil)
  unless messages_class.instance_variable_get(:@__sashiko_instrumented)
    messages_class.prepend(Wrapper)
    messages_class.instance_variable_set(:@__sashiko_instrumented, true)
  end
  messages_class.instance_variable_set(:@__sashiko_tracer, tracer)
  messages_class
end

.instrument_in_box!(box, messages_class_name) ⇒ Object

Ruby 4.0 Ruby::Box variant: apply the prepend only inside the given Box, so the monkey-patch does NOT leak into the main Ruby process. Useful when multiple services in the same process want to instrument Anthropic calls independently, or when you want to A/B different adapter versions side-by-side.

Requires the process to be started with RUBY_BOX=1.

box = Ruby::Box.new
box.require "anthropic"
Sashiko::Adapters::Anthropic.instrument_in_box!(box, "Anthropic::Messages")

# Main process's Anthropic::Messages remains untouched.


63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/sashiko/adapters/anthropic.rb', line 63

def instrument_in_box!(box, messages_class_name)
  raise Sashiko::Box::NotEnabledError unless Sashiko::Box.enabled?
  # When OpenTelemetry is loaded inside the box, bind to the
  # box-local tracer explicitly so Wrapper doesn't fall back to
  # main's Sashiko.tracer. If OTel isn't loaded yet, fall through
  # with tracer: nil — Wrapper will resolve at call time, by
  # which point the caller has typically configured OTel.
  box.eval(<<~RUBY)
    require "sashiko/adapters/anthropic"
    klass = Object.const_get(#{messages_class_name.inspect})
    local_tracer = defined?(::OpenTelemetry) ?
      ::OpenTelemetry.tracer_provider.tracer("sashiko/anthropic") : nil
    Sashiko::Adapters::Anthropic.instrument!(klass, tracer: local_tracer)
  RUBY
end

.record_response(span, response) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/sashiko/adapters/anthropic.rb', line 93

def record_response(span, response)
  if (usage = response[:usage]).is_a?(Hash)
    set_usage_attributes(span, usage)
    set_cost(span, response[:model], usage)
    set_cache_hit_ratio(span, usage)
  end

  RESPONSE_ATTRS.each do |key, attr|
    response[key]&.then { |v| span.set_attribute(attr, v) }
  end

  case response[:stop_reason]
  in String | Symbol => reason
    span.set_attribute("gen_ai.response.finish_reasons", [reason.to_s])
  else
  end
end