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.
- 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/dataset_items_controller.rb +2 -2
- data/app/controllers/observ/dataset_runs_controller.rb +1 -1
- data/app/controllers/observ/datasets_controller.rb +2 -2
- data/app/controllers/observ/messages_controller.rb +5 -1
- data/app/controllers/observ/prompts_controller.rb +11 -3
- 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/markdown_helper.rb +29 -0
- data/app/helpers/observ/prompts_helper.rb +48 -0
- data/app/jobs/observ/moderation_guardrail_job.rb +115 -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/session.rb +33 -0
- data/app/models/observ/trace.rb +90 -4
- data/app/models/observ/transcription.rb +38 -0
- data/app/services/observ/chat_instrumenter.rb +96 -6
- data/app/services/observ/concerns/observable_service.rb +108 -3
- data/app/services/observ/embedding_instrumenter.rb +193 -0
- data/app/services/observ/guardrail_service.rb +9 -0
- data/app/services/observ/image_generation_instrumenter.rb +243 -0
- data/app/services/observ/moderation_guardrail_service.rb +235 -0
- data/app/services/observ/moderation_instrumenter.rb +141 -0
- data/app/services/observ/transcription_instrumenter.rb +187 -0
- 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/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
- 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
|
-
#
|
|
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: {
|
|
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
|
data/app/models/observ/trace.rb
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|