rubyllm-observ 0.6.6 → 0.6.8
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 +4 -4
- data/README.md +319 -1
- data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
- data/app/assets/javascripts/observ/controllers/index.js +29 -0
- data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
- data/app/assets/stylesheets/observ/_chat.scss +199 -0
- data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
- data/app/assets/stylesheets/observ/application.scss +1 -0
- data/app/controllers/observ/annotations_controller.rb +2 -2
- data/app/controllers/observ/chats_controller.rb +1 -1
- data/app/controllers/observ/dataset_items_controller.rb +3 -3
- data/app/controllers/observ/dataset_runs_controller.rb +3 -3
- data/app/controllers/observ/datasets_controller.rb +4 -4
- data/app/controllers/observ/messages_controller.rb +5 -1
- data/app/controllers/observ/prompts_controller.rb +14 -6
- data/app/controllers/observ/review_queue_controller.rb +1 -1
- data/app/controllers/observ/scores_controller.rb +1 -1
- data/app/controllers/observ/traces_controller.rb +1 -1
- data/app/helpers/observ/application_helper.rb +1 -0
- data/app/helpers/observ/dashboard_helper.rb +2 -2
- data/app/helpers/observ/markdown_helper.rb +29 -0
- data/app/helpers/observ/pagination_helper.rb +1 -1
- data/app/helpers/observ/prompts_helper.rb +48 -0
- data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
- data/app/models/concerns/observ/prompt_management.rb +10 -0
- data/app/models/observ/embedding.rb +45 -0
- data/app/models/observ/image_generation.rb +38 -0
- data/app/models/observ/moderation.rb +40 -0
- data/app/models/observ/null_prompt.rb +49 -2
- data/app/models/observ/observation.rb +3 -1
- data/app/models/observ/prompt.rb +2 -2
- data/app/models/observ/review_item.rb +1 -1
- data/app/models/observ/score.rb +1 -1
- data/app/models/observ/session.rb +33 -0
- data/app/models/observ/trace.rb +90 -4
- data/app/models/observ/transcription.rb +38 -0
- data/app/presenters/observ/agent_select_presenter.rb +3 -3
- data/app/services/observ/chat_instrumenter.rb +97 -7
- data/app/services/observ/concerns/observable_service.rb +108 -3
- data/app/services/observ/dataset_runner_service.rb +1 -1
- data/app/services/observ/embedding_instrumenter.rb +193 -0
- data/app/services/observ/evaluator_runner_service.rb +1 -1
- data/app/services/observ/evaluators/contains_evaluator.rb +1 -1
- data/app/services/observ/guardrail_service.rb +10 -1
- data/app/services/observ/image_generation_instrumenter.rb +243 -0
- data/app/services/observ/moderation_guardrail_service.rb +239 -0
- data/app/services/observ/moderation_instrumenter.rb +141 -0
- data/app/services/observ/prompt_manager/caching.rb +15 -2
- data/app/services/observ/transcription_instrumenter.rb +187 -0
- data/app/validators/observ/prompt_config_validator.rb +5 -5
- data/app/views/observ/chats/show.html.erb +9 -0
- data/app/views/observ/messages/_message.html.erb +1 -1
- data/app/views/observ/messages/create.turbo_stream.erb +1 -3
- data/app/views/observ/prompts/_config_editor.html.erb +115 -0
- data/app/views/observ/prompts/_form.html.erb +2 -13
- data/app/views/observ/prompts/_new_form.html.erb +2 -12
- data/config/routes.rb +13 -13
- data/db/migrate/005_create_observ_prompts.rb +2 -2
- data/db/migrate/011_create_observ_dataset_items.rb +1 -1
- data/db/migrate/012_create_observ_dataset_runs.rb +2 -2
- data/db/migrate/013_create_observ_dataset_run_items.rb +1 -1
- data/db/migrate/014_create_observ_scores.rb +2 -2
- data/db/migrate/015_refactor_scores_to_polymorphic.rb +2 -2
- data/db/migrate/016_create_observ_review_items.rb +2 -2
- data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
- data/lib/observ/engine.rb +7 -0
- data/lib/observ/version.rb +1 -1
- data/lib/tasks/observ_tasks.rake +2 -2
- metadata +33 -3
|
@@ -11,6 +11,7 @@ module Observ
|
|
|
11
11
|
@current_trace = nil
|
|
12
12
|
@current_tool_span = nil
|
|
13
13
|
@original_ask_method = nil
|
|
14
|
+
@original_complete_method = nil
|
|
14
15
|
@instrumented = false
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -18,6 +19,7 @@ module Observ
|
|
|
18
19
|
return if @instrumented
|
|
19
20
|
|
|
20
21
|
wrap_ask_method
|
|
22
|
+
wrap_complete_method
|
|
21
23
|
setup_event_handlers
|
|
22
24
|
@instrumented = true
|
|
23
25
|
|
|
@@ -101,6 +103,66 @@ module Observ
|
|
|
101
103
|
raise
|
|
102
104
|
end
|
|
103
105
|
|
|
106
|
+
def wrap_complete_method
|
|
107
|
+
return if @original_complete_method
|
|
108
|
+
|
|
109
|
+
@original_complete_method = chat.method(:complete)
|
|
110
|
+
instrumenter = self
|
|
111
|
+
|
|
112
|
+
chat.define_singleton_method(:complete) do |**kwargs, &block|
|
|
113
|
+
instrumenter.send(:handle_complete_call, self, kwargs, block)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Handle complete calls - similar to ask but uses existing messages
|
|
118
|
+
# instead of adding a new user message
|
|
119
|
+
def handle_complete_call(chat_instance, kwargs, block)
|
|
120
|
+
# Get the last user message for trace input
|
|
121
|
+
last_user_message = find_messages_by_role(chat_instance.messages, :user).last
|
|
122
|
+
user_message_content = last_user_message&.content
|
|
123
|
+
|
|
124
|
+
# Track if this is an ephemeral trace (created just for this call)
|
|
125
|
+
is_ephemeral_trace = @current_trace.nil?
|
|
126
|
+
|
|
127
|
+
trace = @current_trace || create_trace(
|
|
128
|
+
name: "chat.complete",
|
|
129
|
+
input: { text: user_message_content },
|
|
130
|
+
metadata: {}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
model_id = extract_model_id(chat_instance)
|
|
134
|
+
|
|
135
|
+
# Extract prompt metadata from the chat's agent (if available)
|
|
136
|
+
prompt_metadata = extract_prompt_metadata(chat_instance)
|
|
137
|
+
|
|
138
|
+
generation = trace.create_generation(
|
|
139
|
+
name: "llm_call",
|
|
140
|
+
metadata: @context.merge(kwargs.slice(:temperature, :max_tokens)),
|
|
141
|
+
model: model_id,
|
|
142
|
+
model_parameters: extract_model_parameters(chat_instance),
|
|
143
|
+
**prompt_metadata
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
messages_snapshot = capture_messages(chat_instance)
|
|
147
|
+
generation.set_input(user_message_content, messages: messages_snapshot)
|
|
148
|
+
|
|
149
|
+
call_start_time = Time.current
|
|
150
|
+
result = @original_complete_method.call(**kwargs, &block)
|
|
151
|
+
|
|
152
|
+
finalize_generation(generation, result, call_start_time)
|
|
153
|
+
|
|
154
|
+
if is_ephemeral_trace
|
|
155
|
+
link_trace_to_message(trace, chat_instance, call_start_time)
|
|
156
|
+
trace.finalize(output: result.content)
|
|
157
|
+
@current_trace = nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
result
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
handle_error(e, trace, generation)
|
|
163
|
+
raise
|
|
164
|
+
end
|
|
165
|
+
|
|
104
166
|
def setup_event_handlers
|
|
105
167
|
setup_tool_call_handler
|
|
106
168
|
setup_tool_result_handler
|
|
@@ -310,6 +372,26 @@ module Observ
|
|
|
310
372
|
[]
|
|
311
373
|
end
|
|
312
374
|
|
|
375
|
+
# Find messages by role, handling both ActiveRecord relations and plain Arrays.
|
|
376
|
+
# ActiveRecord-backed Chat models return relations with .where(), while raw
|
|
377
|
+
# RubyLLM::Chat objects return plain Arrays.
|
|
378
|
+
#
|
|
379
|
+
# Note: We use a rescue block because some objects may claim to respond_to?(:where)
|
|
380
|
+
# but fail when the method is actually called (edge cases with proxies or custom objects).
|
|
381
|
+
def find_messages_by_role(messages, role)
|
|
382
|
+
role_str = role.to_s
|
|
383
|
+
if messages.respond_to?(:where)
|
|
384
|
+
begin
|
|
385
|
+
messages.where(role: role)
|
|
386
|
+
rescue NoMethodError
|
|
387
|
+
# Fallback to array filtering if where method doesn't actually exist
|
|
388
|
+
messages.select { |m| m.role.to_s == role_str }
|
|
389
|
+
end
|
|
390
|
+
else
|
|
391
|
+
messages.select { |m| m.role.to_s == role_str }
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
313
395
|
def extract_usage(result)
|
|
314
396
|
usage = {
|
|
315
397
|
input_tokens: result.input_tokens || 0,
|
|
@@ -496,7 +578,7 @@ module Observ
|
|
|
496
578
|
elsif value.is_a?(Hash)
|
|
497
579
|
truncate_large_hash(value)
|
|
498
580
|
elsif value.is_a?(Array) && value.size > 100
|
|
499
|
-
value[0..99] + [
|
|
581
|
+
value[0..99] + ["... #{value.size - 100} more items"]
|
|
500
582
|
else
|
|
501
583
|
value
|
|
502
584
|
end
|
|
@@ -506,13 +588,21 @@ module Observ
|
|
|
506
588
|
def link_trace_to_message(trace, chat_instance, call_start_time)
|
|
507
589
|
return unless chat_instance.respond_to?(:messages)
|
|
508
590
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
591
|
+
messages = chat_instance.messages
|
|
592
|
+
assistant_message = if messages.respond_to?(:where)
|
|
593
|
+
# ActiveRecord-backed Chat models support query methods
|
|
594
|
+
messages
|
|
595
|
+
.where(role: "assistant")
|
|
596
|
+
.where("created_at >= ?", call_start_time)
|
|
597
|
+
.order(created_at: :desc)
|
|
598
|
+
.first
|
|
599
|
+
else
|
|
600
|
+
# Raw RubyLLM::Chat objects return plain Arrays without timestamps.
|
|
601
|
+
# Get the last assistant message (most recent from this call).
|
|
602
|
+
find_messages_by_role(messages, :assistant).last
|
|
603
|
+
end
|
|
514
604
|
|
|
515
|
-
if assistant_message
|
|
605
|
+
if assistant_message&.respond_to?(:id) && assistant_message.id
|
|
516
606
|
trace.update(message_id: assistant_message.id)
|
|
517
607
|
Rails.logger.info "[Observability] Linked trace #{trace.trace_id} to message #{assistant_message.id}"
|
|
518
608
|
end
|
|
@@ -12,11 +12,12 @@ module Observ
|
|
|
12
12
|
# class MyService
|
|
13
13
|
# include Observ::Concerns::ObservableService
|
|
14
14
|
#
|
|
15
|
-
# def initialize(observability_session: nil)
|
|
15
|
+
# def initialize(observability_session: nil, moderate: false)
|
|
16
16
|
# initialize_observability(
|
|
17
17
|
# observability_session,
|
|
18
18
|
# service_name: "my_service",
|
|
19
|
-
# metadata: { custom: "data" }
|
|
19
|
+
# metadata: { custom: "data" },
|
|
20
|
+
# moderate: moderate
|
|
20
21
|
# )
|
|
21
22
|
# end
|
|
22
23
|
#
|
|
@@ -24,6 +25,7 @@ module Observ
|
|
|
24
25
|
# with_observability do |session|
|
|
25
26
|
# # Your service logic here
|
|
26
27
|
# # Session automatically finalized on success/error
|
|
28
|
+
# # If moderate: true, content moderation runs after finalization
|
|
27
29
|
# end
|
|
28
30
|
# end
|
|
29
31
|
# end
|
|
@@ -39,7 +41,10 @@ module Observ
|
|
|
39
41
|
# @param session_or_false [Observ::Session, false, nil] Session to use, false to disable, nil to auto-create
|
|
40
42
|
# @param service_name [String] Name of the service (used in session metadata)
|
|
41
43
|
# @param metadata [Hash] Additional metadata to include in the session
|
|
42
|
-
|
|
44
|
+
# @param moderate [Boolean] Whether to run content moderation after session finalization
|
|
45
|
+
def initialize_observability(session_or_false = nil, service_name:, metadata: {}, moderate: false)
|
|
46
|
+
@moderate_on_complete = moderate
|
|
47
|
+
|
|
43
48
|
if session_or_false == false
|
|
44
49
|
# Explicitly disable observability
|
|
45
50
|
@observability = nil
|
|
@@ -61,6 +66,9 @@ module Observ
|
|
|
61
66
|
# whether it succeeds or raises an error. Only sessions owned by this
|
|
62
67
|
# service instance (i.e., auto-created sessions) will be finalized.
|
|
63
68
|
#
|
|
69
|
+
# If moderate: true was passed to initialize_observability, content moderation
|
|
70
|
+
# will be enqueued after the session is finalized.
|
|
71
|
+
#
|
|
64
72
|
# @yield [session] The observability session (may be nil if disabled)
|
|
65
73
|
# @return The result of the block
|
|
66
74
|
#
|
|
@@ -72,9 +80,11 @@ module Observ
|
|
|
72
80
|
def with_observability(&block)
|
|
73
81
|
result = block.call(@observability)
|
|
74
82
|
finalize_service_session if @owns_session
|
|
83
|
+
enqueue_moderation if should_moderate?
|
|
75
84
|
result
|
|
76
85
|
rescue StandardError
|
|
77
86
|
finalize_service_session if @owns_session
|
|
87
|
+
enqueue_moderation if should_moderate?
|
|
78
88
|
raise
|
|
79
89
|
end
|
|
80
90
|
|
|
@@ -97,6 +107,74 @@ module Observ
|
|
|
97
107
|
@observability.instrument_chat(chat, context: context)
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
# Instrument RubyLLM.embed for observability
|
|
111
|
+
#
|
|
112
|
+
# This wraps the RubyLLM.embed class method to automatically create traces
|
|
113
|
+
# and track embedding calls within the observability session.
|
|
114
|
+
#
|
|
115
|
+
# @param context [Hash] Additional context to include in traces
|
|
116
|
+
# @return [Observ::EmbeddingInstrumenter, nil] The instrumenter or nil if observability is disabled
|
|
117
|
+
#
|
|
118
|
+
# @example
|
|
119
|
+
# instrument_embedding(context: { operation: "semantic_search" })
|
|
120
|
+
# embedding = RubyLLM.embed("Search query")
|
|
121
|
+
def instrument_embedding(context: {})
|
|
122
|
+
return unless @observability
|
|
123
|
+
|
|
124
|
+
@observability.instrument_embedding(context: context)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Instrument RubyLLM.paint for observability
|
|
128
|
+
#
|
|
129
|
+
# This wraps the RubyLLM.paint class method to automatically create traces
|
|
130
|
+
# and track image generation calls within the observability session.
|
|
131
|
+
#
|
|
132
|
+
# @param context [Hash] Additional context to include in traces
|
|
133
|
+
# @return [Observ::ImageGenerationInstrumenter, nil] The instrumenter or nil if observability is disabled
|
|
134
|
+
#
|
|
135
|
+
# @example
|
|
136
|
+
# instrument_image_generation(context: { operation: "product_image" })
|
|
137
|
+
# image = RubyLLM.paint("A modern logo")
|
|
138
|
+
def instrument_image_generation(context: {})
|
|
139
|
+
return unless @observability
|
|
140
|
+
|
|
141
|
+
@observability.instrument_image_generation(context: context)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Instrument RubyLLM.transcribe for observability
|
|
145
|
+
#
|
|
146
|
+
# This wraps the RubyLLM.transcribe class method to automatically create traces
|
|
147
|
+
# and track transcription calls within the observability session.
|
|
148
|
+
#
|
|
149
|
+
# @param context [Hash] Additional context to include in traces
|
|
150
|
+
# @return [Observ::TranscriptionInstrumenter, nil] The instrumenter or nil if observability is disabled
|
|
151
|
+
#
|
|
152
|
+
# @example
|
|
153
|
+
# instrument_transcription(context: { operation: "meeting_notes" })
|
|
154
|
+
# transcript = RubyLLM.transcribe("meeting.wav")
|
|
155
|
+
def instrument_transcription(context: {})
|
|
156
|
+
return unless @observability
|
|
157
|
+
|
|
158
|
+
@observability.instrument_transcription(context: context)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Instrument RubyLLM.moderate for observability
|
|
162
|
+
#
|
|
163
|
+
# This wraps the RubyLLM.moderate class method to automatically create traces
|
|
164
|
+
# and track moderation calls within the observability session.
|
|
165
|
+
#
|
|
166
|
+
# @param context [Hash] Additional context to include in traces
|
|
167
|
+
# @return [Observ::ModerationInstrumenter, nil] The instrumenter or nil if observability is disabled
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# instrument_moderation(context: { operation: "user_input_check" })
|
|
171
|
+
# result = RubyLLM.moderate(user_input)
|
|
172
|
+
def instrument_moderation(context: {})
|
|
173
|
+
return unless @observability
|
|
174
|
+
|
|
175
|
+
@observability.instrument_moderation(context: context)
|
|
176
|
+
end
|
|
177
|
+
|
|
100
178
|
private
|
|
101
179
|
|
|
102
180
|
# Create a new observability session for this service
|
|
@@ -138,6 +216,33 @@ module Observ
|
|
|
138
216
|
"[#{self.class.name}] Failed to finalize session: #{e.message}"
|
|
139
217
|
)
|
|
140
218
|
end
|
|
219
|
+
|
|
220
|
+
# Check if moderation should be enqueued
|
|
221
|
+
#
|
|
222
|
+
# Moderation is only enqueued when:
|
|
223
|
+
# - moderate: true was passed to initialize_observability
|
|
224
|
+
# - This service owns the session (created it)
|
|
225
|
+
# - The session exists
|
|
226
|
+
#
|
|
227
|
+
# @return [Boolean] Whether to enqueue moderation
|
|
228
|
+
def should_moderate?
|
|
229
|
+
@moderate_on_complete && @owns_session && @observability.present?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Enqueue content moderation for the session
|
|
233
|
+
#
|
|
234
|
+
# This schedules a background job to run content moderation on all
|
|
235
|
+
# traces in the session, flagging any problematic content for review.
|
|
236
|
+
def enqueue_moderation
|
|
237
|
+
Observ::ModerationGuardrailJob.perform_later(session_id: @observability.id)
|
|
238
|
+
Rails.logger.debug(
|
|
239
|
+
"[#{self.class.name}] Moderation enqueued for session: #{@observability.session_id}"
|
|
240
|
+
)
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
Rails.logger.error(
|
|
243
|
+
"[#{self.class.name}] Failed to enqueue moderation: #{e.message}"
|
|
244
|
+
)
|
|
245
|
+
end
|
|
141
246
|
end
|
|
142
247
|
end
|
|
143
248
|
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class EmbeddingInstrumenter
|
|
5
|
+
attr_reader :session, :context
|
|
6
|
+
|
|
7
|
+
def initialize(session, context: {})
|
|
8
|
+
@session = session
|
|
9
|
+
@context = context
|
|
10
|
+
@original_embed_method = nil
|
|
11
|
+
@instrumented = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def instrument!
|
|
15
|
+
return if @instrumented
|
|
16
|
+
|
|
17
|
+
wrap_embed_method
|
|
18
|
+
@instrumented = true
|
|
19
|
+
|
|
20
|
+
Rails.logger.info "[Observability] Instrumented RubyLLM.embed for session #{session.session_id}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def uninstrument!
|
|
24
|
+
return unless @instrumented
|
|
25
|
+
return unless @original_embed_method
|
|
26
|
+
|
|
27
|
+
RubyLLM.define_singleton_method(:embed, @original_embed_method)
|
|
28
|
+
@instrumented = false
|
|
29
|
+
|
|
30
|
+
Rails.logger.info "[Observability] Uninstrumented RubyLLM.embed"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def wrap_embed_method
|
|
36
|
+
return if @original_embed_method
|
|
37
|
+
|
|
38
|
+
@original_embed_method = RubyLLM.method(:embed)
|
|
39
|
+
instrumenter = self
|
|
40
|
+
|
|
41
|
+
RubyLLM.define_singleton_method(:embed) do |*args, **kwargs|
|
|
42
|
+
instrumenter.send(:handle_embed_call, args, kwargs)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_embed_call(args, kwargs)
|
|
47
|
+
texts = args[0]
|
|
48
|
+
model_id = kwargs[:model] || default_embedding_model
|
|
49
|
+
|
|
50
|
+
trace = session.create_trace(
|
|
51
|
+
name: "embedding",
|
|
52
|
+
input: format_input(texts),
|
|
53
|
+
metadata: @context.merge(
|
|
54
|
+
batch_size: Array(texts).size,
|
|
55
|
+
model: model_id
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
embedding_obs = trace.create_embedding(
|
|
60
|
+
name: "embed",
|
|
61
|
+
model: model_id,
|
|
62
|
+
metadata: {
|
|
63
|
+
batch_size: Array(texts).size
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
embedding_obs.set_input(texts)
|
|
67
|
+
|
|
68
|
+
call_start_time = Time.current
|
|
69
|
+
result = @original_embed_method.call(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
finalize_embedding(embedding_obs, result, call_start_time)
|
|
72
|
+
trace.finalize(
|
|
73
|
+
output: format_output(result),
|
|
74
|
+
metadata: { dimensions: extract_dimensions(result) }
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
result
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
handle_error(e, trace, embedding_obs)
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finalize_embedding(embedding_obs, result, _call_start_time)
|
|
84
|
+
usage = extract_usage(result)
|
|
85
|
+
cost = calculate_cost(result)
|
|
86
|
+
dimensions = extract_dimensions(result)
|
|
87
|
+
vectors_count = extract_vectors_count(result)
|
|
88
|
+
|
|
89
|
+
embedding_obs.finalize(
|
|
90
|
+
output: format_output(result),
|
|
91
|
+
usage: usage,
|
|
92
|
+
cost_usd: cost
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
embedding_obs.update!(
|
|
96
|
+
metadata: embedding_obs.metadata.merge(
|
|
97
|
+
dimensions: dimensions,
|
|
98
|
+
vectors_count: vectors_count
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_usage(result)
|
|
104
|
+
{
|
|
105
|
+
input_tokens: result.input_tokens || 0,
|
|
106
|
+
total_tokens: result.input_tokens || 0
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def calculate_cost(result)
|
|
111
|
+
model_id = result.model
|
|
112
|
+
return 0.0 unless model_id
|
|
113
|
+
|
|
114
|
+
model_info = RubyLLM.models.find(model_id)
|
|
115
|
+
return 0.0 unless model_info&.input_price_per_million
|
|
116
|
+
|
|
117
|
+
input_tokens = result.input_tokens || 0
|
|
118
|
+
(input_tokens * model_info.input_price_per_million / 1_000_000.0).round(6)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
Rails.logger.warn "[Observability] Failed to calculate embedding cost: #{e.message}"
|
|
121
|
+
0.0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_dimensions(result)
|
|
125
|
+
vectors = result.vectors
|
|
126
|
+
return nil unless vectors
|
|
127
|
+
|
|
128
|
+
# Handle both single embedding and batch embeddings
|
|
129
|
+
if vectors.first.is_a?(Array)
|
|
130
|
+
vectors.first.length
|
|
131
|
+
else
|
|
132
|
+
vectors.length
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def extract_vectors_count(result)
|
|
137
|
+
vectors = result.vectors
|
|
138
|
+
return 1 unless vectors
|
|
139
|
+
|
|
140
|
+
# Handle both single embedding and batch embeddings
|
|
141
|
+
if vectors.first.is_a?(Array)
|
|
142
|
+
vectors.length
|
|
143
|
+
else
|
|
144
|
+
1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def format_input(texts)
|
|
149
|
+
if texts.is_a?(Array)
|
|
150
|
+
{ texts: texts, count: texts.size }
|
|
151
|
+
else
|
|
152
|
+
{ text: texts }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def format_output(result)
|
|
157
|
+
{
|
|
158
|
+
model: result.model,
|
|
159
|
+
dimensions: extract_dimensions(result),
|
|
160
|
+
vectors_count: extract_vectors_count(result)
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def default_embedding_model
|
|
165
|
+
if RubyLLM.config.respond_to?(:default_embedding_model)
|
|
166
|
+
RubyLLM.config.default_embedding_model
|
|
167
|
+
else
|
|
168
|
+
"text-embedding-3-small"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handle_error(error, trace, embedding_obs)
|
|
173
|
+
return unless trace
|
|
174
|
+
|
|
175
|
+
error_span = trace.create_span(
|
|
176
|
+
name: "error",
|
|
177
|
+
metadata: {
|
|
178
|
+
error_type: error.class.name,
|
|
179
|
+
level: "ERROR"
|
|
180
|
+
},
|
|
181
|
+
input: {
|
|
182
|
+
error_message: error.message,
|
|
183
|
+
backtrace: error.backtrace&.first(10)
|
|
184
|
+
}.to_json
|
|
185
|
+
)
|
|
186
|
+
error_span.finalize(output: { error_captured: true }.to_json)
|
|
187
|
+
|
|
188
|
+
embedding_obs&.update(status_message: "FAILED") rescue nil
|
|
189
|
+
|
|
190
|
+
Rails.logger.error "[Observability] Embedding error captured: #{error.class.name} - #{error.message}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -48,7 +48,7 @@ module Observ
|
|
|
48
48
|
.left_joins(:review_item)
|
|
49
49
|
.where(observ_review_items: { id: nil })
|
|
50
50
|
|
|
51
|
-
sample_size = [
|
|
51
|
+
sample_size = [(items.count * percentage / 100.0).ceil, 1].max
|
|
52
52
|
|
|
53
53
|
items.order("RANDOM()").limit(sample_size).find_each do |item|
|
|
54
54
|
item.enqueue_for_review!(reason: "random_sample", priority: :normal)
|
|
@@ -65,6 +65,15 @@ module Observ
|
|
|
65
65
|
condition: ->(t) { t.metadata&.dig("error").present? },
|
|
66
66
|
details: ->(t) { { error: t.metadata["error"] } }
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
name: :error_span,
|
|
70
|
+
priority: :critical,
|
|
71
|
+
condition: ->(t) { t.observations.exists?(type: "Observ::Span", name: "error") },
|
|
72
|
+
details: ->(t) {
|
|
73
|
+
error_span = t.observations.find_by(type: "Observ::Span", name: "error")
|
|
74
|
+
{ span_id: error_span&.observation_id, metadata: error_span&.metadata }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
68
77
|
{
|
|
69
78
|
name: :high_cost,
|
|
70
79
|
priority: :high,
|