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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +148 -0
  3. data/Rakefile +11 -0
  4. data/app/controllers/llm_logs/application_controller.rb +5 -0
  5. data/app/controllers/llm_logs/prompt_versions_controller.rb +66 -0
  6. data/app/controllers/llm_logs/prompts_controller.rb +91 -0
  7. data/app/controllers/llm_logs/spans_controller.rb +8 -0
  8. data/app/controllers/llm_logs/traces_controller.rb +19 -0
  9. data/app/helpers/llm_logs/formatting_helper.rb +34 -0
  10. data/app/models/llm_logs/application_record.rb +5 -0
  11. data/app/models/llm_logs/prompt.rb +54 -0
  12. data/app/models/llm_logs/prompt_version.rb +31 -0
  13. data/app/models/llm_logs/span.rb +38 -0
  14. data/app/models/llm_logs/trace.rb +46 -0
  15. data/app/views/kaminari/tailwind/_first_page.html.erb +5 -0
  16. data/app/views/kaminari/tailwind/_gap.html.erb +3 -0
  17. data/app/views/kaminari/tailwind/_last_page.html.erb +5 -0
  18. data/app/views/kaminari/tailwind/_next_page.html.erb +5 -0
  19. data/app/views/kaminari/tailwind/_page.html.erb +9 -0
  20. data/app/views/kaminari/tailwind/_paginator.html.erb +17 -0
  21. data/app/views/kaminari/tailwind/_prev_page.html.erb +5 -0
  22. data/app/views/layouts/llm_logs/application.html.erb +52 -0
  23. data/app/views/llm_logs/prompt_versions/compare.html.erb +41 -0
  24. data/app/views/llm_logs/prompt_versions/index.html.erb +92 -0
  25. data/app/views/llm_logs/prompt_versions/show.html.erb +48 -0
  26. data/app/views/llm_logs/prompts/_form.html.erb +94 -0
  27. data/app/views/llm_logs/prompts/edit.html.erb +6 -0
  28. data/app/views/llm_logs/prompts/index.html.erb +43 -0
  29. data/app/views/llm_logs/prompts/new.html.erb +5 -0
  30. data/app/views/llm_logs/prompts/show.html.erb +107 -0
  31. data/app/views/llm_logs/spans/show.html.erb +99 -0
  32. data/app/views/llm_logs/traces/_span_node.html.erb +66 -0
  33. data/app/views/llm_logs/traces/index.html.erb +78 -0
  34. data/app/views/llm_logs/traces/show.html.erb +79 -0
  35. data/config/routes.rb +18 -0
  36. data/db/migrate/001_create_llm_logs_traces.rb +22 -0
  37. data/db/migrate/002_create_llm_logs_spans.rb +31 -0
  38. data/db/migrate/003_create_llm_logs_prompts.rb +13 -0
  39. data/db/migrate/004_create_llm_logs_prompt_versions.rb +18 -0
  40. data/db/migrate/005_add_prompt_version_to_traces.rb +7 -0
  41. data/lib/generators/llm_logs/install_generator.rb +17 -0
  42. data/lib/generators/llm_logs/templates/initializer.rb +10 -0
  43. data/lib/llm_logs/engine.rb +14 -0
  44. data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +107 -0
  45. data/lib/llm_logs/prompt_renderer.rb +17 -0
  46. data/lib/llm_logs/tracer.rb +67 -0
  47. data/lib/llm_logs/version.rb +3 -0
  48. data/lib/llm_logs.rb +23 -0
  49. 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 %> &rarr; <%= 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
+ &middot; <%= 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) %> &rarr; <%= 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,7 @@
1
+ class AddPromptVersionToTraces < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :llm_logs_traces, :prompt_version,
4
+ foreign_key: { to_table: :llm_logs_prompt_versions, on_delete: :nullify },
5
+ null: true
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ module LlmLogs
2
+ VERSION = "0.1.0"
3
+ 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