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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31867809a66b41a89465171c11b5706ef364f9276fbd145f9545cd7b5c27768f
4
- data.tar.gz: 58dc6a88ba5b337420a380fee8525b0a84ff8e32612cc05c56fd1899ca91dbbc
3
+ metadata.gz: 5bc6872f875f8ceddec93621ab746e249266100a8bd1172645fc0d3f24bdf5b7
4
+ data.tar.gz: 842fdce69e1c3773e65adc38144738ae0dd2e7eb579d5826958fd42858fb6488
5
5
  SHA512:
6
- metadata.gz: 83d8bca6f9869ef0e40e9862e04228e4a3830b9d5cd17d29335aad9422c5b401fe70e655a5e58cb0f0cdcdfe40e5de6487a1b144d6f37b78000feba19ed4039a
7
- data.tar.gz: 94ab95a5f5213a5ab7dbd3294e887db24191bcb16d080928e394b3e51d53f00e7ae5a25a8a572401ccbd283c33a137d242c71dcbc174c33f64754a28cdfc9c6f
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 # for future cleanup job
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).includes(:versions)
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
@@ -3,6 +3,7 @@ module LlmLogs
3
3
  def show
4
4
  @trace = Trace.find(params[:trace_id])
5
5
  @span = @trace.spans.find(params[:id])
6
+ @input = @span.full_input
6
7
  end
7
8
  end
8
9
  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
- @root_spans = @trace.root_spans
16
- @models = @trace.spans.where(span_type: "llm").distinct.pluck(:model).compact
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
 
@@ -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
- self.total_input_tokens = spans.sum(:input_tokens)
42
- self.total_output_tokens = spans.sum(: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.versions.max_by(&:version_number) %>
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 @span.input.present? %>
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<%= @span.input.is_a?(Array) ? " Messages" : "" %></h2>
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 @span.input.is_a?(Array) %>
55
- <% @span.input.each do |msg| %>
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(@span.input) %></pre>
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
- <% 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 } %>
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"><%= @trace.spans.count %></dd>
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
- payload[SPAN_KEY] = LlmLogs::Tracer.start_span(
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: Serialize.messages(payload[:input_messages]),
114
- metadata: request_metadata(payload)
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],
@@ -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
 
@@ -1,3 +1,3 @@
1
1
  module LlmLogs
2
- VERSION = "0.7.1"
2
+ VERSION = "0.8.0"
3
3
  end
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.7.1
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