Skip to content

Commit d10152a

Browse files
authored
Merge pull request #656 from ruby/log-excerpt-search
Add full-text search over build failure logs
2 parents 414ffb5 + 32f2d6e commit d10152a

12 files changed

Lines changed: 648 additions & 3 deletions

File tree

app/assets/stylesheets/application.css

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,49 @@ table.reports td.diff div {
237237
text-overflow: ellipsis;
238238
width: 100px;
239239
}
240+
241+
/* Search form */
242+
243+
form.search-form {
244+
max-width: 55em;
245+
}
246+
247+
form.search-form .search-row {
248+
display: flex;
249+
align-items: center;
250+
gap: 0.5em;
251+
margin: 0.5em 0;
252+
}
253+
254+
form.search-form .search-row label {
255+
display: flex;
256+
align-items: center;
257+
gap: 0.25em;
258+
}
259+
260+
form.search-form .search-text-row label {
261+
flex: 1;
262+
}
263+
264+
form.search-form .search-text-row input[type="text"] {
265+
flex: 1;
266+
}
267+
268+
form.search-form .search-filter-row {
269+
justify-content: space-between;
270+
}
271+
272+
/* Search results reuse table.reports, but their columns are in a different
273+
order, so the reports failure-column rule lands on Match and Diff. */
274+
table.search-results td.match,
275+
table.search-results td.diff {
276+
background-color: transparent;
277+
}
278+
279+
table.search-results td.match code {
280+
display: block;
281+
overflow: hidden;
282+
white-space: nowrap;
283+
text-overflow: ellipsis;
284+
width: 320px;
285+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
class SearchController < ApplicationController
2+
PER_PAGE = 50
3+
MAX_PAGE = 200
4+
5+
# GET /search
6+
# Searches log excerpts of failed builds. Reports are narrowed by the
7+
# indexed columns (server_id, branch, datetime, revision) first, then
8+
# matched against log_excerpts.content (trigram GIN index on PostgreSQL).
9+
def index
10+
@q = params[:q].to_s.strip
11+
@server_id = params[:server].to_s[/\A\d+\z/]
12+
@branch = params[:branch].to_s.strip
13+
@revision = params[:revision].to_s.strip
14+
@from = parse_time(params[:from])
15+
@to = parse_time(params[:to], end_of_day: true)
16+
# to_s first: params[:page] is an Array for ?page[]=1, which has no to_i.
17+
# Clamping also keeps OFFSET within range for absurd page numbers.
18+
@page = params[:page].to_s.to_i.clamp(1, MAX_PAGE)
19+
@servers = Server.order(:ordinal).to_a
20+
21+
@searched = @q.present? || @revision.present? || @branch.present? ||
22+
@server_id.present? || @from || @to
23+
unless @searched
24+
@reports = []
25+
return
26+
end
27+
28+
reports = Report.joins(:log_excerpt).includes(:server, :log_excerpt).
29+
order("reports.datetime DESC")
30+
reports = reports.where(server_id: @server_id) if @server_id
31+
reports = reports.where(branch: @branch) if @branch.present?
32+
reports = reports.where("reports.datetime >= ?", @from) if @from
33+
reports = reports.where("reports.datetime <= ?", @to) if @to
34+
if @revision.present?
35+
reports = reports.where("reports.revision LIKE ?", "#{Report.sanitize_sql_like(@revision)}%")
36+
end
37+
reports = reports.merge(LogExcerpt.content_match(@q)) if @q.present?
38+
39+
page = reports.limit(PER_PAGE + 1).offset((@page - 1) * PER_PAGE).to_a
40+
@has_next = page.size > PER_PAGE
41+
@reports = page.first(PER_PAGE)
42+
end
43+
44+
private
45+
46+
def parse_time(str, end_of_day: false)
47+
return nil if str.blank?
48+
date = Date.parse(str) rescue nil
49+
return nil unless date
50+
time = Time.utc(date.year, date.month, date.day)
51+
end_of_day ? time + 86399 : time
52+
end
53+
end

app/models/log_excerpt.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
require 'net/http'
2+
require 'zlib'
3+
require 'stringio'
4+
5+
class LogExcerpt < ApplicationRecord
6+
belongs_to :report
7+
8+
MAX_CONTENT_BYTES = 100_000
9+
10+
# Fetch fail.txt and diff.txt from S3 for the report and store them as a
11+
# searchable excerpt. Creates an empty-content row when nothing is available
12+
# (missing or empty objects) so that backfill can resume by max report_id.
13+
# Network errors are raised to the caller.
14+
def self.capture(report)
15+
return nil unless %r{\Ahttps?://rubyci\.s3\.amazonaws\.com/}.match?(report.server&.uri.to_s)
16+
content = fetch_content(report)
17+
excerpt = find_or_initialize_by(report_id: report.id)
18+
excerpt.content = content
19+
excerpt.save!
20+
excerpt
21+
end
22+
23+
def self.fetch_content(report)
24+
sections = [report.failtxt_uri, report.difftxt_uri].filter_map do |uri|
25+
body = fetch_text(uri)
26+
body unless body.nil? || body.empty?
27+
end
28+
return "" if sections.empty?
29+
# Budget each section separately, otherwise a fail.txt over the limit
30+
# crowds the diff out entirely. A section under budget donates its
31+
# remainder to the other one. The separators come out of the budget so
32+
# the joined result still fits in MAX_CONTENT_BYTES.
33+
budget = (MAX_CONTENT_BYTES - (sections.size - 1)) / sections.size
34+
slack = sections.sum { |s| [budget - s.bytesize, 0].max }
35+
sections.map { |s| truncate_bytes(s, budget + slack) }.join("\n")
36+
end
37+
38+
# GET a gzipped text file. Returns nil on 404. S3 serves these either with
39+
# Content-Encoding: gzip (Net::HTTP inflates the body) or as raw gzip bytes.
40+
def self.fetch_text(uri)
41+
uri = URI(uri)
42+
res = Net::HTTP.start(uri.host, uri.port, open_timeout: 10, read_timeout: 30, use_ssl: uri.scheme == "https") do |h|
43+
h.get(uri.path)
44+
end
45+
return nil if res.code == "404"
46+
res.value
47+
body = res.body
48+
begin
49+
body = Zlib::GzipReader.new(StringIO.new(body)).read
50+
rescue Zlib::Error
51+
end
52+
body.force_encoding(Encoding::UTF_8).scrub("?").tr("\0", "")
53+
end
54+
55+
def self.truncate_bytes(str, max)
56+
return str if str.bytesize <= max
57+
str.byteslice(0, max).scrub("")
58+
end
59+
60+
# Substring match on content. On PostgreSQL the trigram GIN index makes
61+
# ILIKE efficient; SQLite (development/test) falls back to plain LIKE.
62+
def self.content_match(query)
63+
operator = defined?(SQLite3) ? "LIKE" : "ILIKE"
64+
# explicit ESCAPE: SQLite's LIKE has no default escape character
65+
where("log_excerpts.content #{operator} ? ESCAPE '\\'", "%#{sanitize_sql_like(query)}%")
66+
end
67+
68+
def matching_line(query)
69+
return nil if query.blank?
70+
q = query.downcase
71+
line = content.each_line.find { |l| l.downcase.include?(q) }
72+
line&.strip&.truncate(200)
73+
end
74+
end

app/models/report.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
class Report < ApplicationRecord
88
belongs_to :server
9+
has_one :log_excerpt, dependent: :delete
910
validates :server_id, :presence => true
1011
# SVN revision number or full 40-hex git commit SHA
1112
validates :revision, :format => { :with => /\A(?:\d+|\h{40})\z/ }, allow_nil: true
@@ -130,6 +131,21 @@ def recenturi
130131
server.recent_uri(branch_opts)
131132
end
132133

134+
def failtxt_uri
135+
s3txt_uri('compressed_failhtml_relpath', 'fail')
136+
end
137+
138+
def difftxt_uri
139+
s3txt_uri('compressed_diffhtml_relpath', 'diff')
140+
end
141+
142+
def s3txt_uri(key, kind)
143+
relpath = meta&.[](key)&.sub('.html.gz', '.txt.gz') ||
144+
datetime.strftime("log/%Y%m%dT%H%M%SZ.#{kind}.txt.gz")
145+
"#{server.uri.chomp('/')}/#{depsuffixed_name}/#{relpath}"
146+
end
147+
private :s3txt_uri
148+
133149
def meta
134150
if defined?(@meta)
135151
@meta
@@ -174,7 +190,7 @@ def self.scan_recent_ltsv(server, depsuffixed_name, body)
174190
diff = h["different_sections"]
175191
summary << (diff ? " (diff:#{diff})" : " (no diff)")
176192

177-
Report.create!(
193+
report = Report.create!(
178194
server_id: server.id,
179195
datetime: datetime,
180196
branch: branch,
@@ -183,6 +199,13 @@ def self.scan_recent_ltsv(server, depsuffixed_name, body)
183199
ltsv: line,
184200
summary: summary.gsub(/<[^>]*>/, ''),
185201
)
202+
if h["result"] != "success"
203+
begin
204+
LogExcerpt.capture(report)
205+
rescue => e
206+
warn [e, server.uri, "failed to capture log excerpt", report.id].inspect
207+
end
208+
end
186209
end
187210
rescue RuntimeError => e # It seems not a chkbuild log
188211
warn [e, server.uri, path, "failed to scan_reports"].inspect

app/views/layouts/application.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<ul class="navbar-nav">
2020
<li class="nav-item"><%= link_to "Current Reports", "/reports", class: "nav-link" %></li>
2121
<li class="nav-item"><%= link_to "Latest Reports", "/", class: "nav-link" %></li>
22+
<li class="nav-item"><%= link_to "Search", "/search", class: "nav-link" %></li>
2223
</ul>
2324
</div>
2425
</nav>

app/views/search/index.html.erb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<% content_for(:title) { "Search failures - RubyCI" } %>
2+
3+
<h3>Search failure logs</h3>
4+
5+
<%= form_with url: search_path, method: :get, local: true, html: { class: "search-form" } do %>
6+
<p class="search-row search-text-row">
7+
<label>Text: <%= text_field_tag :q, @q, placeholder: "test name, exception class, error message ..." %></label>
8+
<%# The revision query parameter is kept for machine clients (planned MCP
9+
server) but intentionally has no visible form field. %>
10+
<%= hidden_field_tag :revision, @revision if @revision.present? %>
11+
<%= submit_tag "Search" %>
12+
</p>
13+
<p class="search-row search-filter-row">
14+
<label>Server: <%= select_tag :server, options_for_select(@servers.map {|s| [s.name, s.id] }, @server_id), include_blank: "(all)" %></label>
15+
<label>Branch: <%= text_field_tag :branch, @branch, size: 10, placeholder: "master" %></label>
16+
<label>From: <%= date_field_tag :from, params[:from] %></label>
17+
<label>To: <%= date_field_tag :to, params[:to] %></label>
18+
</p>
19+
<% end %>
20+
21+
<% if @searched %>
22+
<% if @reports.empty? %>
23+
<p>No matching failure logs found<%= " on page #{@page}" if @page > 1 %>.</p>
24+
<% else %>
25+
<div class="table-responsive table-responsive-border">
26+
<table class="reports search-results table table-striped table-bordered">
27+
<thead>
28+
<tr>
29+
<th>Server</th>
30+
<th>Datetime</th>
31+
<th>Branch</th>
32+
<th>Option</th>
33+
<th>Revision</th>
34+
<th>Summary</th>
35+
<th>Match</th>
36+
<th>Diff</th>
37+
</tr>
38+
</thead>
39+
<tbody>
40+
<% @reports.each do |report| %>
41+
<%
42+
failuri = report.failuri || report.loguri
43+
if report.revision&.match?(/\A\d+\z/)
44+
revision = "r#{report.revision}"
45+
elsif report.sha1
46+
revision = report.sha1[0, 11]
47+
end
48+
revision_link = link_to revision, report.revisionuri if revision
49+
snippet = report.log_excerpt&.matching_line(@q)
50+
%>
51+
<tr>
52+
<td class="server"><%= report.server.name %></td>
53+
<td class="datetime"><%= link_to report.sjstdt, report.loguri, title: report.jstdt %></td>
54+
<td class="branch"><%= report.branch %></td>
55+
<td class="option"><%= report.option %></td>
56+
<td class="revision"><%= revision_link %></td>
57+
<td class="summary failure"><div><%= link_to report.shortsummary, failuri, title: report.shortsummary %></div></td>
58+
<td class="match"><code title="<%= snippet %>"><%= snippet %></code></td>
59+
<td class="diff"><div><%= link_to report.diffstat, report.diffuri, title: report.diffstat %></div></td>
60+
</tr>
61+
<% end %>
62+
</tbody>
63+
</table>
64+
</div>
65+
<p>
66+
<% if @page > 1 %>
67+
<%= link_to "Prev", search_path(request.query_parameters.merge("page" => @page - 1)) %>
68+
<% end %>
69+
<% if @has_next %>
70+
<%= link_to "Next", search_path(request.query_parameters.merge("page" => @page + 1)) %>
71+
<% end %>
72+
</p>
73+
<% end %>
74+
<% else %>
75+
<p>Search the excerpted fail/diff logs of failed builds. Datetimes are UTC.</p>
76+
<% end %>

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Rails.application.routes.draw do
22
root :to => 'reports#current'
33

4+
get "search" => "search#index"
5+
46
resources :reports, only: [:show, :index] do
57
collection do
68
get "current"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class CreateLogExcerpts < ActiveRecord::Migration[8.1]
2+
def change
3+
create_table :log_excerpts do |t|
4+
t.integer :report_id, null: false
5+
t.text :content, null: false
6+
t.timestamps
7+
t.index [:report_id], unique: true
8+
end
9+
add_foreign_key :log_excerpts, :reports
10+
11+
# pg_trgm and GIN index are PostgreSQL-only; development and test run on SQLite
12+
if connection.adapter_name == "PostgreSQL"
13+
enable_extension "pg_trgm"
14+
add_index :log_excerpts, :content, using: :gin, opclass: :gin_trgm_ops,
15+
name: "index_log_excerpts_on_content_trgm"
16+
end
17+
end
18+
end

db/schema.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.1].define(version: 2026_07_27_095902) do
13+
ActiveRecord::Schema[8.1].define(version: 2026_07_28_023000) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "pg_catalog.plpgsql"
16+
enable_extension "pg_trgm"
1617

1718
create_table "active_storage_attachments", force: :cascade do |t|
1819
t.bigint "blob_id", null: false
@@ -42,6 +43,15 @@
4243
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
4344
end
4445

46+
create_table "log_excerpts", force: :cascade do |t|
47+
t.text "content", null: false
48+
t.datetime "created_at", null: false
49+
t.integer "report_id", null: false
50+
t.datetime "updated_at", null: false
51+
t.index ["content"], name: "index_log_excerpts_on_content_trgm", opclass: :gin_trgm_ops, using: :gin
52+
t.index ["report_id"], name: "index_log_excerpts_on_report_id", unique: true
53+
end
54+
4555
create_table "recents", force: :cascade do |t|
4656
t.datetime "created_at", null: false
4757
t.string "etag", null: false
@@ -63,7 +73,7 @@
6373
t.datetime "updated_at", precision: nil
6474
t.index ["branch"], name: "index_reports_on_branch"
6575
t.index ["datetime"], name: "index_reports_on_datetime"
66-
t.index ["revision"], name: "index_reports_on_revision", opclass: :varchar_pattern_ops
76+
t.index ["revision"], name: "index_reports_on_revision"
6777
t.index ["server_id", "branch", "option"], name: "index_reports_on_server_id_and_branch_and_option"
6878
end
6979

@@ -79,5 +89,6 @@
7989

8090
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
8191
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
92+
add_foreign_key "log_excerpts", "reports"
8293
add_foreign_key "recents", "servers"
8394
end

0 commit comments

Comments
 (0)