llm_logs 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 +148 -0
- data/Rakefile +11 -0
- data/app/controllers/llm_logs/application_controller.rb +5 -0
- data/app/controllers/llm_logs/prompt_versions_controller.rb +66 -0
- data/app/controllers/llm_logs/prompts_controller.rb +91 -0
- data/app/controllers/llm_logs/spans_controller.rb +8 -0
- data/app/controllers/llm_logs/traces_controller.rb +19 -0
- data/app/helpers/llm_logs/formatting_helper.rb +34 -0
- data/app/models/llm_logs/application_record.rb +5 -0
- data/app/models/llm_logs/prompt.rb +54 -0
- data/app/models/llm_logs/prompt_version.rb +31 -0
- data/app/models/llm_logs/span.rb +38 -0
- data/app/models/llm_logs/trace.rb +46 -0
- data/app/views/kaminari/tailwind/_first_page.html.erb +5 -0
- data/app/views/kaminari/tailwind/_gap.html.erb +3 -0
- data/app/views/kaminari/tailwind/_last_page.html.erb +5 -0
- data/app/views/kaminari/tailwind/_next_page.html.erb +5 -0
- data/app/views/kaminari/tailwind/_page.html.erb +9 -0
- data/app/views/kaminari/tailwind/_paginator.html.erb +17 -0
- data/app/views/kaminari/tailwind/_prev_page.html.erb +5 -0
- data/app/views/layouts/llm_logs/application.html.erb +52 -0
- data/app/views/llm_logs/prompt_versions/compare.html.erb +41 -0
- data/app/views/llm_logs/prompt_versions/index.html.erb +92 -0
- data/app/views/llm_logs/prompt_versions/show.html.erb +48 -0
- data/app/views/llm_logs/prompts/_form.html.erb +94 -0
- data/app/views/llm_logs/prompts/edit.html.erb +6 -0
- data/app/views/llm_logs/prompts/index.html.erb +43 -0
- data/app/views/llm_logs/prompts/new.html.erb +5 -0
- data/app/views/llm_logs/prompts/show.html.erb +107 -0
- data/app/views/llm_logs/spans/show.html.erb +99 -0
- data/app/views/llm_logs/traces/_span_node.html.erb +66 -0
- data/app/views/llm_logs/traces/index.html.erb +78 -0
- data/app/views/llm_logs/traces/show.html.erb +79 -0
- data/config/routes.rb +18 -0
- data/db/migrate/001_create_llm_logs_traces.rb +22 -0
- data/db/migrate/002_create_llm_logs_spans.rb +31 -0
- data/db/migrate/003_create_llm_logs_prompts.rb +13 -0
- data/db/migrate/004_create_llm_logs_prompt_versions.rb +18 -0
- data/db/migrate/005_add_prompt_version_to_traces.rb +7 -0
- data/lib/generators/llm_logs/install_generator.rb +17 -0
- data/lib/generators/llm_logs/templates/initializer.rb +10 -0
- data/lib/llm_logs/engine.rb +14 -0
- data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +107 -0
- data/lib/llm_logs/prompt_renderer.rb +17 -0
- data/lib/llm_logs/tracer.rb +67 -0
- data/lib/llm_logs/version.rb +3 -0
- data/lib/llm_logs.rb +23 -0
- metadata +145 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<div class="<%= 'ml-6' if depth > 0 %> mb-2">
|
|
2
|
+
<details class="group">
|
|
3
|
+
<summary class="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100 cursor-pointer">
|
|
4
|
+
<div class="flex items-center space-x-3">
|
|
5
|
+
<% type_colors = { "llm" => "bg-purple-100 text-purple-800", "tool" => "bg-amber-100 text-amber-800", "custom" => "bg-gray-100 text-gray-800" } %>
|
|
6
|
+
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium <%= type_colors[span.span_type] %>">
|
|
7
|
+
<%= span.span_type %>
|
|
8
|
+
</span>
|
|
9
|
+
<span class="text-sm font-medium text-gray-900"><%= span.name %></span>
|
|
10
|
+
<% if span.model.present? %>
|
|
11
|
+
<span class="text-xs text-gray-500"><%= span.model %></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="flex items-center space-x-4 text-xs text-gray-500">
|
|
15
|
+
<% if span.input_tokens || span.output_tokens %>
|
|
16
|
+
<span>
|
|
17
|
+
<%= span.input_tokens || 0 %> → <%= span.output_tokens || 0 %> tokens
|
|
18
|
+
<% if (span.cached_tokens || 0) > 0 %>
|
|
19
|
+
<span class="text-green-600">(<%= span.cached_tokens %> cached)</span>
|
|
20
|
+
<% end %>
|
|
21
|
+
</span>
|
|
22
|
+
<% end %>
|
|
23
|
+
<span><%= format_duration_ms(span.duration_ms) %></span>
|
|
24
|
+
<% if span.status == "error" %>
|
|
25
|
+
<span class="text-red-600 font-medium">ERROR</span>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
</summary>
|
|
29
|
+
|
|
30
|
+
<div class="mt-2 ml-4 space-y-3">
|
|
31
|
+
<% if span.input.present? %>
|
|
32
|
+
<div>
|
|
33
|
+
<h4 class="text-xs font-medium text-gray-500 uppercase mb-1">Input</h4>
|
|
34
|
+
<pre class="text-xs bg-gray-50 rounded p-3 max-h-64 overflow-y-auto border"><%= pretty_json(span.input) %></pre>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<% if span.output.present? %>
|
|
39
|
+
<div>
|
|
40
|
+
<h4 class="text-xs font-medium text-gray-500 uppercase mb-1">Output</h4>
|
|
41
|
+
<pre class="text-xs bg-gray-50 rounded p-3 max-h-64 overflow-y-auto border"><%= pretty_json(span.output) %></pre>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<% if span.error_message.present? %>
|
|
46
|
+
<div>
|
|
47
|
+
<h4 class="text-xs font-medium text-red-600 uppercase mb-1">Error</h4>
|
|
48
|
+
<pre class="text-xs bg-red-50 rounded p-3 border border-red-200 text-red-800"><%= span.error_message %></pre>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<% if span.metadata.present? && span.metadata.any? %>
|
|
53
|
+
<div>
|
|
54
|
+
<h4 class="text-xs font-medium text-gray-500 uppercase mb-1">Metadata</h4>
|
|
55
|
+
<pre class="text-xs bg-gray-50 rounded p-3 border"><%= JSON.pretty_generate(span.metadata) %></pre>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
|
|
59
|
+
<%= link_to "View details", trace_span_path(trace, span), class: "text-xs text-indigo-600 hover:text-indigo-900" %>
|
|
60
|
+
</div>
|
|
61
|
+
</details>
|
|
62
|
+
|
|
63
|
+
<% span.child_spans.order(:started_at).each do |child| %>
|
|
64
|
+
<%= render partial: "llm_logs/traces/span_node", locals: { span: child, depth: depth + 1, trace: trace } %>
|
|
65
|
+
<% end %>
|
|
66
|
+
</div>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<div class="flex items-center justify-between mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold text-gray-900">Traces</h1>
|
|
3
|
+
<% if @filter_version %>
|
|
4
|
+
<p class="text-sm text-gray-500">
|
|
5
|
+
Filtering by prompt: <span class="font-medium"><%= @filter_version.prompt.name %> v<%= @filter_version.version_number %></span>
|
|
6
|
+
· <%= link_to "Clear filter", traces_path, class: "text-indigo-600 hover:text-indigo-900" %>
|
|
7
|
+
</p>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<div class="flex items-center space-x-3">
|
|
11
|
+
<%= form_tag traces_path, method: :get, class: "flex items-center space-x-2" do %>
|
|
12
|
+
<% if params[:prompt_version_id].present? %>
|
|
13
|
+
<input type="hidden" name="prompt_version_id" value="<%= params[:prompt_version_id] %>">
|
|
14
|
+
<% end %>
|
|
15
|
+
<select name="status" class="rounded-md border-gray-300 text-sm py-1.5 px-3 bg-white border shadow-sm">
|
|
16
|
+
<option value="">All statuses</option>
|
|
17
|
+
<% %w[running completed error].each do |status| %>
|
|
18
|
+
<option value="<%= status %>" <%= 'selected' if params[:status] == status %>><%= status %></option>
|
|
19
|
+
<% end %>
|
|
20
|
+
</select>
|
|
21
|
+
|
|
22
|
+
<button type="submit" class="bg-gray-900 text-white px-3 py-1.5 rounded-md text-sm hover:bg-gray-700">Filter</button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-lg overflow-hidden">
|
|
28
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
29
|
+
<thead class="bg-gray-50">
|
|
30
|
+
<tr>
|
|
31
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
|
32
|
+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Spans</th>
|
|
33
|
+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Tokens</th>
|
|
34
|
+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Cost</th>
|
|
35
|
+
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Duration</th>
|
|
36
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
|
37
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Started</th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody class="divide-y divide-gray-200">
|
|
41
|
+
<% @traces.each do |trace| %>
|
|
42
|
+
<tr class="hover:bg-gray-50">
|
|
43
|
+
<td class="px-4 py-3 text-sm">
|
|
44
|
+
<%= link_to trace.name, trace_path(trace), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
|
|
45
|
+
</td>
|
|
46
|
+
<td class="px-4 py-3 text-sm text-gray-500 text-right"><%= trace.spans_count %></td>
|
|
47
|
+
<td class="px-4 py-3 text-sm text-gray-500 text-right">
|
|
48
|
+
<%= number_with_delimiter(trace.total_input_tokens) %> → <%= number_with_delimiter(trace.total_output_tokens) %>
|
|
49
|
+
<% if trace.total_cached_tokens > 0 %>
|
|
50
|
+
<br><span class="text-xs text-green-600"><%= number_with_delimiter(trace.total_cached_tokens) %> cached</span>
|
|
51
|
+
<% end %>
|
|
52
|
+
</td>
|
|
53
|
+
<td class="px-4 py-3 text-sm text-gray-500 text-right">
|
|
54
|
+
<% if trace.total_cost > 0 %>$<%= sprintf('%.4f', trace.total_cost) %><% end %>
|
|
55
|
+
</td>
|
|
56
|
+
<td class="px-4 py-3 text-sm text-gray-500 text-right">
|
|
57
|
+
<%= format_duration_ms(trace.duration_ms) %>
|
|
58
|
+
</td>
|
|
59
|
+
<td class="px-4 py-3 text-sm">
|
|
60
|
+
<% status_colors = { "running" => "bg-blue-100 text-blue-800", "completed" => "bg-green-100 text-green-800", "error" => "bg-red-100 text-red-800" } %>
|
|
61
|
+
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <%= status_colors[trace.status] %>">
|
|
62
|
+
<%= trace.status %>
|
|
63
|
+
</span>
|
|
64
|
+
</td>
|
|
65
|
+
<td class="px-4 py-3 text-sm text-gray-500"><%= trace.started_at.strftime('%b %d %H:%M:%S') %></td>
|
|
66
|
+
</tr>
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
69
|
+
<% if @traces.empty? %>
|
|
70
|
+
<tr>
|
|
71
|
+
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">No traces found.</td>
|
|
72
|
+
</tr>
|
|
73
|
+
<% end %>
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<%= paginate @traces, theme: "tailwind" %>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<div class="mb-6">
|
|
2
|
+
<div class="flex items-center justify-between">
|
|
3
|
+
<div>
|
|
4
|
+
<div class="flex items-center space-x-3">
|
|
5
|
+
<h1 class="text-2xl font-bold text-gray-900"><%= @trace.name %></h1>
|
|
6
|
+
<% status_colors = { "running" => "bg-blue-100 text-blue-800", "completed" => "bg-green-100 text-green-800", "error" => "bg-red-100 text-red-800" } %>
|
|
7
|
+
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium <%= status_colors[@trace.status] %>">
|
|
8
|
+
<%= @trace.status %>
|
|
9
|
+
</span>
|
|
10
|
+
</div>
|
|
11
|
+
<p class="text-sm text-gray-500 mt-1">
|
|
12
|
+
<%= @trace.started_at.strftime('%b %d, %Y %H:%M:%S') %>
|
|
13
|
+
</p>
|
|
14
|
+
<% if @trace.prompt_version.present? %>
|
|
15
|
+
<p class="text-sm text-gray-500 mt-1">
|
|
16
|
+
Prompt: <%= link_to "#{@trace.prompt_version.prompt.name} v#{@trace.prompt_version.version_number}",
|
|
17
|
+
prompt_version_path(@trace.prompt_version.prompt, @trace.prompt_version),
|
|
18
|
+
class: "text-indigo-600 hover:text-indigo-900" %>
|
|
19
|
+
</p>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
<%= link_to "Back to traces", traces_path, class: "text-sm text-gray-600 hover:text-gray-900" %>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="grid grid-cols-5 gap-4 mb-6">
|
|
27
|
+
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
28
|
+
<dt class="text-xs font-medium text-gray-500 uppercase">Spans</dt>
|
|
29
|
+
<dd class="text-2xl font-semibold text-gray-900 mt-1"><%= @trace.spans.count %></dd>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
32
|
+
<dt class="text-xs font-medium text-gray-500 uppercase">Input Tokens</dt>
|
|
33
|
+
<dd class="text-2xl font-semibold text-gray-900 mt-1"><%= number_with_delimiter(@trace.total_input_tokens) %></dd>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
36
|
+
<dt class="text-xs font-medium text-gray-500 uppercase">Output Tokens</dt>
|
|
37
|
+
<dd class="text-2xl font-semibold text-gray-900 mt-1"><%= number_with_delimiter(@trace.total_output_tokens) %></dd>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
40
|
+
<dt class="text-xs font-medium text-gray-500 uppercase">Cached Tokens</dt>
|
|
41
|
+
<dd class="text-2xl font-semibold text-green-600 mt-1"><%= number_with_delimiter(@trace.total_cached_tokens) %></dd>
|
|
42
|
+
<% if @trace.total_input_tokens > 0 && @trace.total_cached_tokens > 0 %>
|
|
43
|
+
<dd class="text-xs text-green-600 mt-0.5"><%= (@trace.total_cached_tokens.to_f / @trace.total_input_tokens * 100).round %>% of input</dd>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
47
|
+
<dt class="text-xs font-medium text-gray-500 uppercase">Duration</dt>
|
|
48
|
+
<dd class="text-2xl font-semibold text-gray-900 mt-1">
|
|
49
|
+
<%= format_duration_ms(@trace.duration_ms) %>
|
|
50
|
+
</dd>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<% if @trace.metadata.present? && @trace.metadata.any? %>
|
|
55
|
+
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5 mb-6">
|
|
56
|
+
<h3 class="text-sm font-medium text-gray-900 mb-2">Metadata</h3>
|
|
57
|
+
<dl class="grid grid-cols-2 gap-2 text-sm">
|
|
58
|
+
<% @trace.metadata.each do |key, value| %>
|
|
59
|
+
<dt class="text-gray-500"><%= key %></dt>
|
|
60
|
+
<dd class="text-gray-900"><%= value %></dd>
|
|
61
|
+
<% end %>
|
|
62
|
+
</dl>
|
|
63
|
+
</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
|
|
66
|
+
<div class="bg-white rounded-lg shadow-sm ring-1 ring-gray-900/5">
|
|
67
|
+
<div class="px-4 py-3 border-b border-gray-200">
|
|
68
|
+
<h2 class="text-lg font-medium text-gray-900">Span Tree</h2>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="p-4">
|
|
71
|
+
<% if @root_spans.any? %>
|
|
72
|
+
<% @root_spans.each do |span| %>
|
|
73
|
+
<%= render partial: "llm_logs/traces/span_node", locals: { span: span, depth: 0, trace: @trace } %>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% else %>
|
|
76
|
+
<p class="text-sm text-gray-500">No spans recorded.</p>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LlmLogs::Engine.routes.draw do
|
|
2
|
+
root to: "traces#index"
|
|
3
|
+
|
|
4
|
+
resources :traces, only: [:index, :show] do
|
|
5
|
+
resources :spans, only: [:show]
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
resources :prompts do
|
|
9
|
+
resources :versions, only: [:index, :show, :destroy], controller: "prompt_versions" do
|
|
10
|
+
member do
|
|
11
|
+
post :restore
|
|
12
|
+
end
|
|
13
|
+
collection do
|
|
14
|
+
get :compare
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateLlmLogsTraces < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_logs_traces do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.string :status, null: false, default: "running"
|
|
6
|
+
t.jsonb :metadata, default: {}
|
|
7
|
+
t.integer :total_input_tokens, default: 0
|
|
8
|
+
t.integer :total_output_tokens, default: 0
|
|
9
|
+
t.integer :total_cached_tokens, default: 0, null: false
|
|
10
|
+
t.decimal :total_cost, precision: 10, scale: 6, default: 0
|
|
11
|
+
t.float :duration_ms
|
|
12
|
+
t.integer :spans_count, default: 0, null: false
|
|
13
|
+
t.datetime :started_at, null: false
|
|
14
|
+
t.datetime :completed_at
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :llm_logs_traces, :status
|
|
20
|
+
add_index :llm_logs_traces, :started_at
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class CreateLlmLogsSpans < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_logs_spans do |t|
|
|
4
|
+
t.references :trace, null: false, foreign_key: { to_table: :llm_logs_traces }
|
|
5
|
+
t.bigint :parent_span_id
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :span_type, null: false
|
|
8
|
+
t.string :model
|
|
9
|
+
t.string :provider
|
|
10
|
+
t.jsonb :input
|
|
11
|
+
t.jsonb :output
|
|
12
|
+
t.integer :input_tokens
|
|
13
|
+
t.integer :output_tokens
|
|
14
|
+
t.integer :cached_tokens
|
|
15
|
+
t.decimal :cost, precision: 10, scale: 6
|
|
16
|
+
t.float :duration_ms
|
|
17
|
+
t.string :status, null: false, default: "ok"
|
|
18
|
+
t.text :error_message
|
|
19
|
+
t.jsonb :metadata, default: {}
|
|
20
|
+
t.datetime :started_at, null: false
|
|
21
|
+
t.datetime :completed_at
|
|
22
|
+
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_foreign_key :llm_logs_spans, :llm_logs_spans, column: :parent_span_id
|
|
27
|
+
add_index :llm_logs_spans, :parent_span_id
|
|
28
|
+
add_index :llm_logs_spans, :span_type
|
|
29
|
+
add_index :llm_logs_spans, :started_at
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateLlmLogsPrompts < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_logs_prompts do |t|
|
|
4
|
+
t.string :slug, null: false
|
|
5
|
+
t.string :name, null: false
|
|
6
|
+
t.text :description
|
|
7
|
+
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :llm_logs_prompts, :slug, unique: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreateLlmLogsPromptVersions < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_logs_prompt_versions do |t|
|
|
4
|
+
t.references :prompt, null: false, foreign_key: { to_table: :llm_logs_prompts }
|
|
5
|
+
t.integer :version_number, null: false
|
|
6
|
+
t.jsonb :messages, null: false, default: []
|
|
7
|
+
t.string :model
|
|
8
|
+
t.jsonb :model_params, default: {}
|
|
9
|
+
t.jsonb :default_variables, default: {}
|
|
10
|
+
t.text :changelog
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :llm_logs_prompt_versions, [:prompt_id, :version_number], unique: true,
|
|
16
|
+
name: "idx_llm_logs_prompt_versions_on_prompt_and_version"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class InstallGenerator < Rails::Generators::Base
|
|
3
|
+
source_root File.expand_path("templates", __dir__)
|
|
4
|
+
|
|
5
|
+
def copy_initializer
|
|
6
|
+
template "initializer.rb", "config/initializers/llm_logs.rb"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def mount_engine
|
|
10
|
+
route 'mount LlmLogs::Engine, at: "/llm_logs"'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def copy_migrations
|
|
14
|
+
rake "llm_logs:install:migrations"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LlmLogs.setup do |config|
|
|
2
|
+
# Set to false to disable auto-instrumentation of ruby_llm
|
|
3
|
+
# config.auto_instrument = false
|
|
4
|
+
|
|
5
|
+
# Set to false to completely disable logging
|
|
6
|
+
# config.enabled = false
|
|
7
|
+
|
|
8
|
+
# Number of days to keep trace data (for future cleanup job)
|
|
9
|
+
# config.retention_days = 30
|
|
10
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace LlmLogs
|
|
4
|
+
|
|
5
|
+
initializer "llm_logs.auto_instrument" do
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
if LlmLogs.auto_instrument && defined?(RubyLLM::Chat)
|
|
8
|
+
require "llm_logs/instrumentation/ruby_llm_chat"
|
|
9
|
+
RubyLLM::Chat.prepend(LlmLogs::Instrumentation::RubyLlmChat)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
module Instrumentation
|
|
3
|
+
module RubyLlmChat
|
|
4
|
+
def complete(&block)
|
|
5
|
+
return super unless LlmLogs.enabled?
|
|
6
|
+
|
|
7
|
+
span = LlmLogs::Tracer.start_span(
|
|
8
|
+
name: "chat.complete",
|
|
9
|
+
span_type: "llm",
|
|
10
|
+
model: @model&.id,
|
|
11
|
+
provider: @model&.provider,
|
|
12
|
+
input: messages.map { |m| { role: m.role, content: llm_logs_serialize_content(m.content) } }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
messages_before = messages.size
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
result = super(&block)
|
|
19
|
+
span.record_response(result)
|
|
20
|
+
span.cost = llm_logs_compute_cost(result)
|
|
21
|
+
result
|
|
22
|
+
rescue => e
|
|
23
|
+
span.record_error(e)
|
|
24
|
+
llm_logs_capture_partial_tokens(span, messages_before)
|
|
25
|
+
raise
|
|
26
|
+
ensure
|
|
27
|
+
span.finish
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def llm_logs_serialize_content(content)
|
|
34
|
+
content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def llm_logs_serialize_tool_result(result)
|
|
38
|
+
if result.is_a?(Hash)
|
|
39
|
+
result
|
|
40
|
+
elsif defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
41
|
+
{ halted: result.message }
|
|
42
|
+
else
|
|
43
|
+
{ result: result.to_s }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def llm_logs_capture_partial_tokens(span, messages_before)
|
|
48
|
+
assistant_msg = messages[messages_before..].find { |m| m.role == :assistant }
|
|
49
|
+
return unless assistant_msg&.input_tokens
|
|
50
|
+
|
|
51
|
+
span.input_tokens = assistant_msg.input_tokens
|
|
52
|
+
span.output_tokens = assistant_msg.output_tokens
|
|
53
|
+
span.cached_tokens = assistant_msg.cached_tokens
|
|
54
|
+
span.cost = llm_logs_compute_cost(assistant_msg)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def llm_logs_compute_cost(message)
|
|
60
|
+
model_info = llm_logs_resolve_pricing_model
|
|
61
|
+
return unless model_info
|
|
62
|
+
|
|
63
|
+
input_price = model_info.input_price_per_million
|
|
64
|
+
output_price = model_info.output_price_per_million
|
|
65
|
+
return unless input_price && output_price
|
|
66
|
+
|
|
67
|
+
(message.input_tokens.to_f * input_price + message.output_tokens.to_f * output_price) / 1_000_000
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def llm_logs_resolve_pricing_model
|
|
71
|
+
return @model if @model&.input_price_per_million
|
|
72
|
+
|
|
73
|
+
RubyLLM.models.find(@model.id) if @model&.id
|
|
74
|
+
rescue StandardError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
public
|
|
79
|
+
|
|
80
|
+
def execute_tool(tool_call)
|
|
81
|
+
return super unless LlmLogs.enabled?
|
|
82
|
+
|
|
83
|
+
span = LlmLogs::Tracer.start_span(
|
|
84
|
+
name: "tool.#{tool_call.name}",
|
|
85
|
+
span_type: "tool",
|
|
86
|
+
input: tool_call.arguments,
|
|
87
|
+
metadata: { tool_name: tool_call.name }
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
result = super
|
|
92
|
+
span.output = llm_logs_serialize_tool_result(result)
|
|
93
|
+
span.set_attribute("tool.halted", true) if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
94
|
+
# Convert hash/array results to JSON so RubyLLM stores clean JSON
|
|
95
|
+
# in message content, not Ruby hash syntax from Hash#to_s
|
|
96
|
+
result = result.to_json if result.is_a?(Hash) || result.is_a?(Array)
|
|
97
|
+
result
|
|
98
|
+
rescue => e
|
|
99
|
+
span.record_error(e)
|
|
100
|
+
raise
|
|
101
|
+
ensure
|
|
102
|
+
span.finish
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "mustache"
|
|
2
|
+
|
|
3
|
+
module LlmLogs
|
|
4
|
+
class PromptRenderer < Mustache
|
|
5
|
+
# Disable HTML escaping — LLM prompts are plain text
|
|
6
|
+
def escapeHTML(str)
|
|
7
|
+
str
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.render(template_string, variables = {})
|
|
11
|
+
renderer = new
|
|
12
|
+
renderer.template = template_string
|
|
13
|
+
variables.each { |key, value| renderer[key] = value }
|
|
14
|
+
renderer.render
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
module Tracer
|
|
3
|
+
def self.current_trace
|
|
4
|
+
Thread.current[:llm_logs_trace]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def self.current_span
|
|
8
|
+
Thread.current[:llm_logs_span]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.start_trace(name, metadata: {})
|
|
12
|
+
trace = LlmLogs::Trace.create!(
|
|
13
|
+
name: name,
|
|
14
|
+
status: "running",
|
|
15
|
+
metadata: metadata,
|
|
16
|
+
started_at: Time.current
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
previous_trace = Thread.current[:llm_logs_trace]
|
|
20
|
+
previous_span = Thread.current[:llm_logs_span]
|
|
21
|
+
Thread.current[:llm_logs_trace] = trace
|
|
22
|
+
Thread.current[:llm_logs_span] = nil
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
yield trace
|
|
26
|
+
rescue => e
|
|
27
|
+
trace.fail!
|
|
28
|
+
raise
|
|
29
|
+
ensure
|
|
30
|
+
trace.complete! if trace.status == "running"
|
|
31
|
+
Thread.current[:llm_logs_trace] = previous_trace
|
|
32
|
+
Thread.current[:llm_logs_span] = previous_span
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.start_span(name:, span_type:, model: nil, provider: nil, input: nil, metadata: {})
|
|
37
|
+
trace = current_trace || auto_create_trace(name)
|
|
38
|
+
parent = current_span
|
|
39
|
+
|
|
40
|
+
span = LlmLogs::Span.create!(
|
|
41
|
+
trace: trace,
|
|
42
|
+
parent_span: parent,
|
|
43
|
+
name: name,
|
|
44
|
+
span_type: span_type,
|
|
45
|
+
model: model,
|
|
46
|
+
provider: provider,
|
|
47
|
+
input: input,
|
|
48
|
+
metadata: metadata,
|
|
49
|
+
status: "ok",
|
|
50
|
+
started_at: Time.current
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
Thread.current[:llm_logs_span] = span
|
|
54
|
+
span
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.auto_create_trace(span_name)
|
|
58
|
+
trace = LlmLogs::Trace.create!(
|
|
59
|
+
name: "auto:#{span_name}",
|
|
60
|
+
status: "running",
|
|
61
|
+
started_at: Time.current
|
|
62
|
+
)
|
|
63
|
+
Thread.current[:llm_logs_trace] = trace
|
|
64
|
+
trace
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/llm_logs.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "kaminari"
|
|
2
|
+
require "llm_logs/version"
|
|
3
|
+
require "llm_logs/engine"
|
|
4
|
+
require "llm_logs/tracer"
|
|
5
|
+
require "llm_logs/prompt_renderer"
|
|
6
|
+
|
|
7
|
+
module LlmLogs
|
|
8
|
+
mattr_accessor :enabled, default: true
|
|
9
|
+
mattr_accessor :auto_instrument, default: true
|
|
10
|
+
mattr_accessor :retention_days, default: 30
|
|
11
|
+
|
|
12
|
+
def self.enabled?
|
|
13
|
+
enabled
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.setup
|
|
17
|
+
yield self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.trace(name, **options, &block)
|
|
21
|
+
LlmLogs::Tracer.start_trace(name, **options, &block)
|
|
22
|
+
end
|
|
23
|
+
end
|