little_ghost 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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "opentelemetry-api"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
# Optional adapters for sending LittleGhost instrumentation to tracing tools.
|
|
8
|
+
module Tracing
|
|
9
|
+
# OpenTelemetry turns LittleGhost lifecycle notifications into GenAI spans and
|
|
10
|
+
# events. It brings agents, model calls, tools, workflows, and token usage into
|
|
11
|
+
# the same traces as the rest of an application.
|
|
12
|
+
#
|
|
13
|
+
# LittleGhost.configure do |config|
|
|
14
|
+
# config.instrument LittleGhost::Tracing::OpenTelemetry.new
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# LittleGhost depends only on +opentelemetry-api+. Install and configure the
|
|
18
|
+
# desired SDK, processors, and exporters before registering this subscriber.
|
|
19
|
+
#
|
|
20
|
+
# === Content and trust
|
|
21
|
+
#
|
|
22
|
+
# Prompts, responses, messages, tool arguments, and exception content are
|
|
23
|
+
# omitted by default. They appear only when Instrumentation has an explicit,
|
|
24
|
+
# scrubbed Support::ContentCapture policy.
|
|
25
|
+
class OpenTelemetry < Instrumentation::Subscriber
|
|
26
|
+
ATTRIBUTE_NAMES = {
|
|
27
|
+
agent_id: "gen_ai.agent.id",
|
|
28
|
+
agent_name: "gen_ai.agent.name",
|
|
29
|
+
model_id: "gen_ai.request.model",
|
|
30
|
+
model_provider: "gen_ai.provider.name",
|
|
31
|
+
response_id: "gen_ai.response.id",
|
|
32
|
+
response_model: "gen_ai.response.model",
|
|
33
|
+
finish_reasons: "gen_ai.response.finish_reasons",
|
|
34
|
+
input_tokens: "gen_ai.usage.input_tokens",
|
|
35
|
+
output_tokens: "gen_ai.usage.output_tokens",
|
|
36
|
+
cache_read_tokens: "gen_ai.usage.cache_read.input_tokens",
|
|
37
|
+
cache_write_tokens: "gen_ai.usage.cache_creation.input_tokens",
|
|
38
|
+
reasoning_tokens: "gen_ai.usage.reasoning.output_tokens",
|
|
39
|
+
time_to_first_token: "gen_ai.response.time_to_first_chunk",
|
|
40
|
+
session_id: "gen_ai.conversation.id",
|
|
41
|
+
tool_name: "gen_ai.tool.name",
|
|
42
|
+
tool_type: "gen_ai.tool.type",
|
|
43
|
+
tool_call_id: "gen_ai.tool.call.id",
|
|
44
|
+
workflow_name: "gen_ai.workflow.name",
|
|
45
|
+
http_response_status_code: "http.response.status_code",
|
|
46
|
+
error_class: "error.type",
|
|
47
|
+
error_type: "error.type"
|
|
48
|
+
}.freeze # :nodoc:
|
|
49
|
+
OPERATIONS = {
|
|
50
|
+
agent: "invoke_agent",
|
|
51
|
+
agent_turn: "agent_turn",
|
|
52
|
+
model: "chat",
|
|
53
|
+
run: "invoke_agent",
|
|
54
|
+
runtime: "runtime_startup",
|
|
55
|
+
session_store: "session_store",
|
|
56
|
+
subagent: "invoke_agent",
|
|
57
|
+
tool: "execute_tool",
|
|
58
|
+
workflow: "invoke_workflow"
|
|
59
|
+
}.freeze # :nodoc:
|
|
60
|
+
REQUEST_SETTING_ATTRIBUTES = {
|
|
61
|
+
frequency_penalty: "gen_ai.request.frequency_penalty",
|
|
62
|
+
max_tokens: "gen_ai.request.max_tokens",
|
|
63
|
+
max_output_tokens: "gen_ai.request.max_tokens",
|
|
64
|
+
presence_penalty: "gen_ai.request.presence_penalty",
|
|
65
|
+
seed: "gen_ai.request.seed",
|
|
66
|
+
temperature: "gen_ai.request.temperature",
|
|
67
|
+
top_k: "gen_ai.request.top_k",
|
|
68
|
+
top_p: "gen_ai.request.top_p"
|
|
69
|
+
}.freeze # :nodoc:
|
|
70
|
+
CONTENT_KEYS = %w[arguments content input input_text message output output_text prompt response text].freeze # :nodoc:
|
|
71
|
+
SENSITIVE_KEY = /(authorization|api[_-]?key|credential|password|secret|(?:^|[_-])token(?:$|[_-])|cookie|private[_-]?key)/i # :nodoc:
|
|
72
|
+
MAX_ATTRIBUTE_LENGTH = 1_024 # :nodoc:
|
|
73
|
+
|
|
74
|
+
# Uses +tracer+ or the global tracer provider.
|
|
75
|
+
def initialize(tracer: nil)
|
|
76
|
+
@tracer = tracer
|
|
77
|
+
@entries = {}
|
|
78
|
+
@mutex = Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Starts a span for a lifecycle operation.
|
|
82
|
+
def start(name, attributes)
|
|
83
|
+
start_span(name.to_sym, prepare_attributes(:start, name.to_sym, attributes))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Finishes the span correlated by operation ID.
|
|
87
|
+
def finish(name, attributes)
|
|
88
|
+
finish_span(name.to_sym, prepare_attributes(:finish, name.to_sym, attributes))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Adds a structured event to the correlated active span.
|
|
92
|
+
def emit(name, attributes)
|
|
93
|
+
add_event(name.to_sym, prepare_attributes(:emit, name.to_sym, attributes))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Supplies W3C propagation fields for an active operation when known.
|
|
97
|
+
def trace_context(operation_id: nil, **)
|
|
98
|
+
span = @mutex.synchronize { @entries.dig(operation_id, :span) }
|
|
99
|
+
context = span&.context
|
|
100
|
+
return {} unless context&.valid?
|
|
101
|
+
|
|
102
|
+
carrier = {}
|
|
103
|
+
trace_context_propagator.inject(
|
|
104
|
+
carrier,
|
|
105
|
+
context: ::OpenTelemetry::Trace.context_with_span(span)
|
|
106
|
+
)
|
|
107
|
+
carrier.slice("traceparent", "tracestate").merge(trace_id: context.hex_trace_id)
|
|
108
|
+
rescue
|
|
109
|
+
context&.valid? ? {trace_id: context.hex_trace_id} : {}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Wraps a block in a standalone internal span. Prefer lifecycle
|
|
113
|
+
# Instrumentation methods for normal framework operations.
|
|
114
|
+
def with_span(name, attributes:, parent_operation_id: nil)
|
|
115
|
+
parent = @mutex.synchronize { @entries[parent_operation_id] }
|
|
116
|
+
parent_context = parent && ::OpenTelemetry::Trace.context_with_span(parent.fetch(:span))
|
|
117
|
+
options = {kind: :internal, attributes:}
|
|
118
|
+
options[:with_parent] = parent_context if parent_context
|
|
119
|
+
span = tracer.start_span(name, **options)
|
|
120
|
+
yield span
|
|
121
|
+
rescue => error
|
|
122
|
+
span.status = ::OpenTelemetry::Trace::Status.error(error.class.name) if span
|
|
123
|
+
raise
|
|
124
|
+
ensure
|
|
125
|
+
span&.finish
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Flushes through the configured tracer provider when supported.
|
|
129
|
+
def flush(timeout: nil)
|
|
130
|
+
provider = ::OpenTelemetry.tracer_provider
|
|
131
|
+
provider.force_flush(timeout:) if provider.respond_to?(:force_flush)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Finishes spans still owned by this subscriber.
|
|
135
|
+
def shutdown(timeout: nil)
|
|
136
|
+
spans = @mutex.synchronize do
|
|
137
|
+
current = @entries.values.map { |entry| entry.fetch(:span) }.uniq
|
|
138
|
+
@entries = {}
|
|
139
|
+
current
|
|
140
|
+
end
|
|
141
|
+
spans.each(&:finish)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def prepare_attributes(_phase, _name, attributes) = attributes
|
|
147
|
+
|
|
148
|
+
def tracer
|
|
149
|
+
@tracer || ::OpenTelemetry.tracer_provider.tracer("little_ghost", LittleGhost::VERSION)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def start_span(name, attributes)
|
|
153
|
+
kind = name.to_sym
|
|
154
|
+
kind = :workflow if kind == :run && attributes[:entrypoint_kind]&.to_sym == :workflow
|
|
155
|
+
parent = @mutex.synchronize { @entries[attributes[:parent_operation_id]] }
|
|
156
|
+
if kind == :agent && parent&.fetch(:kind) == :run &&
|
|
157
|
+
parent[:agent_id].to_s == attributes[:agent_id].to_s
|
|
158
|
+
alias_primary_agent(attributes, parent)
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
parent_context = if parent
|
|
163
|
+
::OpenTelemetry::Trace.context_with_span(parent.fetch(:span))
|
|
164
|
+
elsif attributes[:trace_context].is_a?(Hash)
|
|
165
|
+
trace_context_propagator.extract(attributes[:trace_context])
|
|
166
|
+
end
|
|
167
|
+
options = {
|
|
168
|
+
kind: span_kind(kind),
|
|
169
|
+
attributes: span_attributes(attributes, kind:).merge(
|
|
170
|
+
"gen_ai.operation.name" => OPERATIONS.fetch(kind, kind.to_s)
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
links = trace_links(attributes[:trace_links])
|
|
174
|
+
options[:links] = links unless links.empty?
|
|
175
|
+
span = if parent_context
|
|
176
|
+
tracer.start_span(span_name(kind, attributes), with_parent: parent_context, **options)
|
|
177
|
+
elsif links.empty?
|
|
178
|
+
tracer.start_span(span_name(kind, attributes), **options)
|
|
179
|
+
else
|
|
180
|
+
tracer.start_root_span(span_name(kind, attributes), **options)
|
|
181
|
+
end
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
@entries[attributes.fetch(:operation_id)] = {
|
|
184
|
+
span:,
|
|
185
|
+
kind:,
|
|
186
|
+
alias: false,
|
|
187
|
+
agent_id: attributes[:agent_id]
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def alias_primary_agent(attributes, parent)
|
|
193
|
+
span = parent.fetch(:span)
|
|
194
|
+
span_attributes(attributes, kind: :agent).each do |key, value|
|
|
195
|
+
span.set_attribute(key, value)
|
|
196
|
+
end
|
|
197
|
+
@mutex.synchronize do
|
|
198
|
+
@entries[attributes.fetch(:operation_id)] = {
|
|
199
|
+
span:,
|
|
200
|
+
kind: :agent,
|
|
201
|
+
alias: true,
|
|
202
|
+
agent_id: attributes[:agent_id]
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def finish_span(name, attributes)
|
|
208
|
+
entry = @mutex.synchronize { @entries.delete(attributes[:operation_id]) }
|
|
209
|
+
return add_event(:orphan_finish, attributes.merge(instrumentation_name: name)) unless entry
|
|
210
|
+
|
|
211
|
+
span = entry.fetch(:span)
|
|
212
|
+
finish_attributes = attributes.except(:diagnostic_input, :diagnostic_tool_definitions)
|
|
213
|
+
span_attributes(finish_attributes, kind: entry.fetch(:kind)).each do |key, value|
|
|
214
|
+
span.set_attribute(key, value)
|
|
215
|
+
end
|
|
216
|
+
return if entry.fetch(:alias)
|
|
217
|
+
|
|
218
|
+
record_status(span, attributes, kind: entry.fetch(:kind))
|
|
219
|
+
span.finish
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def record_status(span, attributes, kind:)
|
|
223
|
+
error_type = attributes[:error_type] || attributes[:error_class]
|
|
224
|
+
unless error_type
|
|
225
|
+
span.status = ::OpenTelemetry::Trace::Status.ok
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
event_attributes = {"exception.type" => error_type.to_s}
|
|
230
|
+
captured = parse_json(attributes[:diagnostic_exception])
|
|
231
|
+
if captured.is_a?(Hash)
|
|
232
|
+
event_attributes["exception.message"] = attribute_value(:exception_message, captured["message"])
|
|
233
|
+
stacktrace = captured["stacktrace"]
|
|
234
|
+
event_attributes["exception.stacktrace"] = String(stacktrace).slice(0, 64_000) if stacktrace
|
|
235
|
+
end
|
|
236
|
+
description = [error_type, event_attributes["exception.message"]].compact.join(": ")
|
|
237
|
+
span.status = ::OpenTelemetry::Trace::Status.error(description)
|
|
238
|
+
event_name = (kind == :model) ? "gen_ai.client.operation.exception" : "exception"
|
|
239
|
+
span.add_event(event_name, attributes: event_attributes.compact)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def add_event(name, attributes)
|
|
243
|
+
owner_ids = [attributes[:operation_id], attributes[:parent_operation_id]].compact.uniq
|
|
244
|
+
span = @mutex.synchronize do
|
|
245
|
+
owner_ids.filter_map { |owner_id| @entries.dig(owner_id, :span) }.first
|
|
246
|
+
end
|
|
247
|
+
kind = attributes[:event_kind]&.to_sym
|
|
248
|
+
if span
|
|
249
|
+
span.add_event("little_ghost.#{name}", attributes: span_attributes(attributes, kind:))
|
|
250
|
+
else
|
|
251
|
+
tracer.in_span("little_ghost.#{name}", attributes: span_attributes(attributes, kind:)) {}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def span_name(kind, attributes)
|
|
256
|
+
return attribute_value(:span_name, attributes[:span_name]) if attributes[:span_name]
|
|
257
|
+
|
|
258
|
+
detail = case kind
|
|
259
|
+
when :agent, :run then attributes[:agent_name] || attributes[:agent_id]
|
|
260
|
+
when :workflow then attributes[:workflow_name]
|
|
261
|
+
when :agent_turn then attributes[:turn]
|
|
262
|
+
when :model then attributes[:model_id]
|
|
263
|
+
when :subagent then attributes[:subagent_id] || attributes[:kind]
|
|
264
|
+
when :tool then attributes[:tool_name]
|
|
265
|
+
end
|
|
266
|
+
detail = attribute_value(:span_name, detail) if detail
|
|
267
|
+
[OPERATIONS.fetch(kind, kind.to_s), detail].compact.join(" ")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def span_attributes(attributes, kind: nil)
|
|
271
|
+
attributes.each_with_object({}) do |(key, value), result|
|
|
272
|
+
next if internal_attribute?(key)
|
|
273
|
+
|
|
274
|
+
if key.to_sym == :model_settings
|
|
275
|
+
add_model_settings(result, value)
|
|
276
|
+
next
|
|
277
|
+
end
|
|
278
|
+
if key.to_sym == :diagnostic_tool_definitions
|
|
279
|
+
definitions = parse_json(value)
|
|
280
|
+
if kind == :tool
|
|
281
|
+
add_tool_metadata(result, definitions)
|
|
282
|
+
else
|
|
283
|
+
add_tool_definitions(result, definitions)
|
|
284
|
+
end
|
|
285
|
+
next
|
|
286
|
+
end
|
|
287
|
+
if key.to_sym == :total_tokens
|
|
288
|
+
next
|
|
289
|
+
end
|
|
290
|
+
next if %i[available_tools duration_ms outcome diagnostic_exception].include?(key.to_sym)
|
|
291
|
+
|
|
292
|
+
if %i[diagnostic_input diagnostic_output].include?(key.to_sym)
|
|
293
|
+
add_content_attributes(result, key, attribute_value(key, value), kind:, attributes:)
|
|
294
|
+
next
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
name = key.to_s.include?(".") ? key.to_s : ATTRIBUTE_NAMES.fetch(key.to_sym, "little_ghost.#{key}")
|
|
298
|
+
normalized = if key.to_sym == :model_provider
|
|
299
|
+
provider_name(value)
|
|
300
|
+
else
|
|
301
|
+
attribute_value(key, value)
|
|
302
|
+
end
|
|
303
|
+
result[name] = gen_ai_usage_value(key, attributes, normalized)
|
|
304
|
+
if key.to_sym == :stop_reason && value
|
|
305
|
+
result["gen_ai.response.finish_reasons"] = [normalized]
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def internal_attribute?(key)
|
|
311
|
+
%i[event_kind operation_id parent_operation_id trace_context trace_links entrypoint_kind span_name].include?(key.to_sym)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def add_model_settings(attributes, settings)
|
|
315
|
+
settings.to_h.each do |key, value|
|
|
316
|
+
name = REQUEST_SETTING_ATTRIBUTES[key.to_sym]
|
|
317
|
+
attributes[name] = scalar(value) if name && !scalar(value).nil?
|
|
318
|
+
end
|
|
319
|
+
rescue NoMethodError
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def add_tool_definitions(attributes, definitions)
|
|
324
|
+
return unless definitions.is_a?(Array)
|
|
325
|
+
|
|
326
|
+
attributes["gen_ai.tool.definitions"] = json_attribute(
|
|
327
|
+
definitions.map do |definition|
|
|
328
|
+
values = definition.to_h.transform_keys(&:to_sym)
|
|
329
|
+
{
|
|
330
|
+
type: "function",
|
|
331
|
+
name: values[:name],
|
|
332
|
+
description: values[:description],
|
|
333
|
+
parameters: values[:input_schema] || {}
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def add_tool_metadata(attributes, definitions)
|
|
340
|
+
return unless definitions.is_a?(Array) && definitions.first.is_a?(Hash)
|
|
341
|
+
|
|
342
|
+
definition = definitions.first
|
|
343
|
+
attributes["gen_ai.tool.description"] = definition["description"].to_s if definition["description"]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def add_content_attributes(result, key, value, kind:, attributes:)
|
|
347
|
+
return unless %i[diagnostic_input diagnostic_output].include?(key.to_sym)
|
|
348
|
+
|
|
349
|
+
prefix = (key.to_sym == :diagnostic_input) ? "input" : "output"
|
|
350
|
+
if %i[agent interrupt tool].include?(kind) &&
|
|
351
|
+
!(kind == :tool && prefix == "output" && attributes[:error_type])
|
|
352
|
+
result["#{prefix}.value"] = value
|
|
353
|
+
result["#{prefix}.mime_type"] = diagnostic_mime_type(value)
|
|
354
|
+
end
|
|
355
|
+
if kind == :tool
|
|
356
|
+
name = (prefix == "input") ? "gen_ai.tool.call.arguments" : "gen_ai.tool.call.result"
|
|
357
|
+
result[name] = value unless prefix == "output" && attributes[:error_type]
|
|
358
|
+
end
|
|
359
|
+
return unless kind == :model
|
|
360
|
+
|
|
361
|
+
semantic_name = (prefix == "input") ? "gen_ai.input.messages" : "gen_ai.output.messages"
|
|
362
|
+
messages = canonical_messages(
|
|
363
|
+
value,
|
|
364
|
+
finish_reason: prefix == "output" && (attributes[:stop_reason] || Array(attributes[:finish_reasons]).first)
|
|
365
|
+
)
|
|
366
|
+
if messages
|
|
367
|
+
result[semantic_name] = messages
|
|
368
|
+
result["#{prefix}.value"] = messages
|
|
369
|
+
result["#{prefix}.mime_type"] = "application/json"
|
|
370
|
+
else
|
|
371
|
+
result["#{prefix}.value"] = value
|
|
372
|
+
result["#{prefix}.mime_type"] = diagnostic_mime_type(value)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def diagnostic_mime_type(value)
|
|
377
|
+
return "application/json" if value.is_a?(Hash) || value.is_a?(Array)
|
|
378
|
+
|
|
379
|
+
JSON.parse(value)
|
|
380
|
+
"application/json"
|
|
381
|
+
rescue JSON::ParserError, TypeError
|
|
382
|
+
"text/plain"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def canonical_messages(value, finish_reason: nil)
|
|
386
|
+
messages = parse_json(value)
|
|
387
|
+
messages = [messages] if messages.is_a?(Hash)
|
|
388
|
+
return unless messages.is_a?(Array)
|
|
389
|
+
|
|
390
|
+
normalized_messages = messages.filter_map do |message|
|
|
391
|
+
next unless message.is_a?(Hash)
|
|
392
|
+
|
|
393
|
+
role = message["role"].to_s
|
|
394
|
+
next if role.empty?
|
|
395
|
+
|
|
396
|
+
normalized = {
|
|
397
|
+
role:,
|
|
398
|
+
parts: Array(message["content"]).filter_map { |part| canonical_message_part(part) }
|
|
399
|
+
}
|
|
400
|
+
normalized[:finish_reason] = finish_reason.to_s if finish_reason
|
|
401
|
+
normalized
|
|
402
|
+
end
|
|
403
|
+
return if normalized_messages.empty?
|
|
404
|
+
|
|
405
|
+
json_attribute(normalized_messages)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def canonical_message_part(part)
|
|
409
|
+
return {type: "text", content: part.to_s} unless part.is_a?(Hash)
|
|
410
|
+
|
|
411
|
+
case part["type"]
|
|
412
|
+
when "text", "reasoning"
|
|
413
|
+
{type: part.fetch("type"), content: part["text"].to_s}
|
|
414
|
+
when "tool_use"
|
|
415
|
+
{
|
|
416
|
+
type: "tool_call",
|
|
417
|
+
id: part["id"].to_s,
|
|
418
|
+
name: part["name"].to_s,
|
|
419
|
+
arguments: part["input"] || {}
|
|
420
|
+
}
|
|
421
|
+
when "tool_result"
|
|
422
|
+
{
|
|
423
|
+
type: "tool_call_response",
|
|
424
|
+
id: part["tool_use_id"].to_s,
|
|
425
|
+
response: part["content"]
|
|
426
|
+
}
|
|
427
|
+
when "image", "document"
|
|
428
|
+
{
|
|
429
|
+
type: "blob",
|
|
430
|
+
mime_type: part["media_type"].to_s,
|
|
431
|
+
name: part["name"],
|
|
432
|
+
size: part["bytes"]
|
|
433
|
+
}.compact
|
|
434
|
+
else
|
|
435
|
+
{type: "text", content: part.to_s}
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def span_kind(kind)
|
|
440
|
+
%i[model session_store].include?(kind) ? :client : :internal
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def gen_ai_usage_value(key, attributes, value)
|
|
444
|
+
if key.to_sym == :input_tokens
|
|
445
|
+
return value.to_i + attributes.fetch(:cache_read_tokens, 0).to_i +
|
|
446
|
+
attributes.fetch(:cache_write_tokens, 0).to_i
|
|
447
|
+
end
|
|
448
|
+
return value.to_i + attributes.fetch(:reasoning_tokens, 0).to_i if key.to_sym == :output_tokens
|
|
449
|
+
|
|
450
|
+
value
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def trace_links(carriers)
|
|
454
|
+
Array(carriers).filter_map do |carrier|
|
|
455
|
+
next unless carrier.is_a?(Hash)
|
|
456
|
+
|
|
457
|
+
context = trace_context_propagator.extract(carrier)
|
|
458
|
+
span_context = ::OpenTelemetry::Trace.current_span(context).context
|
|
459
|
+
::OpenTelemetry::Trace::Link.new(span_context) if span_context.valid?
|
|
460
|
+
rescue
|
|
461
|
+
nil
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def attribute_value(key, value)
|
|
466
|
+
return value if key.to_sym == :time_to_first_token && value.is_a?(Numeric)
|
|
467
|
+
|
|
468
|
+
key_name = key.to_s
|
|
469
|
+
normalized_key = key_name.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
|
|
470
|
+
return "[REDACTED]" if SENSITIVE_KEY.match?(normalized_key) || CONTENT_KEYS.include?(normalized_key)
|
|
471
|
+
|
|
472
|
+
case value
|
|
473
|
+
when String
|
|
474
|
+
limit = key.to_s.start_with?("diagnostic_") ? value.length : MAX_ATTRIBUTE_LENGTH
|
|
475
|
+
value.slice(0, limit)
|
|
476
|
+
when Symbol then value.to_s
|
|
477
|
+
when Numeric, true, false then value
|
|
478
|
+
when Array then value.filter_map { |item| scalar(item) }
|
|
479
|
+
when Hash then json_attribute(value)
|
|
480
|
+
else value.to_s.slice(0, MAX_ATTRIBUTE_LENGTH)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def json_attribute(value)
|
|
485
|
+
JSON.generate(value)
|
|
486
|
+
rescue JSON::GeneratorError, TypeError
|
|
487
|
+
JSON.generate("[UNSERIALIZABLE]")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def parse_json(value)
|
|
491
|
+
JSON.parse(value) if value.is_a?(String)
|
|
492
|
+
rescue JSON::ParserError
|
|
493
|
+
nil
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def provider_name(value)
|
|
497
|
+
{
|
|
498
|
+
"bedrock" => "aws.bedrock",
|
|
499
|
+
"open_router" => "openrouter"
|
|
500
|
+
}.fetch(value.to_s, value.to_s)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def trace_context_propagator
|
|
504
|
+
@trace_context_propagator ||=
|
|
505
|
+
::OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def scalar(value)
|
|
509
|
+
case value
|
|
510
|
+
when String then value.slice(0, MAX_ATTRIBUTE_LENGTH)
|
|
511
|
+
when Symbol then value.to_s
|
|
512
|
+
when Numeric, true, false then value
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|