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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 25fbd9f60530d337d2cfaa0bc4cec00afc6a3c1c802c771d4109460fac64dfea
|
|
4
|
+
data.tar.gz: 5e406b0764ebe2575e5b19da0b5d73934f46e63aff3f1572f385aa48d0839b02
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3ad9d74c2d111ad34e4d132d9fc6ab1c818f9c59bc7143bfee9c68a69e4f89349e48c1710412ac54e004aec8dca544abe1fa99fc9f40c31a82c524a452f1c73b
|
|
7
|
+
data.tar.gz: '0854b4994c2156b431f71fbe08b7f36032e53452962c88ad3467c6fed3c165b37adb47111196c2268d2696f827499ad1fa35c3e69695ea04530f05a98ed0edca'
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# LlmLogs
|
|
2
|
+
|
|
3
|
+
Mountable Rails engine for LLM call tracing and prompt management. Auto-instruments [ruby_llm](https://github.com/crmne/ruby_llm) to capture every LLM call as a span within a trace, and provides a web UI for browsing logs and editing prompts.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "llm_logs", github: "tonic20/llm_logs"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run the install generator:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bin/rails generate llm_logs:install
|
|
17
|
+
bin/rails db:migrate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or manually:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# config/routes.rb
|
|
24
|
+
mount LlmLogs::Engine, at: "/llm_logs"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config/initializers/llm_logs.rb
|
|
29
|
+
LlmLogs.setup do |config|
|
|
30
|
+
config.enabled = true
|
|
31
|
+
config.auto_instrument = true
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Tracing
|
|
36
|
+
|
|
37
|
+
Wrap operations in a trace block. All `ruby_llm` calls inside become child spans automatically.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
LlmLogs.trace("strategy_analysis", metadata: { strategy_id: 42 }) do
|
|
41
|
+
chat = RubyLLM.chat(model: "anthropic/claude-sonnet-4")
|
|
42
|
+
chat.ask("Analyze this strategy...")
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Nested traces are supported:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
LlmLogs.trace("full_pipeline") do
|
|
50
|
+
chat.ask("Step 1...")
|
|
51
|
+
|
|
52
|
+
LlmLogs.trace("risk_assessment") do
|
|
53
|
+
chat.ask("What are the risks?")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
LLM calls made outside an explicit trace are auto-wrapped in one.
|
|
59
|
+
|
|
60
|
+
### What Gets Captured
|
|
61
|
+
|
|
62
|
+
Each span records:
|
|
63
|
+
|
|
64
|
+
- Model and provider
|
|
65
|
+
- Input messages and output content
|
|
66
|
+
- Input, output, and cached token counts
|
|
67
|
+
- Duration in milliseconds
|
|
68
|
+
- Error messages (on failure)
|
|
69
|
+
- Custom metadata
|
|
70
|
+
|
|
71
|
+
Tool calls are captured as child spans with tool name and arguments.
|
|
72
|
+
|
|
73
|
+
## Prompt Management
|
|
74
|
+
|
|
75
|
+
Create prompts with [Mustache](https://mustache.github.io/) templates and auto-versioning.
|
|
76
|
+
|
|
77
|
+
### Create a Prompt
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
prompt = LlmLogs::Prompt.create!(
|
|
81
|
+
slug: "strategy-analysis",
|
|
82
|
+
name: "Strategy Analysis"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
prompt.update_content!(
|
|
86
|
+
messages: [
|
|
87
|
+
{ "role" => "system", "content" => "You analyze trading strategies for {{app_name}}." },
|
|
88
|
+
{ "role" => "user", "content" => "Analyze {{strategy_name}} on the {{timeframe}} timeframe." }
|
|
89
|
+
],
|
|
90
|
+
model: "claude-sonnet-4",
|
|
91
|
+
model_params: { "temperature" => 0.3, "max_tokens" => 2048 }
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Load and Render
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
prompt = LlmLogs::Prompt.load("strategy-analysis")
|
|
99
|
+
params = prompt.build(
|
|
100
|
+
app_name: "Tradebot",
|
|
101
|
+
strategy_name: "Momentum Alpha",
|
|
102
|
+
timeframe: "4h"
|
|
103
|
+
)
|
|
104
|
+
# => { model: "claude-sonnet-4", messages: [...], temperature: 0.3, max_tokens: 2048 }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Versioning
|
|
108
|
+
|
|
109
|
+
Every save creates a new version. Previous versions are never modified.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
prompt.update_content!(
|
|
113
|
+
messages: [{ "role" => "user", "content" => "Updated prompt" }],
|
|
114
|
+
changelog: "Simplified the prompt"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
prompt.current_version # latest
|
|
118
|
+
prompt.version(1) # specific version
|
|
119
|
+
prompt.rollback_to!(1) # creates new version from v1 content
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Web UI
|
|
123
|
+
|
|
124
|
+
Browse traces and manage prompts at `/llm_logs`.
|
|
125
|
+
|
|
126
|
+
**Traces** — list with filtering by status, drill into hierarchical span trees with collapsible input/output.
|
|
127
|
+
|
|
128
|
+
**Prompts** — CRUD with Mustache template editor, model configuration, and version history.
|
|
129
|
+
|
|
130
|
+
## Configuration
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
LlmLogs.setup do |config|
|
|
134
|
+
config.enabled = true # master switch for logging
|
|
135
|
+
config.auto_instrument = true # auto-prepend on RubyLLM::Chat
|
|
136
|
+
config.retention_days = 30 # for future cleanup job
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Requirements
|
|
141
|
+
|
|
142
|
+
- Rails 8.0+
|
|
143
|
+
- Ruby 3.3+
|
|
144
|
+
- PostgreSQL
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "diffy"
|
|
2
|
+
|
|
3
|
+
module LlmLogs
|
|
4
|
+
class PromptVersionsController < ApplicationController
|
|
5
|
+
before_action :set_prompt
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@versions = @prompt.versions.order(version_number: :desc)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
@version = @prompt.versions.find(params[:id])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def restore
|
|
16
|
+
version = @prompt.versions.find(params[:id])
|
|
17
|
+
@prompt.rollback_to!(version.version_number)
|
|
18
|
+
redirect_to prompt_path(@prompt), notice: "Restored to version #{version.version_number}."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def destroy
|
|
22
|
+
version = @prompt.versions.find(params[:id])
|
|
23
|
+
|
|
24
|
+
if version == @prompt.current_version
|
|
25
|
+
redirect_to prompt_versions_path(@prompt), alert: "Cannot delete the current active version."
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
version.destroy!
|
|
30
|
+
redirect_to prompt_versions_path(@prompt), notice: "Version #{version.version_number} deleted."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def compare
|
|
34
|
+
|
|
35
|
+
if params[:a].blank? || params[:b].blank? || params[:a] == params[:b]
|
|
36
|
+
redirect_to prompt_versions_path(@prompt), alert: "Select two different versions to compare."
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@version_a = @prompt.versions.find_by(version_number: params[:a])
|
|
41
|
+
@version_b = @prompt.versions.find_by(version_number: params[:b])
|
|
42
|
+
|
|
43
|
+
unless @version_a && @version_b
|
|
44
|
+
redirect_to prompt_versions_path(@prompt), alert: "Version not found."
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
max_messages = [@version_a.messages.size, @version_b.messages.size].max
|
|
49
|
+
@diffs = (0...max_messages).map do |i|
|
|
50
|
+
msg_a = @version_a.messages[i]
|
|
51
|
+
msg_b = @version_b.messages[i]
|
|
52
|
+
role = (msg_a || msg_b)["role"]
|
|
53
|
+
content_a = (msg_a&.dig("content") || "").gsub("\r\n", "\n")
|
|
54
|
+
content_b = (msg_b&.dig("content") || "").gsub("\r\n", "\n")
|
|
55
|
+
diff_html = Diffy::SplitDiff.new(content_a, content_b, format: :html_simple)
|
|
56
|
+
{ role: role, left: diff_html.left, right: diff_html.right }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def set_prompt
|
|
63
|
+
@prompt = Prompt.find(params[:prompt_id])
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class PromptsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@prompts = Prompt.order(:name).includes(:versions).page(params[:page]).per(25)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@prompt = Prompt.find(params[:id])
|
|
9
|
+
@current_version = @prompt.current_version
|
|
10
|
+
@versions = @prompt.versions.order(version_number: :desc).limit(5)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@prompt = Prompt.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
@prompt = Prompt.new(prompt_params)
|
|
19
|
+
|
|
20
|
+
if @prompt.save
|
|
21
|
+
if version_params[:messages].present?
|
|
22
|
+
@prompt.update_content!(**version_params)
|
|
23
|
+
end
|
|
24
|
+
redirect_to prompt_path(@prompt), notice: "Prompt created."
|
|
25
|
+
else
|
|
26
|
+
render :new, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def edit
|
|
31
|
+
@prompt = Prompt.find(params[:id])
|
|
32
|
+
@current_version = @prompt.current_version
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update
|
|
36
|
+
@prompt = Prompt.find(params[:id])
|
|
37
|
+
|
|
38
|
+
if @prompt.update(prompt_params)
|
|
39
|
+
if version_params[:messages].present?
|
|
40
|
+
@prompt.update_content!(**version_params)
|
|
41
|
+
end
|
|
42
|
+
redirect_to prompt_path(@prompt), notice: "Prompt updated."
|
|
43
|
+
else
|
|
44
|
+
render :edit, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def destroy
|
|
49
|
+
@prompt = Prompt.find(params[:id])
|
|
50
|
+
@prompt.destroy
|
|
51
|
+
redirect_to prompts_path, notice: "Prompt deleted."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def prompt_params
|
|
57
|
+
params.require(:prompt).permit(:slug, :name, :description)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def version_params
|
|
61
|
+
raw = params.require(:prompt).permit(:model, :changelog, model_params: {})
|
|
62
|
+
messages = parse_messages
|
|
63
|
+
{
|
|
64
|
+
messages: messages,
|
|
65
|
+
model: raw[:model],
|
|
66
|
+
model_params: coerce_model_params(raw[:model_params]&.to_h || {}),
|
|
67
|
+
changelog: raw[:changelog]
|
|
68
|
+
}.compact_blank
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def coerce_model_params(params_hash)
|
|
72
|
+
params_hash.each_with_object({}) do |(key, value), result|
|
|
73
|
+
next if value.blank?
|
|
74
|
+
|
|
75
|
+
result[key] = case value
|
|
76
|
+
when /\A\d+\z/ then value.to_i
|
|
77
|
+
when /\A\d+\.\d+\z/ then value.to_f
|
|
78
|
+
else value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_messages
|
|
84
|
+
return [] unless params[:prompt][:messages].present?
|
|
85
|
+
|
|
86
|
+
params[:prompt][:messages].values.map do |msg|
|
|
87
|
+
{ "role" => msg[:role], "content" => msg[:content] }
|
|
88
|
+
end.reject { |m| m["content"].blank? }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class TracesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@traces = Trace.recent
|
|
5
|
+
@traces = @traces.by_status(params[:status]) if params[:status].present?
|
|
6
|
+
if params[:prompt_version_id].present?
|
|
7
|
+
@traces = @traces.where(prompt_version_id: params[:prompt_version_id])
|
|
8
|
+
@filter_version = PromptVersion.find_by(id: params[:prompt_version_id])
|
|
9
|
+
end
|
|
10
|
+
@traces = @traces.page(params[:page]).per(50)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
@trace = Trace.includes(prompt_version: :prompt).find(params[:id])
|
|
15
|
+
@root_spans = @trace.root_spans
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
module FormattingHelper
|
|
3
|
+
def format_duration_ms(ms)
|
|
4
|
+
return "—" if ms.nil?
|
|
5
|
+
|
|
6
|
+
"#{number_with_delimiter(sprintf('%.0f', ms))} ms"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def pretty_json(data)
|
|
10
|
+
JSON.pretty_generate(deep_parse_json(data))
|
|
11
|
+
rescue
|
|
12
|
+
data.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def deep_parse_json(obj)
|
|
18
|
+
case obj
|
|
19
|
+
when Hash
|
|
20
|
+
obj.transform_values { |v| deep_parse_json(v) }
|
|
21
|
+
when Array
|
|
22
|
+
obj.map { |v| deep_parse_json(v) }
|
|
23
|
+
when String
|
|
24
|
+
begin
|
|
25
|
+
deep_parse_json(JSON.parse(obj))
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
obj
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
obj
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class Prompt < ApplicationRecord
|
|
3
|
+
has_many :versions, class_name: "LlmLogs::PromptVersion", dependent: :destroy
|
|
4
|
+
|
|
5
|
+
validates :slug, presence: true, uniqueness: true
|
|
6
|
+
validates :name, presence: true
|
|
7
|
+
|
|
8
|
+
def self.load(slug)
|
|
9
|
+
find_by!(slug: slug)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def current_version
|
|
13
|
+
versions.order(version_number: :desc).first
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def version(number)
|
|
17
|
+
versions.find_by!(version_number: number)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build(variables = {})
|
|
21
|
+
ver = current_version
|
|
22
|
+
raise "No versions exist for prompt '#{slug}'" unless ver
|
|
23
|
+
|
|
24
|
+
trace = LlmLogs::Tracer.current_trace
|
|
25
|
+
if trace && trace.prompt_version_id.nil?
|
|
26
|
+
trace.update_column(:prompt_version_id, ver.id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ver.render(variables)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update_content!(messages:, model: nil, model_params: {}, changelog: nil)
|
|
33
|
+
next_number = (versions.maximum(:version_number) || 0) + 1
|
|
34
|
+
|
|
35
|
+
versions.create!(
|
|
36
|
+
version_number: next_number,
|
|
37
|
+
messages: messages,
|
|
38
|
+
model: model,
|
|
39
|
+
model_params: model_params,
|
|
40
|
+
changelog: changelog
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rollback_to!(version_number)
|
|
45
|
+
source = version(version_number)
|
|
46
|
+
update_content!(
|
|
47
|
+
messages: source.messages,
|
|
48
|
+
model: source.model,
|
|
49
|
+
model_params: source.model_params,
|
|
50
|
+
changelog: "Rollback to version #{version_number}"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "mustache"
|
|
2
|
+
|
|
3
|
+
module LlmLogs
|
|
4
|
+
class PromptVersion < ApplicationRecord
|
|
5
|
+
belongs_to :prompt
|
|
6
|
+
has_many :traces, class_name: "LlmLogs::Trace", dependent: :nullify
|
|
7
|
+
|
|
8
|
+
validates :version_number, presence: true, uniqueness: { scope: :prompt_id }
|
|
9
|
+
validates :messages, presence: true
|
|
10
|
+
|
|
11
|
+
def variables
|
|
12
|
+
messages.flat_map { |msg| msg["content"].to_s.scan(/\{\{[#^]?([^\/}]+)\}\}/) }.flatten.uniq.sort
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render(variables = {})
|
|
16
|
+
merged = (default_variables || {}).merge(variables.stringify_keys)
|
|
17
|
+
|
|
18
|
+
rendered_messages = messages.map do |msg|
|
|
19
|
+
{
|
|
20
|
+
role: msg["role"],
|
|
21
|
+
content: LlmLogs::PromptRenderer.render(msg["content"], merged)
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
params = { messages: rendered_messages }
|
|
26
|
+
params[:model] = model if model.present?
|
|
27
|
+
params.merge!(model_params.symbolize_keys) if model_params.present?
|
|
28
|
+
params
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class Span < ApplicationRecord
|
|
3
|
+
belongs_to :trace, counter_cache: true
|
|
4
|
+
belongs_to :parent_span, class_name: "LlmLogs::Span", optional: true
|
|
5
|
+
has_many :child_spans, class_name: "LlmLogs::Span", foreign_key: :parent_span_id, dependent: :nullify
|
|
6
|
+
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
validates :span_type, presence: true, inclusion: { in: %w[llm tool custom] }
|
|
9
|
+
validates :status, presence: true, inclusion: { in: %w[ok error] }
|
|
10
|
+
validates :started_at, presence: true
|
|
11
|
+
|
|
12
|
+
def finish
|
|
13
|
+
update!(
|
|
14
|
+
completed_at: Time.current,
|
|
15
|
+
duration_ms: (Time.current - started_at) * 1000
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Restore parent span as current
|
|
19
|
+
Thread.current[:llm_logs_span] = parent_span
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record_response(message)
|
|
23
|
+
self.output = { content: message.content.to_s }
|
|
24
|
+
self.input_tokens = message.input_tokens
|
|
25
|
+
self.output_tokens = message.output_tokens
|
|
26
|
+
self.cached_tokens = message.cached_tokens
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record_error(exception)
|
|
30
|
+
self.status = "error"
|
|
31
|
+
self.error_message = "#{exception.class}: #{exception.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set_attribute(key, value)
|
|
35
|
+
self.metadata = (metadata || {}).merge(key => value)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class Trace < ApplicationRecord
|
|
3
|
+
has_many :spans, dependent: :destroy
|
|
4
|
+
belongs_to :prompt_version, class_name: "LlmLogs::PromptVersion", optional: true
|
|
5
|
+
|
|
6
|
+
validates :name, presence: true
|
|
7
|
+
validates :status, presence: true, inclusion: { in: %w[running completed error] }
|
|
8
|
+
validates :started_at, presence: true
|
|
9
|
+
|
|
10
|
+
scope :recent, -> { order(started_at: :desc) }
|
|
11
|
+
scope :by_status, ->(status) { where(status: status) if status.present? }
|
|
12
|
+
|
|
13
|
+
def complete!
|
|
14
|
+
return if status == "completed"
|
|
15
|
+
|
|
16
|
+
rollup_stats!
|
|
17
|
+
update!(
|
|
18
|
+
status: "completed",
|
|
19
|
+
completed_at: Time.current,
|
|
20
|
+
duration_ms: (Time.current - started_at) * 1000
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fail!
|
|
25
|
+
rollup_stats!
|
|
26
|
+
update!(
|
|
27
|
+
status: "error",
|
|
28
|
+
completed_at: Time.current,
|
|
29
|
+
duration_ms: (Time.current - started_at) * 1000
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def root_spans
|
|
34
|
+
spans.where(parent_span_id: nil).order(:started_at)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def rollup_stats!
|
|
40
|
+
self.total_input_tokens = spans.sum(:input_tokens)
|
|
41
|
+
self.total_output_tokens = spans.sum(:output_tokens)
|
|
42
|
+
self.total_cached_tokens = spans.sum(:cached_tokens)
|
|
43
|
+
self.total_cost = spans.sum(:cost)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% if page.current? %>
|
|
2
|
+
<li>
|
|
3
|
+
<span class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium text-white bg-indigo-600 rounded-md"><%= page %></span>
|
|
4
|
+
</li>
|
|
5
|
+
<% else %>
|
|
6
|
+
<li>
|
|
7
|
+
<%= link_to page, url, class: "inline-flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50", remote: remote %>
|
|
8
|
+
</li>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%= paginator.render do -%>
|
|
2
|
+
<nav class="flex items-center justify-center mt-6" aria-label="Pagination">
|
|
3
|
+
<ul class="flex items-center space-x-1">
|
|
4
|
+
<%= first_page_tag unless current_page.first? %>
|
|
5
|
+
<%= prev_page_tag unless current_page.first? %>
|
|
6
|
+
<% each_page do |page| -%>
|
|
7
|
+
<% if page.left_outer? || page.right_outer? || page.inside_window? -%>
|
|
8
|
+
<%= page_tag page %>
|
|
9
|
+
<% elsif !page.was_truncated? -%>
|
|
10
|
+
<%= gap_tag %>
|
|
11
|
+
<% end -%>
|
|
12
|
+
<% end -%>
|
|
13
|
+
<%= next_page_tag unless current_page.last? %>
|
|
14
|
+
<%= last_page_tag unless current_page.last? %>
|
|
15
|
+
</ul>
|
|
16
|
+
</nav>
|
|
17
|
+
<% end -%>
|