rubyllm-observ 0.6.6 → 0.6.7

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +319 -1
  3. data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
  4. data/app/assets/javascripts/observ/controllers/index.js +29 -0
  5. data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
  6. data/app/assets/stylesheets/observ/_chat.scss +199 -0
  7. data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
  8. data/app/assets/stylesheets/observ/application.scss +1 -0
  9. data/app/controllers/observ/dataset_items_controller.rb +2 -2
  10. data/app/controllers/observ/dataset_runs_controller.rb +1 -1
  11. data/app/controllers/observ/datasets_controller.rb +2 -2
  12. data/app/controllers/observ/messages_controller.rb +5 -1
  13. data/app/controllers/observ/prompts_controller.rb +11 -3
  14. data/app/controllers/observ/scores_controller.rb +1 -1
  15. data/app/controllers/observ/traces_controller.rb +1 -1
  16. data/app/helpers/observ/application_helper.rb +1 -0
  17. data/app/helpers/observ/markdown_helper.rb +29 -0
  18. data/app/helpers/observ/prompts_helper.rb +48 -0
  19. data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
  20. data/app/models/observ/embedding.rb +45 -0
  21. data/app/models/observ/image_generation.rb +38 -0
  22. data/app/models/observ/moderation.rb +40 -0
  23. data/app/models/observ/null_prompt.rb +49 -2
  24. data/app/models/observ/observation.rb +3 -1
  25. data/app/models/observ/session.rb +33 -0
  26. data/app/models/observ/trace.rb +90 -4
  27. data/app/models/observ/transcription.rb +38 -0
  28. data/app/services/observ/chat_instrumenter.rb +96 -6
  29. data/app/services/observ/concerns/observable_service.rb +108 -3
  30. data/app/services/observ/embedding_instrumenter.rb +193 -0
  31. data/app/services/observ/guardrail_service.rb +9 -0
  32. data/app/services/observ/image_generation_instrumenter.rb +243 -0
  33. data/app/services/observ/moderation_guardrail_service.rb +235 -0
  34. data/app/services/observ/moderation_instrumenter.rb +141 -0
  35. data/app/services/observ/transcription_instrumenter.rb +187 -0
  36. data/app/views/observ/chats/show.html.erb +9 -0
  37. data/app/views/observ/messages/_message.html.erb +1 -1
  38. data/app/views/observ/messages/create.turbo_stream.erb +1 -3
  39. data/app/views/observ/prompts/_config_editor.html.erb +115 -0
  40. data/app/views/observ/prompts/_form.html.erb +2 -13
  41. data/app/views/observ/prompts/_new_form.html.erb +2 -12
  42. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
  43. data/lib/observ/engine.rb +7 -0
  44. data/lib/observ/version.rb +1 -1
  45. metadata +31 -1
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class ImageGeneration < Observation
5
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
6
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
7
+
8
+ update!(
9
+ output: output.is_a?(String) ? output : output.to_json,
10
+ usage: merged_usage,
11
+ cost_usd: cost_usd,
12
+ end_time: Time.current,
13
+ status_message: status_message
14
+ )
15
+ end
16
+
17
+ # Image-specific helpers
18
+ def size
19
+ metadata&.dig("size")
20
+ end
21
+
22
+ def quality
23
+ metadata&.dig("quality")
24
+ end
25
+
26
+ def revised_prompt
27
+ metadata&.dig("revised_prompt")
28
+ end
29
+
30
+ def output_format
31
+ metadata&.dig("output_format") # "url" or "base64"
32
+ end
33
+
34
+ def mime_type
35
+ metadata&.dig("mime_type")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Moderation < Observation
5
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
6
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
7
+
8
+ update!(
9
+ output: output.is_a?(String) ? output : output.to_json,
10
+ usage: merged_usage,
11
+ cost_usd: cost_usd,
12
+ end_time: Time.current,
13
+ status_message: status_message
14
+ )
15
+ end
16
+
17
+ # Moderation-specific helpers
18
+ def flagged?
19
+ metadata&.dig("flagged") || false
20
+ end
21
+
22
+ def categories
23
+ metadata&.dig("categories") || {}
24
+ end
25
+
26
+ def category_scores
27
+ metadata&.dig("category_scores") || {}
28
+ end
29
+
30
+ def flagged_categories
31
+ metadata&.dig("flagged_categories") || []
32
+ end
33
+
34
+ def highest_score_category
35
+ return nil if category_scores.empty?
36
+
37
+ category_scores.max_by { |_, score| score }&.first
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mustache"
4
+
3
5
  module Observ
