ruby_llm-monitoring 0.1.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 +7 -0
- data/README.md +179 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/ruby_llm/monitoring/application.css +15 -0
- data/app/assets/stylesheets/ruby_llm/monitoring/bulma.min.css +3 -0
- data/app/controllers/ruby_llm/monitoring/alerts_controller.rb +7 -0
- data/app/controllers/ruby_llm/monitoring/application_controller.rb +12 -0
- data/app/controllers/ruby_llm/monitoring/metrics_controller.rb +91 -0
- data/app/helpers/ruby_llm/monitoring/alerts_helper.rb +4 -0
- data/app/helpers/ruby_llm/monitoring/application_helper.rb +6 -0
- data/app/helpers/ruby_llm/monitoring/metrics_helper.rb +7 -0
- data/app/javascript/ruby_llm/monitoring/application.js +5 -0
- data/app/javascript/ruby_llm/monitoring/controllers/application.js +11 -0
- data/app/javascript/ruby_llm/monitoring/controllers/chart_controller.js +93 -0
- data/app/javascript/ruby_llm/monitoring/controllers/index.js +4 -0
- data/app/jobs/ruby_llm/monitoring/application_job.rb +6 -0
- data/app/mailers/ruby_llm/monitoring/alert_mailer.rb +13 -0
- data/app/mailers/ruby_llm/monitoring/application_mailer.rb +8 -0
- data/app/models/concerns/ruby_llm/monitoring/alertable.rb +53 -0
- data/app/models/ruby_llm/monitoring/application_record.rb +7 -0
- data/app/models/ruby_llm/monitoring/event.rb +22 -0
- data/app/views/layouts/ruby_llm/monitoring/application.html.erb +29 -0
- data/app/views/layouts/ruby_llm/monitoring/mailer.html.erb +13 -0
- data/app/views/layouts/ruby_llm/monitoring/mailer.text.erb +1 -0
- data/app/views/ruby_llm/monitoring/alert_mailer/alert_notification.html.erb +1 -0
- data/app/views/ruby_llm/monitoring/alert_mailer/alert_notification.text.erb +1 -0
- data/app/views/ruby_llm/monitoring/alerts/_alert.html.erb +2 -0
- data/app/views/ruby_llm/monitoring/alerts/index.html.erb +26 -0
- data/app/views/ruby_llm/monitoring/application/_flashes.html.erb +9 -0
- data/app/views/ruby_llm/monitoring/application/_nav.html.erb +12 -0
- data/app/views/ruby_llm/monitoring/application/_pagination.html.erb +7 -0
- data/app/views/ruby_llm/monitoring/application/_tabs.html.erb +6 -0
- data/app/views/ruby_llm/monitoring/metrics/_filters.html.erb +25 -0
- data/app/views/ruby_llm/monitoring/metrics/_totals.html.erb +51 -0
- data/app/views/ruby_llm/monitoring/metrics/index.html.erb +16 -0
- data/config/importmap.rb +8 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20251208171258_create_ruby_llm_monitoring_events.rb +70 -0
- data/lib/ruby_llm/monitoring/channel_registry.rb +19 -0
- data/lib/ruby_llm/monitoring/channels/base.rb +11 -0
- data/lib/ruby_llm/monitoring/channels/email.rb +18 -0
- data/lib/ruby_llm/monitoring/channels/slack.rb +18 -0
- data/lib/ruby_llm/monitoring/engine.rb +61 -0
- data/lib/ruby_llm/monitoring/event_subscriber.rb +20 -0
- data/lib/ruby_llm/monitoring/version.rb +5 -0
- data/lib/ruby_llm/monitoring.rb +24 -0
- data/lib/tasks/ruby_llm/monitoring_tasks.rake +4 -0
- metadata +185 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module RubyLLM::Monitoring
|
|
2
|
+
class MetricsController < ApplicationController
|
|
3
|
+
before_action :set_resolution
|
|
4
|
+
before_action :set_time_range
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@events = Event.group(:provider, :model)
|
|
8
|
+
.group_by_minute(:created_at, range: @time_range, n: @resolution.in_minutes.to_i)
|
|
9
|
+
|
|
10
|
+
error_count_by_time = @events.where.not(exception_class: nil).size
|
|
11
|
+
|
|
12
|
+
@metrics = [
|
|
13
|
+
build_metric_series(title: "Throughput", data: aggregate_metric(@events.count)),
|
|
14
|
+
build_metric_series(title: "Cost", data: aggregate_metric(@events.sum(:cost)), unit: "money"),
|
|
15
|
+
build_metric_series(
|
|
16
|
+
title: "Response time",
|
|
17
|
+
data: aggregate_metric(@events.average(:duration), default_value: 0),
|
|
18
|
+
unit: "ms"
|
|
19
|
+
),
|
|
20
|
+
build_metric_series(
|
|
21
|
+
title: "Errors",
|
|
22
|
+
data: aggregate_metric(error_count_by_time, default_value: 0),
|
|
23
|
+
unit: "number"
|
|
24
|
+
)
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
@totals_by_provider = Event.where(created_at: @time_range)
|
|
28
|
+
.group(:provider, :model)
|
|
29
|
+
.select(
|
|
30
|
+
:provider,
|
|
31
|
+
:model,
|
|
32
|
+
"COUNT(*) as requests",
|
|
33
|
+
"SUM(cost) as cost",
|
|
34
|
+
"AVG(duration) as avg_response_time"
|
|
35
|
+
).to_a
|
|
36
|
+
|
|
37
|
+
total_requests = @totals_by_provider.sum(&:requests)
|
|
38
|
+
error_count = error_count_by_time.values.sum
|
|
39
|
+
|
|
40
|
+
@totals = {
|
|
41
|
+
requests: total_requests,
|
|
42
|
+
cost: @totals_by_provider.sum { |r| r.cost.to_f },
|
|
43
|
+
avg_response_time: @totals_by_provider.any? ? @totals_by_provider.sum do |r|
|
|
44
|
+
r.avg_response_time.to_f * r.requests
|
|
45
|
+
end / total_requests : nil,
|
|
46
|
+
error_rate: total_requests.positive? ? (error_count.to_f / total_requests * 100).round(1) : 0
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def aggregate_metric(aggregated_data, default_value: nil)
|
|
53
|
+
aggregated_data
|
|
54
|
+
.group_by { |(provider, model, _), _| [ provider, model ] }
|
|
55
|
+
.transform_values do |entries|
|
|
56
|
+
entries.map do |(_, _, timestamp), value|
|
|
57
|
+
[ timestamp.to_i * 1000, value || default_value ]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_metric_series(title:, data:, unit: nil)
|
|
63
|
+
{
|
|
64
|
+
title: title,
|
|
65
|
+
unit: unit,
|
|
66
|
+
series: data.map { |k, v| { name: k.join("/"), data: v } }
|
|
67
|
+
}.compact
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def filter_param
|
|
71
|
+
{
|
|
72
|
+
filter: {
|
|
73
|
+
created_at_start: @created_at_start,
|
|
74
|
+
created_at_end: @created_at_end,
|
|
75
|
+
resolution: @resolution
|
|
76
|
+
}
|
|
77
|
+
}.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def set_resolution
|
|
81
|
+
@resolution = params.dig(:filter, :resolution).try(:to_i).try(:minutes) || 1.minute
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set_time_range
|
|
85
|
+
@created_at_start = params.dig(:filter, :created_at_start).try(:in_time_zone) || 2.hours.ago
|
|
86
|
+
@created_at_end = params.dig(:filter, :created_at_end).try(:in_time_zone)
|
|
87
|
+
|
|
88
|
+
@time_range = @created_at_start..@created_at_end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Application } from "@hotwired/stimulus"
|
|
2
|
+
import AutoSubmit from "@stimulus-components/auto-submit"
|
|
3
|
+
|
|
4
|
+
const application = Application.start()
|
|
5
|
+
application.register("auto-submit", AutoSubmit)
|
|
6
|
+
|
|
7
|
+
// Configure Stimulus development experience
|
|
8
|
+
application.debug = false
|
|
9
|
+
window.Stimulus = application
|
|
10
|
+
|
|
11
|
+
export { application }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
initialize() {
|
|
5
|
+
this.title = this.element.dataset.title
|
|
6
|
+
this.min = parseInt(this.element.dataset.min)
|
|
7
|
+
this.series = JSON.parse(this.element.dataset.series)
|
|
8
|
+
this.formatter = this.element.dataset.formatter
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.chart = new ApexCharts(this.element, {
|
|
13
|
+
chart: {
|
|
14
|
+
id: this.title,
|
|
15
|
+
group: "metrics",
|
|
16
|
+
height: "200px",
|
|
17
|
+
type: "area",
|
|
18
|
+
toolbar: {
|
|
19
|
+
tools: {
|
|
20
|
+
download: false
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
events: {
|
|
24
|
+
rendered: () => this.syncAll()
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
colors: ["#3b82f6", "#4ade80", "#facc15", "#dc2626", "#7c3aed"],
|
|
28
|
+
dataLabels: {
|
|
29
|
+
enabled: false
|
|
30
|
+
},
|
|
31
|
+
onDatasetHover: {
|
|
32
|
+
highlightDataSeries: true,
|
|
33
|
+
},
|
|
34
|
+
series: this.series,
|
|
35
|
+
stroke: {
|
|
36
|
+
width: 1
|
|
37
|
+
},
|
|
38
|
+
title: {
|
|
39
|
+
text: this.title
|
|
40
|
+
},
|
|
41
|
+
tooltip: {
|
|
42
|
+
shared: true,
|
|
43
|
+
x: {
|
|
44
|
+
format: "dd/MM/yyyy HH:mm:ss"
|
|
45
|
+
},
|
|
46
|
+
y: {
|
|
47
|
+
formatter: this.formatterFunction()
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
xaxis: {
|
|
51
|
+
type: "datetime",
|
|
52
|
+
min: this.min
|
|
53
|
+
},
|
|
54
|
+
yaxis: {
|
|
55
|
+
labels: { show: false }
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
this.chart.render()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
syncAll() {
|
|
63
|
+
if (typeof ApexCharts !== "undefined") {
|
|
64
|
+
ApexCharts.exec("metrics", "syncAll")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
formatterFunction() {
|
|
69
|
+
let f = function(value) {
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (this.formatter === "percentage") {
|
|
74
|
+
f = function(value) {
|
|
75
|
+
return `${value || 0}%`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.formatter === "ms") {
|
|
80
|
+
f = function(value) {
|
|
81
|
+
return `${value.toFixed(2)} ms`
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.formatter === "money") {
|
|
86
|
+
f = function(value) {
|
|
87
|
+
return `$${value}`
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return f
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module RubyLLM
|
|
2
|
+
module Monitoring
|
|
3
|
+
module Alertable
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
after_create_commit :evaluate_alert_rules
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cooldown(rule_id, custom_cooldown)
|
|
13
|
+
ttl = custom_cooldown || RubyLLM::Monitoring.alert_cooldown
|
|
14
|
+
|
|
15
|
+
Rails.cache.write("ruby_llm-monitoring/#{rule_id}", Time.current + ttl, expires_in: ttl)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def cooling_down?(rule_id)
|
|
19
|
+
Rails.cache.exist?("ruby_llm-monitoring/#{rule_id}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def dispatch_to_channels(alert_rule)
|
|
23
|
+
alert_rule[:channels].each do |channel_name|
|
|
24
|
+
channel = RubyLLM::Monitoring.channel_registry.fetch(channel_name)
|
|
25
|
+
channel_config = RubyLLM::Monitoring.channels[channel_name] || {}
|
|
26
|
+
channel.deliver(alert_rule[:message], channel_config)
|
|
27
|
+
rescue => e
|
|
28
|
+
Rails.logger.error "[RubyLLM::Monitoring] Failed to deliver alert to #{channel_name}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def evaluate_alert_rules
|
|
33
|
+
RubyLLM::Monitoring.alert_rules.each_with_index do |alert_rule, index|
|
|
34
|
+
rule_id = "rule_#{index}"
|
|
35
|
+
|
|
36
|
+
next unless valid_alert_rule?(alert_rule)
|
|
37
|
+
next if cooling_down?(rule_id)
|
|
38
|
+
|
|
39
|
+
events = self.class.where(created_at: alert_rule[:time_range].call)
|
|
40
|
+
|
|
41
|
+
if alert_rule[:rule].call(events)
|
|
42
|
+
cooldown(rule_id, alert_rule[:cooldown])
|
|
43
|
+
dispatch_to_channels(alert_rule)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid_alert_rule?(alert_rule)
|
|
49
|
+
alert_rule[:time_range].respond_to?(:call) && alert_rule[:rule].respond_to?(:call) && alert_rule[:channels].is_a?(Array) && alert_rule[:message].is_a?(Hash)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module RubyLLM::Monitoring
|
|
2
|
+
class Event < ApplicationRecord
|
|
3
|
+
include Alertable
|
|
4
|
+
|
|
5
|
+
before_validation :set_cost
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def set_cost
|
|
10
|
+
model, provider = RubyLLM.models.resolve payload["model"], provider: payload["provider"]
|
|
11
|
+
|
|
12
|
+
self.cost = if provider.local? || [ payload["input_tokens"], payload["output_tokens"] ].all?(nil)
|
|
13
|
+
0.0
|
|
14
|
+
else
|
|
15
|
+
input_cost = payload["input_tokens"] / 1_000_000.0 * model.input_price_per_million
|
|
16
|
+
output_cost = payload["output_tokens"] / 1_000_000.0 * model.output_price_per_million
|
|
17
|
+
|
|
18
|
+
input_cost + output_cost
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>RubyLLM Monitoring - <%= content_for :title %></title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
9
|
+
<meta name="turbo-cache-control" content="no-cache">
|
|
10
|
+
|
|
11
|
+
<%= yield :head %>
|
|
12
|
+
|
|
13
|
+
<%= stylesheet_link_tag "ruby_llm/monitoring/bulma.min" %>
|
|
14
|
+
<%= stylesheet_link_tag "ruby_llm/monitoring/application", media: "all" %>
|
|
15
|
+
<%= javascript_importmap_tags importmap: RubyLLM::Monitoring.importmap %>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
|
|
19
|
+
<section class="section">
|
|
20
|
+
<div class="container">
|
|
21
|
+
<%= render "ruby_llm/monitoring/application/flashes" %>
|
|
22
|
+
<%= render "ruby_llm/monitoring/application/nav" %>
|
|
23
|
+
<%= render "ruby_llm/monitoring/application/tabs" %>
|
|
24
|
+
<%= yield %>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= yield %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<p><%= @body %></p>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= @body %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<% content_for :title, "Alerts" %>
|
|
2
|
+
|
|
3
|
+
<% if @alert_rules.any? %>
|
|
4
|
+
<table class="table is-hoverable is-fullwidth">
|
|
5
|
+
<thead>
|
|
6
|
+
<tr>
|
|
7
|
+
<th>Time range</th>
|
|
8
|
+
<th>Channels</th>
|
|
9
|
+
<th>Message</th>
|
|
10
|
+
<th>Status</th>
|
|
11
|
+
</tr>
|
|
12
|
+
</thead>
|
|
13
|
+
<tbody>
|
|
14
|
+
<% @alert_rules.each do |alert_rule| %>
|
|
15
|
+
<tr>
|
|
16
|
+
<td><%= time_range = alert_rule[:time_range].call %></td>
|
|
17
|
+
<td><%= alert_rule[:channels].to_sentence %></td>
|
|
18
|
+
<td><%= alert_rule[:message][:text] %></td>
|
|
19
|
+
<td><%= alert_rule[:rule].call(RubyLLM::Monitoring::Event.where(created_at: time_range)) ? "🟢" : "🔴" %></td>
|
|
20
|
+
</tr>
|
|
21
|
+
<% end %>
|
|
22
|
+
</tbody>
|
|
23
|
+
</table>
|
|
24
|
+
<% else %>
|
|
25
|
+
<div class="mt-6 has-text-centered is-size-4 has-text-grey">There are no alert rules</div>
|
|
26
|
+
<% end %>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<nav class="navbar" role="navigation" aria-label="main navigation">
|
|
2
|
+
<div class="navbar-menu is-active mb-4">
|
|
3
|
+
<div class="navbar-start">
|
|
4
|
+
<% if defined?(main_app.root_path) %>
|
|
5
|
+
<%= link_to "Back to main app", main_app.root_path %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="navbar-end">
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
</nav>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<nav class="buttons is-right" role="navigation" aria-label="pagination">
|
|
2
|
+
<span class="mr-3"><%= page.index %> / <%= page.pages_count || "..." %></span>
|
|
3
|
+
<%= link_to "↞", url_for(page: 1), class: "pagination-previous", disabled: page.first? %>
|
|
4
|
+
<%= link_to "Previous page", url_for(page: page.previous_index), class: "pagination-previous", disabled: page.first? %>
|
|
5
|
+
<%= link_to "Next page", url_for(page: page.next_index), class: "pagination-next", disabled: page.last? %>
|
|
6
|
+
<%= link_to "↠", url_for(page: page.pages_count), class: "pagination-next", disabled: page.last? %>
|
|
7
|
+
</nav>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<div class="level">
|
|
2
|
+
<div class="level-left">
|
|
3
|
+
<%= form_with scope: :filter, url: metrics_path, method: :get, data: {controller: "auto-submit"} do |form| %>
|
|
4
|
+
<div class="field is-grouped">
|
|
5
|
+
<div>
|
|
6
|
+
<%= form.label :created_at_start, class: "label" %>
|
|
7
|
+
<%= form.datetime_field :created_at_start, value: filter_param.dig(:filter, :created_at_start), class: "input", data: {action: "input->auto-submit#submit"} %>
|
|
8
|
+
</div>
|
|
9
|
+
<div>
|
|
10
|
+
<%= form.label :created_at_end, class: "label" %>
|
|
11
|
+
<%= form.datetime_field :created_at_end, value: filter_param.dig(:filter, :created_at_end), class: "input", data: {action: "input->auto-submit#submit"} %>
|
|
12
|
+
</div>
|
|
13
|
+
<div>
|
|
14
|
+
<%= form.label :resolution, class: "label" %>
|
|
15
|
+
<div class="select">
|
|
16
|
+
<%= form.select :resolution, resolution_options, {selected: filter_param.dig(:filter, :resolution).in_minutes.to_i}, data: {action: "auto-submit#submit"} %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="is-align-self-flex-end">
|
|
20
|
+
<%= link_to "Clear", clear_path, class: "button" %>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<div class="columns is-multiline mb-5">
|
|
2
|
+
<div class="column is-3">
|
|
3
|
+
<div class="box has-text-centered">
|
|
4
|
+
<p class="heading">Requests</p>
|
|
5
|
+
<p class="title"><%= number_with_delimiter(@totals[:requests]) %></p>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="column is-3">
|
|
9
|
+
<div class="box has-text-centered">
|
|
10
|
+
<p class="heading">Cost</p>
|
|
11
|
+
<p class="title"><%= number_to_currency(@totals[:cost]) %></p>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="column is-3">
|
|
15
|
+
<div class="box has-text-centered">
|
|
16
|
+
<p class="heading">Avg Response Time</p>
|
|
17
|
+
<p class="title"><%= @totals[:avg_response_time] ? "#{@totals[:avg_response_time].round(1)}ms" : "N/A" %></p>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="column is-3">
|
|
21
|
+
<div class="box has-text-centered">
|
|
22
|
+
<p class="heading">Error Rate</p>
|
|
23
|
+
<p class="title"><%= @totals[:error_rate] %>%</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<% if @totals_by_provider.any? %>
|
|
29
|
+
<div class="box mb-5">
|
|
30
|
+
<table class="table is-fullwidth is-striped">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr>
|
|
33
|
+
<th>Provider/Model</th>
|
|
34
|
+
<th class="has-text-right">Requests</th>
|
|
35
|
+
<th class="has-text-right">Cost</th>
|
|
36
|
+
<th class="has-text-right">Avg Response Time</th>
|
|
37
|
+
</tr>
|
|
38
|
+
</thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
<% @totals_by_provider.each do |row| %>
|
|
41
|
+
<tr>
|
|
42
|
+
<td><%= row.provider %>/<%= row.model %></td>
|
|
43
|
+
<td class="has-text-right"><%= number_with_delimiter(row.requests) %></td>
|
|
44
|
+
<td class="has-text-right"><%= number_to_currency(row.cost || 0) %></td>
|
|
45
|
+
<td class="has-text-right"><%= row.avg_response_time ? "#{row.avg_response_time.round(1)}ms" : "N/A" %></td>
|
|
46
|
+
</tr>
|
|
47
|
+
<% end %>
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<% content_for :title, "Metrics" %>
|
|
2
|
+
|
|
3
|
+
<%= render "filters", clear_path: metrics_path %>
|
|
4
|
+
|
|
5
|
+
<%= render "totals" %>
|
|
6
|
+
|
|
7
|
+
<% @metrics.each do |metric| %>
|
|
8
|
+
<div
|
|
9
|
+
data-controller="chart"
|
|
10
|
+
data-title="<%= metric[:title] %>"
|
|
11
|
+
data-min="<%= @created_at_start.to_i.in_milliseconds %>"
|
|
12
|
+
data-formatter="<%= metric[:unit] %>"
|
|
13
|
+
data-series="<%= metric[:series].to_json %>"
|
|
14
|
+
>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
|
|
2
|
+
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
|
|
3
|
+
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
|
|
4
|
+
pin "@stimulus-components/auto-submit", to: "https://ga.jspm.io/npm:@stimulus-components/auto-submit@6.0.0/dist/stimulus-auto-submit.mjs"
|
|
5
|
+
pin "apexcharts", to: "https://ga.jspm.io/npm:apexcharts@4.3.0/dist/apexcharts.common.js"
|
|
6
|
+
|
|
7
|
+
pin "application", to: "ruby_llm/monitoring/application.js", preload: true
|
|
8
|
+
pin_all_from RubyLLM::Monitoring::Engine.root.join("app/javascript/ruby_llm/monitoring/controllers"), under: "controllers", to: "ruby_llm/monitoring/controllers"
|