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.
Files changed (69) 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/annotations_controller.rb +2 -2
  10. data/app/controllers/observ/chats_controller.rb +1 -1
  11. data/app/controllers/observ/dataset_items_controller.rb +3 -3
  12. data/app/controllers/observ/dataset_runs_controller.rb +3 -3
  13. data/app/controllers/observ/datasets_controller.rb +4 -4
  14. data/app/controllers/observ/messages_controller.rb +5 -1
  15. data/app/controllers/observ/prompts_controller.rb +14 -6
  16. data/app/controllers/observ/review_queue_controller.rb +1 -1
  17. data/app/controllers/observ/scores_controller.rb +1 -1
  18. data/app/controllers/observ/traces_controller.rb +1 -1
  19. data/app/helpers/observ/application_helper.rb +1 -0
  20. data/app/helpers/observ/dashboard_helper.rb +2 -2
  21. data/app/helpers/observ/markdown_helper.rb +29 -0
  22. data/app/helpers/observ/pagination_helper.rb +1 -1
  23. data/app/helpers/observ/prompts_helper.rb +48 -0
  24. data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
  25. data/app/models/concerns/observ/prompt_management.rb +10 -0
  26. data/app/models/observ/embedding.rb +45 -0
  27. data/app/models/observ/image_generation.rb +38 -0
  28. data/app/models/observ/moderation.rb +40 -0
  29. data/app/models/observ/null_prompt.rb +49 -2
  30. data/app/models/observ/observation.rb +3 -1
  31. data/app/models/observ/prompt.rb +2 -2
  32. data/app/models/observ/review_item.rb +1 -1
  33. data/app/models/observ/score.rb +1 -1
  34. data/app/models/observ/session.rb +33 -0
  35. data/app/models/observ/trace.rb +90 -4
  36. data/app/models/observ/transcription.rb +38 -0
  37. data/app/presenters/observ/agent_select_presenter.rb +3 -3
  38. data/app/services/observ/chat_instrumenter.rb +97 -7
  39. data/app/services/observ/concerns/observable_service.rb +108 -3
  40. data/app/services/observ/dataset_runner_service.rb +1 -1
  41. data/app/services/observ/embedding_instrumenter.rb +193 -0
  42. data/app/services/observ/evaluator_runner_service.rb +1 -1
  43. data/app/services/observ/evaluators/contains_evaluator.rb +1 -1
  44. data/app/services/observ/guardrail_service.rb +10 -1
  45. data/app/services/observ/image_generation_instrumenter.rb +243 -0
  46. data/app/services/observ/moderation_guardrail_service.rb +239 -0
  47. data/app/services/observ/moderation_instrumenter.rb +141 -0
  48. data/app/services/observ/prompt_manager/caching.rb +15 -2
  49. data/app/services/observ/transcription_instrumenter.rb +187 -0
  50. data/app/validators/observ/prompt_config_validator.rb +5 -5
  51. data/app/views/observ/chats/show.html.erb +9 -0
  52. data/app/views/observ/messages/_message.html.erb +1 -1
  53. data/app/views/observ/messages/create.turbo_stream.erb +1 -3
  54. data/app/views/observ/prompts/_config_editor.html.erb +115 -0
  55. data/app/views/observ/prompts/_form.html.erb +2 -13
  56. data/app/views/observ/prompts/_new_form.html.erb +2 -12
  57. data/config/routes.rb +13 -13
  58. data/db/migrate/005_create_observ_prompts.rb +2 -2
  59. data/db/migrate/011_create_observ_dataset_items.rb +1 -1
  60. data/db/migrate/012_create_observ_dataset_runs.rb +2 -2
  61. data/db/migrate/013_create_observ_dataset_run_items.rb +1 -1
  62. data/db/migrate/014_create_observ_scores.rb +2 -2
  63. data/db/migrate/015_refactor_scores_to_polymorphic.rb +2 -2
  64. data/db/migrate/016_create_observ_review_items.rb +2 -2
  65. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
  66. data/lib/observ/engine.rb +7 -0
  67. data/lib/observ/version.rb +1 -1
  68. data/lib/tasks/observ_tasks.rake +2 -2
  69. 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] + [ "... #{value.size - 100} more items" ]
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
- 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
@@ -82,7 +82,7 @@ module Observ
82
82
  dataset_item_id: run_item.dataset_item_id,
83
83
  agent_class: dataset.agent_class
84
84
  },
85
- tags: [ "dataset_evaluation", dataset.name, dataset_run.name ]
85
+ tags: ["dataset_evaluation", dataset.name, dataset_run.name]
86
86
  )
87
87
  end
88
88
 
@@ -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
@@ -42,7 +42,7 @@ module Observ
42
42
 
43
43
  def default_evaluator_configs
44
44
  # Default to exact_match if no config specified
45
- [ { "type" => "exact_match" } ]
45
+ [{ "type" => "exact_match" }]
46
46
  end
47
47
 
48
48
  def build_evaluator(config)
@@ -32,7 +32,7 @@ module Observ
32
32
  when Array
33
33
  expected
34
34
  when String
35
- [ expected ]
35
+ [expected]
36
36
  else
37
37
  []
38
38
  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 = [ (items.count * percentage / 100.0).ceil, 1 ].max
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,