llm_logs 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +7 -1
- data/app/controllers/llm_logs/prompts_controller.rb +2 -1
- data/app/controllers/llm_logs/spans_controller.rb +1 -0
- data/app/controllers/llm_logs/traces_controller.rb +4 -2
- data/app/jobs/llm_logs/purge_job.rb +21 -0
- data/app/models/llm_logs/prompt_version.rb +8 -0
- data/app/models/llm_logs/span.rb +25 -0
- data/app/models/llm_logs/trace.rb +2 -4
- data/app/views/llm_logs/prompts/index.html.erb +1 -1
- data/app/views/llm_logs/spans/show.html.erb +5 -5
- data/app/views/llm_logs/traces/_span_node.html.erb +9 -3
- data/app/views/llm_logs/traces/show.html.erb +2 -2
- data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +24 -3
- data/lib/llm_logs/tracer.rb +13 -0
- data/lib/llm_logs/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5bc6872f875f8ceddec93621ab746e249266100a8bd1172645fc0d3f24bdf5b7
|
|
4
|
+
data.tar.gz: 842fdce69e1c3773e65adc38144738ae0dd2e7eb579d5826958fd42858fb6488
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e0eeda8eb30321b52abd6b0646f4f47b6a8b557265319372b3fd72cfa8f651a02ad95855952c15c4e2f312623d2b4c751fb50296467440f75bfa676cccccbe3
|
|
7
|
+
data.tar.gz: b403981ee9cd7313581d52f54d236bbf32341bb707e1f37f0c6b78dae74504a99171ed7839922ebaa9abd8589498a23c66c12aa9b0cc7bb2a5c95d0435e1b705
|
data/README.md
CHANGED
|
@@ -71,6 +71,12 @@ Each span records:
|
|
|
71
71
|
|
|
72
72
|
Tool calls are captured as child spans with tool name and arguments.
|
|
73
73
|
|
|
74
|
+
Each provider round resends the whole conversation, so a round that continues the trace's previous round stores only the messages it added. Its metadata names the round it continues (`input_base_span_id`) and how many of that round's messages it carried over (`input_offset`). `span.full_input` returns the full list. A round that doesn't start with the previous round's messages, such as a second chat in the same trace, stores its full input.
|
|
75
|
+
|
|
76
|
+
### Retention
|
|
77
|
+
|
|
78
|
+
`LlmLogs::PurgeJob` deletes traces started more than `retention_days` ago, together with their spans. Schedule it from the host app, for example once a day. With `retention_days = nil` it keeps everything.
|
|
79
|
+
|
|
74
80
|
## Prompt Management
|
|
75
81
|
|
|
76
82
|
Create prompts with [Mustache](https://mustache.github.io/) templates and auto-versioning.
|
|
@@ -323,7 +329,7 @@ Browse traces and manage prompts at `/llm_logs`.
|
|
|
323
329
|
LlmLogs.setup do |config|
|
|
324
330
|
config.enabled = true # master switch for logging
|
|
325
331
|
config.auto_instrument = true # subscribe to ruby_llm notifications (needs RubyLLM.config.instrumenter = ActiveSupport::Notifications)
|
|
326
|
-
config.retention_days = 30 #
|
|
332
|
+
config.retention_days = 30 # LlmLogs::PurgeJob deletes older traces; nil keeps them
|
|
327
333
|
config.prompts_source_path = Rails.root.join("db/data/prompts")
|
|
328
334
|
config.prompt_subfolders = %w[skills fragments templates]
|
|
329
335
|
config.batch_enabled = true # enable the batch API integration
|
|
@@ -6,9 +6,10 @@ module LlmLogs
|
|
|
6
6
|
tag = params[:tag].is_a?(String) ? params[:tag].presence : nil
|
|
7
7
|
@sort = SORT_COLUMNS.key?(params[:sort]) ? params[:sort] : "name"
|
|
8
8
|
@direction = params[:direction] == "desc" ? "desc" : "asc"
|
|
9
|
-
scope = Prompt.order(SORT_COLUMNS.fetch(@sort) => @direction.to_sym)
|
|
9
|
+
scope = Prompt.order(SORT_COLUMNS.fetch(@sort) => @direction.to_sym)
|
|
10
10
|
scope = scope.with_tag(tag) if tag
|
|
11
11
|
@prompts = scope.page(params[:page]).per(LlmLogs.page_size)
|
|
12
|
+
@latest_versions = PromptVersion.latest_for(@prompts.map(&:id)).index_by(&:prompt_id)
|
|
12
13
|
@active_tag = tag
|
|
13
14
|
@all_tags = Prompt.pluck(:tags).flatten.compact.uniq.sort
|
|
14
15
|
end
|
|
@@ -12,8 +12,10 @@ module LlmLogs
|
|
|
12
12
|
|
|
13
13
|
def show
|
|
14
14
|
@trace = Trace.includes(prompt_version: :prompt).find(params[:id])
|
|
15
|
-
@
|
|
16
|
-
@
|
|
15
|
+
@spans = @trace.spans.order(:started_at).to_a
|
|
16
|
+
@child_spans = @spans.group_by(&:parent_span_id)
|
|
17
|
+
@root_spans = @child_spans.fetch(nil, [])
|
|
18
|
+
@models = @spans.select { |span| span.span_type == "llm" }.filter_map(&:model).uniq
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
# Deletes traces (and their spans) started more than LlmLogs.retention_days ago. Schedule it
|
|
3
|
+
# from the host app, e.g. daily; a nil retention_days keeps everything.
|
|
4
|
+
class PurgeJob < ::ActiveJob::Base
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
BATCH_SIZE = 500
|
|
8
|
+
|
|
9
|
+
def perform
|
|
10
|
+
days = LlmLogs.retention_days
|
|
11
|
+
return if days.nil?
|
|
12
|
+
|
|
13
|
+
LlmLogs::Trace.where(started_at: ...days.days.ago).in_batches(of: BATCH_SIZE) do |traces|
|
|
14
|
+
trace_ids = traces.ids
|
|
15
|
+
LlmLogs::BatchRequest.where(trace_id: trace_ids).update_all(trace_id: nil)
|
|
16
|
+
LlmLogs::Span.where(trace_id: trace_ids).delete_all
|
|
17
|
+
LlmLogs::Trace.where(id: trace_ids).delete_all
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -9,6 +9,14 @@ module LlmLogs
|
|
|
9
9
|
validates :messages, presence: true
|
|
10
10
|
validate :reasoning_effort_is_supported
|
|
11
11
|
|
|
12
|
+
# The newest version of each given prompt, without its messages (for listings).
|
|
13
|
+
def self.latest_for(prompt_ids)
|
|
14
|
+
newest = unscoped.where(prompt_id: prompt_ids).group(:prompt_id).select(:prompt_id, "MAX(version_number) AS version_number")
|
|
15
|
+
select(:id, :prompt_id, :version_number, :model, :model_params)
|
|
16
|
+
.joins("INNER JOIN (#{newest.to_sql}) newest ON newest.prompt_id = #{table_name}.prompt_id " \
|
|
17
|
+
"AND newest.version_number = #{table_name}.version_number")
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
def reasoning_effort
|
|
13
21
|
return nil unless model_params.is_a?(Hash)
|
|
14
22
|
|
data/app/models/llm_logs/span.rb
CHANGED
|
@@ -9,6 +9,31 @@ module LlmLogs
|
|
|
9
9
|
validates :status, presence: true, inclusion: { in: %w[ok error] }
|
|
10
10
|
validates :started_at, presence: true
|
|
11
11
|
|
|
12
|
+
# An llm round that continues the trace's previous round stores only the messages it
|
|
13
|
+
# added (input) plus how many it carried over from that round (input_offset).
|
|
14
|
+
def input_base_span_id
|
|
15
|
+
metadata&.dig("input_base_span_id")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input_offset
|
|
19
|
+
metadata&.dig("input_offset")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The messages this round sent, rebuilt from the chain of rounds it continues. Spans
|
|
23
|
+
# recorded before llm_logs 0.8 have no base and already hold their full input.
|
|
24
|
+
def full_input
|
|
25
|
+
return input unless input_base_span_id
|
|
26
|
+
|
|
27
|
+
rounds = trace.spans.where(span_type: "llm").where(id: ...id).select(:id, :input, :metadata).index_by(&:id)
|
|
28
|
+
chain = [self]
|
|
29
|
+
while (base = rounds[chain.first.input_base_span_id])
|
|
30
|
+
chain.unshift(base)
|
|
31
|
+
end
|
|
32
|
+
chain.drop(1).reduce(Array(chain.first.input)) do |messages, span|
|
|
33
|
+
messages.first(span.input_offset) + Array(span.input)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
12
37
|
def finish
|
|
13
38
|
update!(
|
|
14
39
|
completed_at: Time.current,
|
|
@@ -38,10 +38,8 @@ module LlmLogs
|
|
|
38
38
|
private
|
|
39
39
|
|
|
40
40
|
def rollup_stats!
|
|
41
|
-
|
|
42
|
-
self.total_output_tokens =
|
|
43
|
-
self.total_cached_tokens = spans.sum(:cached_tokens)
|
|
44
|
-
self.total_cost = spans.sum(:cost)
|
|
41
|
+
totals = spans.pick(*%w[input_tokens output_tokens cached_tokens cost].map { |column| Arel.sql("COALESCE(SUM(#{column}), 0)") })
|
|
42
|
+
self.total_input_tokens, self.total_output_tokens, self.total_cached_tokens, self.total_cost = totals
|
|
45
43
|
end
|
|
46
44
|
end
|
|
47
45
|
end
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
</thead>
|
|
28
28
|
<tbody class="divide-y divide-gray-200">
|
|
29
29
|
<% @prompts.each do |prompt| %>
|
|
30
|
-
<% latest = prompt.
|
|
30
|
+
<% latest = @latest_versions[prompt.id] %>
|
|
31
31
|
<tr class="hover:bg-gray-50">
|
|
32
32
|
<td class="px-4 py-3 text-sm">
|
|
33
33
|
<%= link_to prompt.name, prompt_path(prompt), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
|
|
@@ -45,21 +45,21 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
</div>
|
|
47
47
|
|
|
48
|
-
<% if @
|
|
48
|
+
<% if @input.present? %>
|
|
49
49
|
<div class="bg-white rounded-lg shadow-sm ring-1 ring-gray-900/5 mb-6">
|
|
50
50
|
<div class="px-4 py-3 border-b border-gray-200">
|
|
51
|
-
<h2 class="text-sm font-medium text-gray-900">Input<%= @
|
|
51
|
+
<h2 class="text-sm font-medium text-gray-900">Input<%= @input.is_a?(Array) ? " Messages" : "" %></h2>
|
|
52
52
|
</div>
|
|
53
53
|
<div class="p-4 space-y-3">
|
|
54
|
-
<% if @
|
|
55
|
-
<% @
|
|
54
|
+
<% if @input.is_a?(Array) %>
|
|
55
|
+
<% @input.each do |msg| %>
|
|
56
56
|
<div class="rounded-lg p-3 <%= msg['role'] == 'user' ? 'bg-blue-50' : 'bg-gray-50' %>">
|
|
57
57
|
<div class="text-xs font-medium text-gray-500 uppercase mb-1"><%= msg['role'] %></div>
|
|
58
58
|
<pre class="text-sm whitespace-pre-wrap"><%= msg['content'] %></pre>
|
|
59
59
|
</div>
|
|
60
60
|
<% end %>
|
|
61
61
|
<% else %>
|
|
62
|
-
<pre class="text-sm bg-gray-50 rounded-lg p-3 whitespace-pre-wrap"><%= pretty_json(@
|
|
62
|
+
<pre class="text-sm bg-gray-50 rounded-lg p-3 whitespace-pre-wrap"><%= pretty_json(@input) %></pre>
|
|
63
63
|
<% end %>
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
@@ -28,9 +28,15 @@
|
|
|
28
28
|
</summary>
|
|
29
29
|
|
|
30
30
|
<div class="mt-2 ml-4 space-y-3">
|
|
31
|
-
<% if span.input.present? %>
|
|
31
|
+
<% if span.input.present? || span.input_base_span_id %>
|
|
32
32
|
<div>
|
|
33
33
|
<h4 class="text-xs font-medium text-gray-500 uppercase mb-1">Input</h4>
|
|
34
|
+
<% if span.input_base_span_id %>
|
|
35
|
+
<p class="text-xs text-gray-500 mb-1">
|
|
36
|
+
New messages only; <%= pluralize(span.input_offset, "earlier message") %> from the previous round not repeated.
|
|
37
|
+
<%= link_to "Full input", trace_span_path(trace, span), class: "text-indigo-600 hover:text-indigo-900" %>
|
|
38
|
+
</p>
|
|
39
|
+
<% end %>
|
|
34
40
|
<pre class="text-xs bg-gray-50 rounded p-3 max-h-64 overflow-y-auto border"><%= pretty_json(span.input) %></pre>
|
|
35
41
|
</div>
|
|
36
42
|
<% end %>
|
|
@@ -60,7 +66,7 @@
|
|
|
60
66
|
</div>
|
|
61
67
|
</details>
|
|
62
68
|
|
|
63
|
-
<%
|
|
64
|
-
<%= render partial: "llm_logs/traces/span_node", locals: { span: child, depth: depth + 1, trace: trace } %>
|
|
69
|
+
<% child_spans.fetch(span.id, []).each do |child| %>
|
|
70
|
+
<%= render partial: "llm_logs/traces/span_node", locals: { span: child, depth: depth + 1, trace: trace, child_spans: child_spans } %>
|
|
65
71
|
<% end %>
|
|
66
72
|
</div>
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
</div>
|
|
33
33
|
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
34
34
|
<dt class="text-xs font-medium text-gray-500 uppercase">Spans</dt>
|
|
35
|
-
<dd class="text-2xl font-semibold text-gray-900 mt-1"><%= @
|
|
35
|
+
<dd class="text-2xl font-semibold text-gray-900 mt-1"><%= @spans.size %></dd>
|
|
36
36
|
</div>
|
|
37
37
|
<div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
|
|
38
38
|
<dt class="text-xs font-medium text-gray-500 uppercase">Input Tokens</dt>
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
<div class="p-4">
|
|
77
77
|
<% if @root_spans.any? %>
|
|
78
78
|
<% @root_spans.each do |span| %>
|
|
79
|
-
<%= render partial: "llm_logs/traces/span_node", locals: { span: span, depth: 0, trace: @trace } %>
|
|
79
|
+
<%= render partial: "llm_logs/traces/span_node", locals: { span: span, depth: 0, trace: @trace, child_spans: @child_spans } %>
|
|
80
80
|
<% end %>
|
|
81
81
|
<% else %>
|
|
82
82
|
<p class="text-sm text-gray-500">No spans recorded.</p>
|
|
@@ -105,14 +105,20 @@ module LlmLogs
|
|
|
105
105
|
|
|
106
106
|
chat = payload[:chat]
|
|
107
107
|
payload[USAGE_START_KEY] = chat.usage_entries.length if chat.respond_to?(:usage_entries)
|
|
108
|
-
|
|
108
|
+
messages = Serialize.messages(payload[:input_messages])
|
|
109
|
+
base = continued_round(messages)
|
|
110
|
+
metadata = request_metadata(payload)
|
|
111
|
+
metadata.merge!("input_base_span_id" => base[:span_id], "input_offset" => base[:messages].size) if base
|
|
112
|
+
span = LlmLogs::Tracer.start_span(
|
|
109
113
|
name: "chat.complete",
|
|
110
114
|
span_type: "llm",
|
|
111
115
|
model: payload[:model],
|
|
112
116
|
provider: payload[:provider],
|
|
113
|
-
input:
|
|
114
|
-
metadata:
|
|
117
|
+
input: base ? messages.drop(base[:messages].size) : messages,
|
|
118
|
+
metadata: metadata
|
|
115
119
|
)
|
|
120
|
+
LlmLogs::Tracer.last_llm_round = {trace_id: span.trace_id, span_id: span.id, messages: messages}
|
|
121
|
+
payload[SPAN_KEY] = span
|
|
116
122
|
end
|
|
117
123
|
end
|
|
118
124
|
|
|
@@ -137,6 +143,21 @@ module LlmLogs
|
|
|
137
143
|
|
|
138
144
|
private
|
|
139
145
|
|
|
146
|
+
# Every round of a chat resends the whole conversation. When this round's messages
|
|
147
|
+
# start with the trace's previous round's, the span stores only the new ones and
|
|
148
|
+
# points at that round; Span#full_input puts them back together. Anything else
|
|
149
|
+
# (another chat in the same trace, a rewritten history) stores the full input.
|
|
150
|
+
def continued_round(messages)
|
|
151
|
+
base = LlmLogs::Tracer.last_llm_round
|
|
152
|
+
trace = LlmLogs::Tracer.current_trace
|
|
153
|
+
return nil unless base && trace && base[:trace_id] == trace.id
|
|
154
|
+
|
|
155
|
+
prefix = base[:messages]
|
|
156
|
+
return nil if prefix.empty? || prefix.size > messages.size
|
|
157
|
+
|
|
158
|
+
messages.first(prefix.size) == prefix ? base : nil
|
|
159
|
+
end
|
|
160
|
+
|
|
140
161
|
def request_metadata(payload)
|
|
141
162
|
{
|
|
142
163
|
"streaming" => payload[:streaming],
|
data/lib/llm_logs/tracer.rb
CHANGED
|
@@ -17,6 +17,16 @@ module LlmLogs
|
|
|
17
17
|
Fiber[:llm_logs_span] = span
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The current trace's latest llm round, {trace_id:, span_id:, messages:}, kept so the next
|
|
21
|
+
# round can store only the messages it adds (see Instrumentation::RubyLlmChat).
|
|
22
|
+
def self.last_llm_round
|
|
23
|
+
Fiber[:llm_logs_last_llm_round]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.last_llm_round=(round)
|
|
27
|
+
Fiber[:llm_logs_last_llm_round] = round
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
def self.start_trace(name, metadata: {})
|
|
21
31
|
trace = LlmLogs::Trace.create!(
|
|
22
32
|
name: name,
|
|
@@ -27,8 +37,10 @@ module LlmLogs
|
|
|
27
37
|
|
|
28
38
|
previous_trace = Fiber[:llm_logs_trace]
|
|
29
39
|
previous_span = Fiber[:llm_logs_span]
|
|
40
|
+
previous_round = Fiber[:llm_logs_last_llm_round]
|
|
30
41
|
Fiber[:llm_logs_trace] = trace
|
|
31
42
|
Fiber[:llm_logs_span] = nil
|
|
43
|
+
Fiber[:llm_logs_last_llm_round] = nil
|
|
32
44
|
|
|
33
45
|
begin
|
|
34
46
|
yield trace
|
|
@@ -39,6 +51,7 @@ module LlmLogs
|
|
|
39
51
|
trace.complete! if trace.status == "running"
|
|
40
52
|
Fiber[:llm_logs_trace] = previous_trace
|
|
41
53
|
Fiber[:llm_logs_span] = previous_span
|
|
54
|
+
Fiber[:llm_logs_last_llm_round] = previous_round
|
|
42
55
|
end
|
|
43
56
|
end
|
|
44
57
|
|
data/lib/llm_logs/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_logs
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton
|
|
@@ -189,6 +189,7 @@ files:
|
|
|
189
189
|
- app/helpers/llm_logs/prompts_helper.rb
|
|
190
190
|
- app/jobs/llm_logs/batch/flush_job.rb
|
|
191
191
|
- app/jobs/llm_logs/batch/poll_job.rb
|
|
192
|
+
- app/jobs/llm_logs/purge_job.rb
|
|
192
193
|
- app/models/llm_logs/application_record.rb
|
|
193
194
|
- app/models/llm_logs/batch.rb
|
|
194
195
|
- app/models/llm_logs/batch/adapters/bedrock.rb
|