rails_error_dashboard 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +99 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +41 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +307 -0
- data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +36 -0
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +63 -5
- data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +97 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +8 -0
- data/config/routes.rb +1 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/configuration.rb +101 -1
- data/lib/rails_error_dashboard/engine.rb +14 -0
- data/lib/rails_error_dashboard/integrations/llm_middleware.rb +276 -0
- data/lib/rails_error_dashboard/integrations/llm_span_processor.rb +181 -0
- data/lib/rails_error_dashboard/integrations/o_tel.rb +45 -0
- data/lib/rails_error_dashboard/services/llm_client.rb +368 -0
- data/lib/rails_error_dashboard/services/llm_cost_estimator.rb +85 -0
- data/lib/rails_error_dashboard/services/llm_summary.rb +91 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +87 -0
- data/lib/rails_error_dashboard/subscribers/llm_call_subscriber.rb +150 -0
- data/lib/rails_error_dashboard/value_objects/llm_call_event.rb +92 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +8 -0
- metadata +12 -2
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RailsErrorDashboard
|
|
8
|
+
module Services
|
|
9
|
+
class LlmClient
|
|
10
|
+
class ConfigurationError < StandardError; end
|
|
11
|
+
class RequestError < StandardError; end
|
|
12
|
+
|
|
13
|
+
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
|
|
14
|
+
OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions"
|
|
15
|
+
ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
|
|
16
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
17
|
+
|
|
18
|
+
attr_reader :config
|
|
19
|
+
|
|
20
|
+
def self.call(error:, question:, context:)
|
|
21
|
+
new.call(error: error, question: question, context: context)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.stream(error:, question:, context:, &block)
|
|
25
|
+
new.stream(error: error, question: question, context: context, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(config: RailsErrorDashboard.configuration)
|
|
29
|
+
@config = config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(error:, question:, context:)
|
|
33
|
+
raise ConfigurationError, "AI Help is not configured" unless config.llm_configured?
|
|
34
|
+
|
|
35
|
+
case config.effective_llm_provider
|
|
36
|
+
when :openai
|
|
37
|
+
openai_response(error: error, question: question, context: context)
|
|
38
|
+
when :anthropic
|
|
39
|
+
anthropic_response(error: error, question: question, context: context)
|
|
40
|
+
else
|
|
41
|
+
raise ConfigurationError, "Unsupported LLM provider: #{config.llm_provider.inspect}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stream(error:, question:, context:, &block)
|
|
46
|
+
raise ConfigurationError, "AI Help is not configured" unless config.llm_configured?
|
|
47
|
+
raise ArgumentError, "block is required for streaming" unless block
|
|
48
|
+
|
|
49
|
+
case config.effective_llm_provider
|
|
50
|
+
when :openai
|
|
51
|
+
openai_stream(error: error, question: question, context: context, &block)
|
|
52
|
+
when :anthropic
|
|
53
|
+
anthropic_stream(error: error, question: question, context: context, &block)
|
|
54
|
+
else
|
|
55
|
+
raise ConfigurationError, "Unsupported LLM provider: #{config.llm_provider.inspect}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def openai_response(error:, question:, context:)
|
|
62
|
+
model = config.effective_llm_model
|
|
63
|
+
mode = openai_endpoint_for(model)
|
|
64
|
+
|
|
65
|
+
if mode == :chat_completions
|
|
66
|
+
response = post_json(
|
|
67
|
+
OPENAI_CHAT_COMPLETIONS_URL,
|
|
68
|
+
openai_headers,
|
|
69
|
+
openai_chat_payload(error: error, question: question, context: context, model: model)
|
|
70
|
+
)
|
|
71
|
+
answer = response.dig("choices", 0, "message", "content")
|
|
72
|
+
else
|
|
73
|
+
response = post_json(
|
|
74
|
+
OPENAI_RESPONSES_URL,
|
|
75
|
+
openai_headers,
|
|
76
|
+
openai_responses_payload(error: error, question: question, context: context, model: model)
|
|
77
|
+
)
|
|
78
|
+
answer = extract_openai_response_text(response)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
provider_result(answer, :openai, model)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def anthropic_response(error:, question:, context:)
|
|
85
|
+
model = config.effective_llm_model
|
|
86
|
+
response = post_json(
|
|
87
|
+
ANTHROPIC_MESSAGES_URL,
|
|
88
|
+
anthropic_headers,
|
|
89
|
+
anthropic_payload(error: error, question: question, context: context, model: model)
|
|
90
|
+
)
|
|
91
|
+
answer = Array(response["content"]).filter_map { |part| part["text"] if part["type"] == "text" }.join("\n\n")
|
|
92
|
+
|
|
93
|
+
provider_result(answer, :anthropic, model)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def openai_stream(error:, question:, context:)
|
|
97
|
+
model = config.effective_llm_model
|
|
98
|
+
mode = openai_endpoint_for(model)
|
|
99
|
+
|
|
100
|
+
if mode == :chat_completions
|
|
101
|
+
payload = openai_chat_payload(error: error, question: question, context: context, model: model).merge(stream: true)
|
|
102
|
+
post_stream_json(OPENAI_CHAT_COMPLETIONS_URL, openai_headers, payload) do |event, data|
|
|
103
|
+
handle_openai_stream_event(event, data) { |text| yield text }
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
payload = openai_responses_payload(error: error, question: question, context: context, model: model).merge(stream: true)
|
|
107
|
+
post_stream_json(OPENAI_RESPONSES_URL, openai_headers, payload) do |event, data|
|
|
108
|
+
handle_openai_stream_event(event, data) { |text| yield text }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{ provider: "openai", model: model }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def anthropic_stream(error:, question:, context:)
|
|
116
|
+
model = config.effective_llm_model
|
|
117
|
+
payload = anthropic_payload(error: error, question: question, context: context, model: model).merge(stream: true)
|
|
118
|
+
|
|
119
|
+
post_stream_json(ANTHROPIC_MESSAGES_URL, anthropic_headers, payload) do |event, data|
|
|
120
|
+
handle_anthropic_stream_event(event, data) { |text| yield text }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{ provider: "anthropic", model: model }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def openai_endpoint_for(model)
|
|
127
|
+
configured = config.llm_openai_endpoint&.to_sym || :auto
|
|
128
|
+
return configured unless configured == :auto
|
|
129
|
+
|
|
130
|
+
:responses
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def openai_headers
|
|
134
|
+
{
|
|
135
|
+
"Authorization" => "Bearer #{config.effective_llm_api_key}",
|
|
136
|
+
"Content-Type" => "application/json"
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def anthropic_headers
|
|
141
|
+
{
|
|
142
|
+
"x-api-key" => config.effective_llm_api_key,
|
|
143
|
+
"anthropic-version" => ANTHROPIC_VERSION,
|
|
144
|
+
"Content-Type" => "application/json"
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def openai_responses_payload(error:, question:, context:, model:)
|
|
149
|
+
payload = {
|
|
150
|
+
model: model,
|
|
151
|
+
instructions: system_prompt,
|
|
152
|
+
input: user_prompt(error: error, question: question, context: context),
|
|
153
|
+
max_output_tokens: config.llm_max_output_tokens.to_i
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
payload
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def openai_chat_payload(error:, question:, context:, model:)
|
|
160
|
+
{
|
|
161
|
+
model: model,
|
|
162
|
+
messages: [
|
|
163
|
+
{ role: "system", content: system_prompt },
|
|
164
|
+
{ role: "user", content: user_prompt(error: error, question: question, context: context) }
|
|
165
|
+
],
|
|
166
|
+
max_tokens: config.llm_max_output_tokens.to_i
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def anthropic_payload(error:, question:, context:, model:)
|
|
171
|
+
{
|
|
172
|
+
model: model,
|
|
173
|
+
max_tokens: config.llm_max_output_tokens.to_i,
|
|
174
|
+
system: system_prompt,
|
|
175
|
+
messages: [
|
|
176
|
+
{ role: "user", content: user_prompt(error: error, question: question, context: context) }
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def system_prompt
|
|
182
|
+
prompt = <<~PROMPT.strip
|
|
183
|
+
You are helping debug a Rails exception from Rails Error Dashboard.
|
|
184
|
+
Answer only from the provided error context unless you clearly label an inference.
|
|
185
|
+
Focus on likely root cause, useful next checks, and concrete Rails code or data fixes.
|
|
186
|
+
Do not ask for secrets, credentials, or unrelated source code.
|
|
187
|
+
PROMPT
|
|
188
|
+
|
|
189
|
+
[ prompt, config.llm_system_prompt.presence ].compact.join("\n\n")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def user_prompt(error:, question:, context:)
|
|
193
|
+
<<~PROMPT
|
|
194
|
+
Error ID: #{error.id}
|
|
195
|
+
Error type: #{error.error_type}
|
|
196
|
+
Severity: #{error.severity}
|
|
197
|
+
Occurrences: #{error.occurrence_count}
|
|
198
|
+
|
|
199
|
+
User question:
|
|
200
|
+
#{question}
|
|
201
|
+
|
|
202
|
+
Error context:
|
|
203
|
+
#{context}
|
|
204
|
+
PROMPT
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def post_json(url, headers, payload)
|
|
208
|
+
uri = URI.parse(url)
|
|
209
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
210
|
+
request.body = JSON.generate(payload)
|
|
211
|
+
|
|
212
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
213
|
+
open_timeout: config.llm_timeout_seconds.to_i,
|
|
214
|
+
read_timeout: config.llm_timeout_seconds.to_i) do |http|
|
|
215
|
+
http.request(request)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
parsed = parse_json_response(response)
|
|
219
|
+
return parsed if response.is_a?(Net::HTTPSuccess)
|
|
220
|
+
|
|
221
|
+
message = provider_error_message(parsed, response.message)
|
|
222
|
+
raise RequestError, "LLM request failed (#{response.code}): #{message}"
|
|
223
|
+
rescue JSON::ParserError
|
|
224
|
+
raise RequestError, "LLM provider returned invalid JSON"
|
|
225
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
226
|
+
raise RequestError, "LLM request timed out"
|
|
227
|
+
rescue SocketError, SystemCallError => e
|
|
228
|
+
raise RequestError, "LLM request failed: #{e.message}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def parse_json_response(response)
|
|
232
|
+
body = response.body.to_s
|
|
233
|
+
body.present? ? JSON.parse(body) : {}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def extract_openai_response_text(response)
|
|
237
|
+
return response["output_text"] if response["output_text"].present?
|
|
238
|
+
|
|
239
|
+
Array(response["output"]).flat_map do |item|
|
|
240
|
+
Array(item["content"]).filter_map { |part| part["text"] if part["type"] == "output_text" || part["type"] == "text" }
|
|
241
|
+
end.join("\n\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def provider_result(answer, provider, model)
|
|
245
|
+
raise RequestError, "LLM provider returned an empty answer" if answer.blank?
|
|
246
|
+
|
|
247
|
+
{ answer: answer, provider: provider.to_s, model: model }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def post_stream_json(url, headers, payload)
|
|
251
|
+
uri = URI.parse(url)
|
|
252
|
+
stream_headers = headers.merge(
|
|
253
|
+
"Accept" => "text/event-stream",
|
|
254
|
+
"Accept-Encoding" => "identity",
|
|
255
|
+
"Cache-Control" => "no-cache"
|
|
256
|
+
)
|
|
257
|
+
request = Net::HTTP::Post.new(uri.request_uri, stream_headers)
|
|
258
|
+
request.body = JSON.generate(payload)
|
|
259
|
+
|
|
260
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
261
|
+
open_timeout: config.llm_timeout_seconds.to_i,
|
|
262
|
+
read_timeout: config.llm_timeout_seconds.to_i) do |http|
|
|
263
|
+
http.request(request) do |response|
|
|
264
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
265
|
+
body = +""
|
|
266
|
+
response.read_body { |chunk| body << chunk }
|
|
267
|
+
parsed = JSON.parse(body) rescue {}
|
|
268
|
+
message = provider_error_message(parsed, response.message)
|
|
269
|
+
raise RequestError, "LLM request failed (#{response.code}): #{message}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
each_sse_event(response) do |event, data|
|
|
273
|
+
next if data == "[DONE]"
|
|
274
|
+
|
|
275
|
+
parsed = JSON.parse(data)
|
|
276
|
+
yield event, parsed
|
|
277
|
+
rescue JSON::ParserError
|
|
278
|
+
raise RequestError, "LLM provider returned invalid streaming JSON"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
283
|
+
raise RequestError, "LLM request timed out"
|
|
284
|
+
rescue SocketError, SystemCallError => e
|
|
285
|
+
raise RequestError, "LLM request failed: #{e.message}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def each_sse_event(response)
|
|
289
|
+
buffer = +""
|
|
290
|
+
|
|
291
|
+
response.read_body do |chunk|
|
|
292
|
+
buffer << chunk.to_s
|
|
293
|
+
buffer.gsub!("\r\n", "\n")
|
|
294
|
+
while (separator = buffer.index("\n\n"))
|
|
295
|
+
raw_event = buffer.slice!(0, separator + 2)
|
|
296
|
+
event, data = parse_sse_event(raw_event)
|
|
297
|
+
yield event, data if data.present?
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
event, data = parse_sse_event(buffer)
|
|
302
|
+
yield event, data if data.present?
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def parse_sse_event(raw_event)
|
|
306
|
+
event = "message"
|
|
307
|
+
data_lines = []
|
|
308
|
+
|
|
309
|
+
raw_event.to_s.each_line do |line|
|
|
310
|
+
line = line.chomp
|
|
311
|
+
next if line.blank? || line.start_with?(":")
|
|
312
|
+
|
|
313
|
+
field, value = line.split(":", 2)
|
|
314
|
+
value = value.to_s.sub(/\A /, "")
|
|
315
|
+
|
|
316
|
+
case field
|
|
317
|
+
when "event"
|
|
318
|
+
event = value
|
|
319
|
+
when "data"
|
|
320
|
+
data_lines << value
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
[ event, data_lines.join("\n") ]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def handle_openai_stream_event(event, data)
|
|
328
|
+
if event == "error" || data["type"] == "error"
|
|
329
|
+
message = provider_error_message(data, "OpenAI stream failed")
|
|
330
|
+
raise RequestError, message
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
text = if event == "response.output_text.delta" || data["type"] == "response.output_text.delta"
|
|
334
|
+
data["delta"]
|
|
335
|
+
else
|
|
336
|
+
data.dig("choices", 0, "delta", "content")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
yield text if text.present?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def handle_anthropic_stream_event(event, data)
|
|
343
|
+
if event == "error" || data["type"] == "error"
|
|
344
|
+
message = provider_error_message(data, "Anthropic stream failed")
|
|
345
|
+
raise RequestError, message
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
delta = data["delta"] || {}
|
|
349
|
+
text = delta["text"] if data["type"] == "content_block_delta" && delta["type"] == "text_delta"
|
|
350
|
+
yield text if text.present?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def provider_error_message(parsed, fallback)
|
|
354
|
+
return fallback unless parsed.is_a?(Hash)
|
|
355
|
+
|
|
356
|
+
error = parsed["error"]
|
|
357
|
+
case error
|
|
358
|
+
when Hash
|
|
359
|
+
error["message"].presence || parsed["message"].presence || fallback
|
|
360
|
+
when String
|
|
361
|
+
error.presence || parsed["message"].presence || fallback
|
|
362
|
+
else
|
|
363
|
+
parsed["message"].presence || fallback
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Estimates USD cost for an LLM call given provider, model, and token counts.
|
|
6
|
+
#
|
|
7
|
+
# Prices are stored as USD per 1,000,000 tokens (the canonical unit used
|
|
8
|
+
# by OpenAI, Anthropic, and Google in their pricing pages).
|
|
9
|
+
#
|
|
10
|
+
# IMPORTANT: This is a best-effort estimate using a hardcoded snapshot.
|
|
11
|
+
# Prices change frequently. Users SHOULD configure overrides via
|
|
12
|
+
# `config.llm_pricing_overrides` for production accuracy.
|
|
13
|
+
#
|
|
14
|
+
# Returns nil for unknown models (no override AND not in the built-in table).
|
|
15
|
+
# Never raises — wrapped in rescue.
|
|
16
|
+
class LlmCostEstimator
|
|
17
|
+
# Prices last refreshed: 2026-05 (approximate, see provider pricing pages).
|
|
18
|
+
# Format: { "model-name" => { input: usd_per_1m, output: usd_per_1m } }
|
|
19
|
+
PRICES = {
|
|
20
|
+
# Anthropic
|
|
21
|
+
"claude-opus-4-7" => { input: 15.0, output: 75.0 },
|
|
22
|
+
"claude-sonnet-4-6" => { input: 3.0, output: 15.0 },
|
|
23
|
+
"claude-haiku-4-5" => { input: 0.80, output: 4.0 },
|
|
24
|
+
|
|
25
|
+
# OpenAI
|
|
26
|
+
"gpt-4o" => { input: 2.50, output: 10.0 },
|
|
27
|
+
"gpt-4o-mini" => { input: 0.15, output: 0.60 },
|
|
28
|
+
"gpt-4-turbo" => { input: 10.0, output: 30.0 },
|
|
29
|
+
"o1" => { input: 15.0, output: 60.0 },
|
|
30
|
+
"o1-mini" => { input: 3.0, output: 12.0 },
|
|
31
|
+
|
|
32
|
+
# Google
|
|
33
|
+
"gemini-2.5-pro" => { input: 1.25, output: 5.0 },
|
|
34
|
+
"gemini-2.5-flash" => { input: 0.075, output: 0.30 }
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# @param provider [String, Symbol] currently informational only (not used in lookup)
|
|
38
|
+
# @param model [String] model identifier — matched case-insensitively against PRICES
|
|
39
|
+
# @param input_tokens [Integer, nil]
|
|
40
|
+
# @param output_tokens [Integer, nil]
|
|
41
|
+
# @return [Float, nil] estimated USD cost, or nil if model unknown / tokens missing
|
|
42
|
+
def self.estimate(provider:, model:, input_tokens:, output_tokens:)
|
|
43
|
+
return nil if model.nil? || model.to_s.empty?
|
|
44
|
+
return nil if input_tokens.nil? && output_tokens.nil?
|
|
45
|
+
|
|
46
|
+
rate = lookup_rate(model.to_s)
|
|
47
|
+
return nil unless rate
|
|
48
|
+
|
|
49
|
+
in_tokens = input_tokens.to_i
|
|
50
|
+
out_tokens = output_tokens.to_i
|
|
51
|
+
|
|
52
|
+
cost = (in_tokens * rate[:input] + out_tokens * rate[:output]) / 1_000_000.0
|
|
53
|
+
cost.round(6)
|
|
54
|
+
rescue
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.lookup_rate(model)
|
|
59
|
+
# User-configured override wins. Match case-insensitively on the model key.
|
|
60
|
+
overrides = RailsErrorDashboard.configuration.llm_pricing_overrides || {}
|
|
61
|
+
normalized = model.downcase
|
|
62
|
+
|
|
63
|
+
overrides.each do |key, rate|
|
|
64
|
+
return symbolize_rate(rate) if key.to_s.downcase == normalized
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
PRICES.each do |key, rate|
|
|
68
|
+
return rate if key.downcase == normalized
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.symbolize_rate(rate)
|
|
75
|
+
return nil unless rate.is_a?(Hash)
|
|
76
|
+
{
|
|
77
|
+
input: rate[:input] || rate["input"],
|
|
78
|
+
output: rate[:output] || rate["output"]
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method :lookup_rate, :symbolize_rate
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Roll up LLM breadcrumbs into a per-error summary.
|
|
6
|
+
#
|
|
7
|
+
# Operates on already-captured breadcrumb data at display time only —
|
|
8
|
+
# zero runtime cost. Same pattern as CacheAnalyzer / NplusOneDetector.
|
|
9
|
+
#
|
|
10
|
+
# NOTE on string coercion: BreadcrumbCollector#truncate_metadata stringifies
|
|
11
|
+
# every metadata value (input_tokens "42", cost_usd "0.0003", etc.). This
|
|
12
|
+
# service does the `.to_i` / `.to_f` itself so callers don't have to.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# RailsErrorDashboard::Services::LlmSummary.call(breadcrumbs)
|
|
16
|
+
# # => { total_calls: 3, total_tool_calls: 2, total_input_tokens: 1450,
|
|
17
|
+
# # total_output_tokens: 220, total_tokens: 1670,
|
|
18
|
+
# # total_cost_usd: 0.00821, error_count: 1, total_duration_ms: 4321.5,
|
|
19
|
+
# # providers: ["anthropic", "openai"],
|
|
20
|
+
# # by_model: [ { provider: "openai", model: "gpt-4o-mini",
|
|
21
|
+
# # calls: 2, tokens: 800, cost_usd: 0.0042 }, ... ] }
|
|
22
|
+
class LlmSummary
|
|
23
|
+
# @param breadcrumbs [Array<Hash>] Parsed breadcrumb array
|
|
24
|
+
# @return [Hash, nil] Summary hash, or nil if no LLM breadcrumbs present
|
|
25
|
+
def self.call(breadcrumbs)
|
|
26
|
+
return nil unless breadcrumbs.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
llm_crumbs = breadcrumbs.select { |c| c.is_a?(Hash) && c["c"] == "llm" }
|
|
29
|
+
tool_crumbs = breadcrumbs.select { |c| c.is_a?(Hash) && c["c"] == "llm_tool" }
|
|
30
|
+
return nil if llm_crumbs.empty? && tool_crumbs.empty?
|
|
31
|
+
|
|
32
|
+
total_input = 0
|
|
33
|
+
total_output = 0
|
|
34
|
+
total_cost = 0.0
|
|
35
|
+
total_duration = 0.0
|
|
36
|
+
error_count = 0
|
|
37
|
+
providers = {}
|
|
38
|
+
by_model = {}
|
|
39
|
+
|
|
40
|
+
llm_crumbs.each do |crumb|
|
|
41
|
+
meta = crumb["meta"].is_a?(Hash) ? crumb["meta"] : {}
|
|
42
|
+
provider = meta["provider"].to_s
|
|
43
|
+
model = meta["model"].to_s
|
|
44
|
+
input = meta["input_tokens"].to_i
|
|
45
|
+
output = meta["output_tokens"].to_i
|
|
46
|
+
cost = meta["cost_usd"].to_f
|
|
47
|
+
duration = crumb["d"].to_f
|
|
48
|
+
status = meta["status"].to_s
|
|
49
|
+
|
|
50
|
+
total_input += input
|
|
51
|
+
total_output += output
|
|
52
|
+
total_cost += cost
|
|
53
|
+
total_duration += duration
|
|
54
|
+
error_count += 1 unless status == "success" || status == ""
|
|
55
|
+
|
|
56
|
+
providers[provider] = true unless provider.empty?
|
|
57
|
+
|
|
58
|
+
key = [ provider, model ]
|
|
59
|
+
by_model[key] ||= { provider: provider, model: model, calls: 0, tokens: 0, cost_usd: 0.0 }
|
|
60
|
+
by_model[key][:calls] += 1
|
|
61
|
+
by_model[key][:tokens] += input + output
|
|
62
|
+
by_model[key][:cost_usd] += cost
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Tool calls also contribute to duration (visible request impact)
|
|
66
|
+
tool_crumbs.each do |crumb|
|
|
67
|
+
total_duration += crumb["d"].to_f
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
total_calls: llm_crumbs.size,
|
|
72
|
+
total_tool_calls: tool_crumbs.size,
|
|
73
|
+
total_input_tokens: total_input,
|
|
74
|
+
total_output_tokens: total_output,
|
|
75
|
+
total_tokens: total_input + total_output,
|
|
76
|
+
total_cost_usd: total_cost.round(6),
|
|
77
|
+
error_count: error_count,
|
|
78
|
+
total_duration_ms: total_duration.round(1),
|
|
79
|
+
providers: providers.keys.sort,
|
|
80
|
+
by_model: by_model.values.sort_by { |row| -row[:calls] }.map { |row|
|
|
81
|
+
row[:cost_usd] = row[:cost_usd].round(6)
|
|
82
|
+
row
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
rescue => e
|
|
86
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] LlmSummary.call failed: #{e.message}")
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -15,6 +15,7 @@ module RailsErrorDashboard
|
|
|
15
15
|
MAX_BACKTRACE_LINES = 15
|
|
16
16
|
MAX_BREADCRUMBS = 10
|
|
17
17
|
MAX_VARIABLES = 10
|
|
18
|
+
MAX_LLM_CALL_ROWS = 10
|
|
18
19
|
|
|
19
20
|
# @param error [ErrorLog] An error log record
|
|
20
21
|
# @param related_errors [Array] Related error results with :error and :similarity
|
|
@@ -41,6 +42,7 @@ module RailsErrorDashboard
|
|
|
41
42
|
sections << local_variables_section
|
|
42
43
|
sections << instance_variables_section
|
|
43
44
|
sections << request_context_section
|
|
45
|
+
sections << llm_calls_section
|
|
44
46
|
sections << breadcrumbs_section
|
|
45
47
|
sections << environment_section
|
|
46
48
|
sections << system_health_section
|
|
@@ -198,6 +200,91 @@ module RailsErrorDashboard
|
|
|
198
200
|
"## Request Context\n\n#{items.join("\n")}"
|
|
199
201
|
end
|
|
200
202
|
|
|
203
|
+
def llm_calls_section
|
|
204
|
+
raw = @error.breadcrumbs
|
|
205
|
+
return nil if raw.blank?
|
|
206
|
+
|
|
207
|
+
crumbs = parse_json(raw)
|
|
208
|
+
return nil unless crumbs.is_a?(Array) && crumbs.any?
|
|
209
|
+
|
|
210
|
+
summary = LlmSummary.call(crumbs)
|
|
211
|
+
return nil unless summary
|
|
212
|
+
|
|
213
|
+
llm_crumbs = crumbs.select { |c| c.is_a?(Hash) && (c["c"] == "llm" || c["c"] == "llm_tool") }
|
|
214
|
+
return nil if llm_crumbs.empty?
|
|
215
|
+
|
|
216
|
+
# One-line totals: tokens / cost / duration / errors
|
|
217
|
+
totals_parts = []
|
|
218
|
+
totals_parts << "#{summary[:total_calls]} call#{summary[:total_calls] == 1 ? "" : "s"}"
|
|
219
|
+
totals_parts << "#{summary[:total_tool_calls]} tool call#{summary[:total_tool_calls] == 1 ? "" : "s"}" if summary[:total_tool_calls] > 0
|
|
220
|
+
totals_parts << "#{summary[:total_tokens]} tokens (in:#{summary[:total_input_tokens]}/out:#{summary[:total_output_tokens]})" if summary[:total_tokens] > 0
|
|
221
|
+
totals_parts << "$#{format("%.6f", summary[:total_cost_usd])}" if summary[:total_cost_usd] > 0
|
|
222
|
+
totals_parts << "#{summary[:total_duration_ms]}ms total"
|
|
223
|
+
totals_parts << "**#{summary[:error_count]} error#{summary[:error_count] == 1 ? "" : "s"}**" if summary[:error_count] > 0
|
|
224
|
+
|
|
225
|
+
out = [ "## LLM Calls", "", totals_parts.join(" · ") ]
|
|
226
|
+
|
|
227
|
+
if summary[:by_model].any?
|
|
228
|
+
out << ""
|
|
229
|
+
out << "**By Model**"
|
|
230
|
+
out << ""
|
|
231
|
+
out << "| Provider | Model | Calls | Tokens | Cost |"
|
|
232
|
+
out << "|----------|-------|-------|--------|------|"
|
|
233
|
+
summary[:by_model].each do |row|
|
|
234
|
+
cost_cell = row[:cost_usd] > 0 ? "$#{format("%.6f", row[:cost_usd])}" : "—"
|
|
235
|
+
out << "| #{row[:provider].presence || "—"} | #{row[:model].presence || "—"} | #{row[:calls]} | #{row[:tokens]} | #{cost_cell} |"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Per-call detail table — last N events (chat + tool, in order)
|
|
240
|
+
recent = llm_crumbs.last(MAX_LLM_CALL_ROWS)
|
|
241
|
+
out << ""
|
|
242
|
+
out << "**Calls (last #{recent.size})**"
|
|
243
|
+
out << ""
|
|
244
|
+
out << "| Time | Type | Provider/Model | Status | Tokens | Cost | Duration |"
|
|
245
|
+
out << "|------|------|----------------|--------|--------|------|----------|"
|
|
246
|
+
recent.each do |c|
|
|
247
|
+
meta = c["meta"].is_a?(Hash) ? c["meta"] : {}
|
|
248
|
+
time = c["t"] ? Time.at(c["t"] / 1000.0).utc.strftime("%H:%M:%S.%L") : "—"
|
|
249
|
+
type = c["c"] == "llm_tool" ? "tool" : "chat"
|
|
250
|
+
provider_model = if c["c"] == "llm_tool"
|
|
251
|
+
"tool: #{truncate_value(meta["tool_name"], 40)}"
|
|
252
|
+
else
|
|
253
|
+
[ meta["provider"].presence, meta["model"].presence ].compact.join("/").presence || "—"
|
|
254
|
+
end
|
|
255
|
+
status = meta["status"].presence || "—"
|
|
256
|
+
tokens = if meta["input_tokens"].present? || meta["output_tokens"].present?
|
|
257
|
+
"#{meta["input_tokens"] || "?"}/#{meta["output_tokens"] || "?"}"
|
|
258
|
+
else
|
|
259
|
+
"—"
|
|
260
|
+
end
|
|
261
|
+
cost = meta["cost_usd"].present? && meta["cost_usd"].to_f > 0 ? "$#{format("%.6f", meta["cost_usd"].to_f)}" : "—"
|
|
262
|
+
duration = c["d"] ? "#{c["d"]}ms" : "—"
|
|
263
|
+
out << "| #{time} | #{type} | #{truncate_value(provider_model, 40)} | #{status} | #{tokens} | #{cost} | #{duration} |"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Surface error messages so the LLM consumer sees them inline rather than
|
|
267
|
+
# having to cross-reference the breadcrumb stream below.
|
|
268
|
+
error_lines = llm_crumbs.select { |c|
|
|
269
|
+
c["c"] == "llm" && c["meta"].is_a?(Hash) && c["meta"]["status"] && c["meta"]["status"] != "success"
|
|
270
|
+
}
|
|
271
|
+
if error_lines.any?
|
|
272
|
+
out << ""
|
|
273
|
+
out << "**Failures**"
|
|
274
|
+
out << ""
|
|
275
|
+
error_lines.each do |c|
|
|
276
|
+
meta = c["meta"]
|
|
277
|
+
label = [ meta["provider"], meta["model"] ].compact.join("/")
|
|
278
|
+
err_class = meta["error_class"].presence
|
|
279
|
+
err_msg = meta["error_message"].presence
|
|
280
|
+
detail = [ err_class, err_msg ].compact.join(": ")
|
|
281
|
+
out << "- `#{label}` — #{meta["status"]}#{detail.empty? ? "" : ": #{detail}"}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
out.join("\n")
|
|
286
|
+
end
|
|
287
|
+
|
|
201
288
|
def breadcrumbs_section
|
|
202
289
|
raw = @error.breadcrumbs
|
|
203
290
|
return nil if raw.blank?
|