4
6
  # Null Object pattern for Prompt
5
7
  # Used when a prompt is not found, providing a fallback with the same interface
@@ -17,9 +19,36 @@ module Observ
17
19
  nil
18
20
  end
19
21
 
20
- # Returns the fallback text as-is (no variable compilation)
22
+ # Compile prompt with Mustache templating (same as Prompt)
21
23
  def compile(variables = {})
22
- @prompt
24
+ return @prompt if variables.empty?
25
+
26
+ Mustache.render(@prompt, variables)
27
+ end
28
+
29
+ # Compile with validation (raises if missing top-level variables)
30
+ # Note: Variables inside sections (loops) are validated at render time by Mustache
31
+ def compile_with_validation(variables = {})
32
+ required_vars = required_variables
33
+ provided_keys = variables.keys.map(&:to_s)
34
+
35
+ missing_vars = required_vars.reject do |var|
36
+ # Handle dot notation (e.g., "user.name" - check if "user" key exists)
37
+ root_key = var.split(".").first
38
+ provided_keys.include?(var) || provided_keys.include?(root_key)
39
+ end
40
+
41
+ if missing_vars.any?
42
+ raise Observ::VariableSubstitutionError, "Missing variables: #{missing_vars.join(', ')}"
43
+ end
44
+
45
+ compile(variables)
46
+ end
47
+
48
+ # Extract top-level variables from template (for validation purposes)
49
+ def required_variables
50
+ template_without_sections = strip_sections(@prompt)
51
+ template_without_sections.scan(/\{\{([^#\^\/!>\{\s][^}\s]*)\}\}/).flatten.uniq
23
52
  end
24
53
 
25
54
  # Null prompts are always in a "fallback" state
@@ -55,5 +84,23 @@ module Observ
55
84
  def inspect
56
85
  "#<Observ::NullPrompt name: #{name.inspect}, fallback: #{prompt[0..50].inspect}...>"
57
86
  end
87
+
88
+ private
89
+
90
+ # Strip section content from template for top-level variable extraction
91
+ # Removes content between {{#section}}...{{/section}} and {{^section}}...{{/section}}
92
+ def strip_sections(template)
93
+ result = template.dup
94
+
95
+ # Match sections: {{#name}}...{{/name}} or {{^name}}...{{/name}}
96
+ # Use non-greedy matching and handle nesting by repeating until stable
97
+ loop do
98
+ previous = result
99
+ result = result.gsub(/\{\{[#\^](\w+)\}\}.*?\{\{\/\1\}\}/m, "")
100
+ break if result == previous
101
+ end
102
+
103
+ result
104
+ end
58
105
  end
59
106
  end
@@ -9,7 +9,9 @@ module Observ
9
9
 
10
10
  validates :observation_id, presence: true, uniqueness: true
11
11
  validates :start_time, presence: true
12
- validates :type, presence: true, inclusion: { in: %w[Observ::Generation Observ::Span] }
12
+ validates :type, presence: true, inclusion: {
13
+ in: %w[Observ::Generation Observ::Span Observ::Embedding Observ::ImageGeneration Observ::Transcription Observ::Moderation]
14
+ }
13
15
 
14
16
  after_save :update_trace_metrics, if: :saved_change_to_cost_or_usage?
15
17
 
@@ -17,6 +17,7 @@ module Observ
17
17
 
18
18
  before_validation :set_session_id, on: :create
19
19
  before_validation :set_start_time, on: :create
20
+ after_commit :evaluate_guardrails, if: :just_completed?
20
21
 
21
22
  def create_trace(name: nil, input: nil, metadata: {}, tags: [])
22
23
  traces.create!(
@@ -103,6 +104,30 @@ module Observ
103
104
  instrumenter
104
105
  end
105
106
 
107
+ def instrument_embedding(context: {})
108
+ instrumenter = Observ::EmbeddingInstrumenter.new(self, context: context)
109
+ instrumenter.instrument!
110
+ instrumenter
111
+ end
112
+
113
+ def instrument_image_generation(context: {})
114
+ instrumenter = Observ::ImageGenerationInstrumenter.new(self, context: context)
115
+ instrumenter.instrument!
116
+ instrumenter
117
+ end
118
+
119
+ def instrument_transcription(context: {})
120
+ instrumenter = Observ::TranscriptionInstrumenter.new(self, context: context)
121
+ instrumenter.instrument!
122
+ instrumenter
123
+ end
124
+
125
+ def instrument_moderation(context: {})
126
+ instrumenter = Observ::ModerationInstrumenter.new(self, context: context)
127
+ instrumenter.instrument!
128
+ instrumenter
129
+ end
130
+
106
131
  def chat
107
132
  @chat ||= ::Chat.find_by(observability_session_id: session_id) if defined?(::Chat)
108
133
  end
@@ -131,5 +156,13 @@ module Observ
131
156
  ((g.end_time - g.start_time) * 1000).round(2)
132
157
  end || 0
133
158
  end
159
+
160
+ def just_completed?
161
+ previous_changes.key?("end_time") && end_time.present?
162
+ end
163
+
164
+ def evaluate_guardrails
165
+ GuardrailService.evaluate_session(self)
166
+ end
134
167
  end
135
168
  end
@@ -17,6 +17,7 @@ module Observ
17
17
  validates :start_time, presence: true
18
18
 
19
19
  after_save :update_session_metrics, if: :saved_change_to_total_cost_or_total_tokens?
20
+ after_commit :evaluate_guardrails, if: :just_completed?
20
21
 
21
22
  def create_generation(name: "llm_generation", model: nil, metadata: {}, **options)
22
23
  observations.create!(
@@ -42,6 +43,54 @@ module Observ
42
43
  )
43
44
  end
44
45
 
46
+ def create_embedding(name: "embedding", model: nil, metadata: {}, **options)
47
+ observations.create!(
48
+ observation_id: SecureRandom.uuid,
49
+ type: "Observ::Embedding",
50
+ name: name,
51
+ model: model,
52
+ metadata: metadata,
53
+ start_time: Time.current,
54
+ **options.slice(:model_parameters, :parent_observation_id)
55
+ )
56
+ end
57
+
58
+ def create_image_generation(name: "image_generation", model: nil, metadata: {}, **options)
59
+ observations.create!(
60
+ observation_id: SecureRandom.uuid,
61
+ type: "Observ::ImageGeneration",
62
+ name: name,
63
+ model: model,
64
+ metadata: metadata,
65
+ start_time: Time.current,
66
+ **options.slice(:model_parameters, :parent_observation_id)
67
+ )
68
+ end
69
+
70
+ def create_transcription(name: "transcription", model: nil, metadata: {}, **options)
71
+ observations.create!(
72
+ observation_id: SecureRandom.uuid,
73
+ type: "Observ::Transcription",
74
+ name: name,
75
+ model: model,
76
+ metadata: metadata,
77
+ start_time: Time.current,
78
+ **options.slice(:model_parameters, :parent_observation_id)
79
+ )
80
+ end
81
+
82
+ def create_moderation(name: "moderation", model: nil, metadata: {}, **options)
83
+ observations.create!(
84
+ observation_id: SecureRandom.uuid,
85
+ type: "Observ::Moderation",
86
+ name: name,
87
+ model: model,
88
+ metadata: metadata,
89
+ start_time: Time.current,
90
+ **options.slice(:model_parameters, :parent_observation_id)
91
+ )
92
+ end
93
+
45
94
  def finalize(output: nil, metadata: {})
46
95
  merged_metadata = (self.metadata || {}).merge(metadata)
47
96
  update!(
@@ -74,13 +123,26 @@ module Observ
74
123
  end
75
124
 
76
125
  def update_aggregated_metrics
77
- new_total_cost = generations.sum(:cost_usd) || 0.0
78
-
79
- # Database-agnostic token calculation
80
- new_total_tokens = generations.sum do |gen|
126
+ # Include generations, embeddings, image generations, transcriptions, and moderations in cost calculation
127
+ new_total_cost = (generations.sum(:cost_usd) || 0.0) +
128
+ (embeddings.sum(:cost_usd) || 0.0) +
129
+ (image_generations.sum(:cost_usd) || 0.0) +
130
+ (transcriptions.sum(:cost_usd) || 0.0) +
131
+ (moderations.sum(:cost_usd) || 0.0)
132
+
133
+ # Database-agnostic token calculation for generations
134
+ generation_tokens = generations.sum do |gen|
81
135
  gen.usage&.dig("total_tokens") || 0
82
136
  end
83
137
 
138
+ # Embeddings only have input tokens
139
+ embedding_tokens = embeddings.sum do |emb|
140
+ emb.usage&.dig("input_tokens") || 0
141
+ end
142
+
143
+ # Image generations, transcriptions, and moderations don't use tokens
144
+ new_total_tokens = generation_tokens + embedding_tokens
145
+
84
146
  update_columns(
85
147
  total_cost: new_total_cost,
86
148
  total_tokens: new_total_tokens
@@ -95,6 +157,22 @@ module Observ
95
157
  observations.where(type: "Observ::Span")
96
158
  end
97
159
 
160
+ def embeddings
161
+ observations.where(type: "Observ::Embedding")
162
+ end
163
+
164
+ def image_generations
165
+ observations.where(type: "Observ::ImageGeneration")
166
+ end
167
+
168
+ def transcriptions
169
+ observations.where(type: "Observ::Transcription")
170
+ end
171
+
172
+ def moderations
173
+ observations.where(type: "Observ::Moderation")
174
+ end
175
+
98
176
  def models_used
99
177
  generations.where.not(model: nil).distinct.pluck(:model)
100
178
  end
@@ -134,5 +212,13 @@ module Observ
134
212
  def update_session_metrics
135
213
  observ_session&.update_aggregated_metrics
136
214
  end
215
+
216
+ def just_completed?
217
+ previous_changes.key?("end_time") && end_time.present?
218
+ end
219
+
220
+ def evaluate_guardrails
221
+ GuardrailService.evaluate_trace(self)
222
+ end
137
223
  end
138
224
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Transcription < Observation
5
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
6
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
7
+
8
+ update!(
9
+ output: output.is_a?(String) ? output : output.to_json,
10
+ usage: merged_usage,
11
+ cost_usd: cost_usd,
12
+ end_time: Time.current,
13
+ status_message: status_message
14
+ )
15
+ end
16
+
17
+ # Transcription-specific helpers
18
+ def audio_duration_s
19
+ metadata&.dig("audio_duration_s")
20
+ end
21
+
22
+ def language
23
+ metadata&.dig("language")
24
+ end
25
+
26
+ def segments_count
27
+ metadata&.dig("segments_count") || 0
28
+ end
29
+
30
+ def speakers_count
31
+ metadata&.dig("speakers_count")
32
+ end
33
+
34
+ def has_diarization?
35
+ metadata&.dig("has_diarization") || false
36
+ end
37
+ end
38
+ end
@@ -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,
@@ -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
- assistant_message = chat_instance.messages
510
- .where(role: "assistant")
511
- .where("created_at >= ?", call_start_time)
512
- .order(created_at: :desc)
513
- .first
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
- def initialize_observability(session_or_false = nil, service_name:, metadata: {})
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