flare 0.4.2 → 0.5.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 +60 -1
- data/app/controllers/flare/jobs_controller.rb +1 -1
- data/app/controllers/flare/requests_controller.rb +1 -1
- data/app/controllers/flare/spans_controller.rb +1 -1
- data/app/helpers/flare/application_helper.rb +23 -0
- data/app/views/flare/jobs/index.html.erb +2 -1
- data/app/views/flare/requests/index.html.erb +2 -1
- data/app/views/flare/requests/show.html.erb +16 -7
- data/app/views/flare/spans/index.html.erb +2 -1
- data/lib/flare/cli/doctor_command.rb +4 -11
- data/lib/flare/cli/local_spans.rb +110 -0
- data/lib/flare/cli/nplus1_command.rb +97 -0
- data/lib/flare/cli/project.rb +28 -0
- data/lib/flare/cli/request_command.rb +120 -0
- data/lib/flare/cli/requests_command.rb +45 -0
- data/lib/flare/cli/setup_command.rb +64 -41
- data/lib/flare/cli/status_command.rb +5 -2
- data/lib/flare/cli.rb +12 -0
- data/lib/flare/client_headers.rb +7 -3
- data/lib/flare/configuration.rb +20 -0
- data/lib/flare/export_filter.rb +273 -0
- data/lib/flare/filtering_span_processor.rb +14 -0
- data/lib/flare/http_transport.rb +4 -0
- data/lib/flare/metric_span_processor.rb +51 -35
- data/lib/flare/metric_submitter.rb +9 -1
- data/lib/flare/recording_batch_span_processor.rb +9 -1
- data/lib/flare/redis_statement_processor.rb +29 -0
- data/lib/flare/revision.rb +82 -0
- data/lib/flare/source_location.rb +21 -16
- data/lib/flare/sql_mask.rb +235 -0
- data/lib/flare/sqlite_exporter.rb +12 -8
- data/lib/flare/storage/sqlite.rb +195 -144
- data/lib/flare/storage.rb +1 -40
- data/lib/flare/store_statement.rb +136 -0
- data/lib/flare/trace_blob.rb +9 -5
- data/lib/flare/trace_exporter.rb +18 -3
- data/lib/flare/upload_url_pool.rb +2 -15
- data/lib/flare/utf8.rb +20 -0
- data/lib/flare/version.rb +1 -1
- data/lib/flare.rb +21 -2
- data/public/flare-assets/flare.css +10 -60
- metadata +45 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1bc922e7eef0dab2d93ab0a6d765a6d5cc5f1dbf40f322774fe6e8ceb146d626
|
|
4
|
+
data.tar.gz: 9bab57b668d51b8c7d2047611443bf36dfb77fa76d7827acc4444749c622529b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28b4a678ea6563eba5449a5864953cefa7218eb4531de4a8deeb33ead3d9bc5a138ee4f310b01b147b808e820c2091494116beae9ebc0fb8db614f8a8c0eb7a5
|
|
7
|
+
data.tar.gz: 45b32e82c6b8e4470d09eab648911541a1a7f76414b16f4d2b53cac2a11628f4f8b7b04b144bc55ef03f03d4849f6b39756b65bedbd7511f5cfe6f01980b89b1
|
data/README.md
CHANGED
|
@@ -60,8 +60,58 @@ To enable metrics, set `FLARE_KEY` in your environment (get one at [flare.am](ht
|
|
|
60
60
|
flare setup # Authenticate and configure Flare
|
|
61
61
|
flare doctor # Check your setup for issues
|
|
62
62
|
flare status # Show current configuration
|
|
63
|
+
flare requests # List recent requests from local development spans
|
|
64
|
+
flare request # Show one request's timeline from local development spans
|
|
65
|
+
flare nplus1 # Find queries repeated within recent requests from local development spans
|
|
63
66
|
```
|
|
64
67
|
|
|
68
|
+
`flare requests` reads the local spans database, so you or a coding agent can make a request and check its duration and query count without opening `/flare`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
$ bundle exec flare requests --last 2
|
|
72
|
+
2026-10-27T20:14:03.412-04:00 GET 200 48.2ms 12 queries /posts PostsController#index 4bf92f3577b34da6a3ce929d0e0e4736
|
|
73
|
+
2026-10-27T20:13:41.087-04:00 GET 404 3.0ms 1 queries /posts/9 PostsController#show 0af7651916cd43dd8448eb211c80319c
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`flare request <trace_id>` shows what one of those requests did, in order, with the code that ran each query:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
$ bundle exec flare request 4bf92f3577b34da6a3ce929d0e0e4736
|
|
80
|
+
GET /posts 200 48.2ms PostsController#index
|
|
81
|
+
Started 2026-10-27T20:14:03.412-04:00 12 queries
|
|
82
|
+
|
|
83
|
+
offset duration
|
|
84
|
+
1.2ms 0.4ms sql.active_record: SELECT "posts".* FROM "posts" app/controllers/posts_controller.rb:5
|
|
85
|
+
2.1ms 0.2ms sql.active_record: SELECT "users".* FROM "users" WHERE "users"."id" = ? app/views/posts/index.html.erb:3
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`flare nplus1` checks recent requests for the same query running more than once, ignoring the values bound to it, and names the code that ran it. Add `--last N` to check more or fewer requests than the default 10:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
$ bundle exec flare nplus1
|
|
92
|
+
GET /posts 200 48.2ms PostsController#index 4bf92f3577b34da6a3ce929d0e0e4736
|
|
93
|
+
10x 2.3ms SELECT "users".* FROM "users" WHERE "users"."id" = ?
|
|
94
|
+
10x app/views/posts/index.html.erb:3
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
All three commands take `--json` for machine-readable output and `--db PATH` if you changed `database_path`.
|
|
98
|
+
|
|
99
|
+
### Coding agents
|
|
100
|
+
|
|
101
|
+
Add something like this to your app's `AGENTS.md` or `CLAUDE.md` so coding agents (Claude Code, Codex, Cursor) use these commands:
|
|
102
|
+
|
|
103
|
+
```markdown
|
|
104
|
+
## Checking performance
|
|
105
|
+
|
|
106
|
+
Flare records every request in development. After changing a controller, view or query, make the request, then:
|
|
107
|
+
|
|
108
|
+
- `bundle exec flare requests --last 1 --json` shows its duration, query count and trace_id
|
|
109
|
+
- `bundle exec flare nplus1 --last 1 --json` shows any query it ran more than once, with the code that ran it
|
|
110
|
+
- `bundle exec flare request <trace_id> --json` shows every query in order, when you need the full timeline
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
No Flare account or network access needed.
|
|
114
|
+
|
|
65
115
|
## Configuration
|
|
66
116
|
|
|
67
117
|
All configuration is optional. Flare works out of the box with sensible defaults.
|
|
@@ -87,6 +137,11 @@ Flare.configure do |config|
|
|
|
87
137
|
|
|
88
138
|
# Subscribe to custom notification prefixes (default: ["app."])
|
|
89
139
|
config.subscribe_patterns << "mycompany."
|
|
140
|
+
|
|
141
|
+
# The deployed revision, shown as deploy markers on your charts
|
|
142
|
+
# (default: FLARE_REVISION, then your platform's commit variable, a
|
|
143
|
+
# REVISION file, or git rev-parse HEAD)
|
|
144
|
+
config.revision = ENV["MY_BUILD_SHA"]
|
|
90
145
|
end
|
|
91
146
|
```
|
|
92
147
|
|
|
@@ -135,13 +190,17 @@ Flare uses [OpenTelemetry](https://opentelemetry.io/) for instrumentation. It au
|
|
|
135
190
|
|
|
136
191
|
Spans are stored in a local SQLite database (`db/flare.sqlite3` by default) and automatically pruned based on retention settings.
|
|
137
192
|
|
|
193
|
+
### What leaves your app
|
|
194
|
+
|
|
195
|
+
Captured traces go through your app's `config.filter_parameters` before upload, including query strings, along with a default list of common credential names. SQL statements have their values replaced with `?`, Redis statements keep the command and the key's first segment, MongoDB commands keep their structure with values masked, URLs lose their userinfo and fragments, and mail recipients, subjects, job arguments and exception contents are left out. Cache keys keep only their leading word. Metrics carry names (controller, job, table, operation, host), not values, plus your app's deployed revision (usually a git SHA) so Flare can mark deploys. Your local development database and dashboard keep the full values, except Redis arguments, which aren't kept anywhere.
|
|
196
|
+
|
|
138
197
|
## Development
|
|
139
198
|
|
|
140
199
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
|
|
141
200
|
|
|
142
201
|
## Contributing
|
|
143
202
|
|
|
144
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
203
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/verygoodsoftwarellc/flare-gem.
|
|
145
204
|
|
|
146
205
|
## License
|
|
147
206
|
|
|
@@ -14,7 +14,7 @@ module Flare
|
|
|
14
14
|
name: params[:name].presence
|
|
15
15
|
}
|
|
16
16
|
# Fetch one extra to know if there's a next page
|
|
17
|
-
jobs = Flare.storage.list_jobs(**filter_params, limit: PER_PAGE + 1, offset: @offset)
|
|
17
|
+
jobs = Flare.storage.list_jobs(**filter_params, sort: params[:sort], limit: PER_PAGE + 1, offset: @offset)
|
|
18
18
|
@total_count = Flare.storage.count_jobs(**filter_params)
|
|
19
19
|
@has_next = jobs.size > PER_PAGE
|
|
20
20
|
@jobs = jobs.first(PER_PAGE)
|
|
@@ -17,7 +17,7 @@ module Flare
|
|
|
17
17
|
origin: current_origin
|
|
18
18
|
}
|
|
19
19
|
# Fetch one extra to know if there's a next page
|
|
20
|
-
requests = Flare.storage.list_requests(**filter_params, limit: PER_PAGE + 1, offset: @offset)
|
|
20
|
+
requests = Flare.storage.list_requests(**filter_params, sort: params[:sort], limit: PER_PAGE + 1, offset: @offset)
|
|
21
21
|
@total_count = Flare.storage.count_requests(**filter_params)
|
|
22
22
|
@has_next = requests.size > PER_PAGE
|
|
23
23
|
@requests = requests.first(PER_PAGE)
|
|
@@ -64,7 +64,7 @@ module Flare
|
|
|
64
64
|
@offset = params[:offset].to_i
|
|
65
65
|
filter_params = { name: params[:name].presence }
|
|
66
66
|
|
|
67
|
-
spans = Flare.storage.list_spans_by_category(category, **filter_params, limit: PER_PAGE + 1, offset: @offset)
|
|
67
|
+
spans = Flare.storage.list_spans_by_category(category, **filter_params, sort: params[:sort], limit: PER_PAGE + 1, offset: @offset)
|
|
68
68
|
@total_count = Flare.storage.count_spans_by_category(category, **filter_params)
|
|
69
69
|
@has_next = spans.size > PER_PAGE
|
|
70
70
|
@spans = spans.first(PER_PAGE)
|
|
@@ -100,6 +100,29 @@ module Flare
|
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def bookkeeping_query?(span)
|
|
104
|
+
Storage::SQLite.bookkeeping_query?(span[:properties] || {})
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sorted_by_slowest?
|
|
108
|
+
params[:sort] == "slowest"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Links the Duration heading to the slowest first, or back to newest first,
|
|
112
|
+
# keeping the filters and starting again from the first page.
|
|
113
|
+
def duration_sort_link
|
|
114
|
+
query = request.query_parameters.except("offset", "sort")
|
|
115
|
+
label, css, title = "Duration", "sort-link", "Sort by slowest first"
|
|
116
|
+
|
|
117
|
+
if sorted_by_slowest?
|
|
118
|
+
label, css, title = "Duration \u2193", "sort-link active", "Back to newest first"
|
|
119
|
+
else
|
|
120
|
+
query = query.merge("sort" => "slowest")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
link_to label, [request.path, query.to_query.presence].compact.join("?"), class: css, title: title
|
|
124
|
+
end
|
|
125
|
+
|
|
103
126
|
def format_duration(ms)
|
|
104
127
|
return "-" if ms.nil?
|
|
105
128
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<h1 class="page-title"><%= page_title %></h1>
|
|
3
3
|
<div class="filters">
|
|
4
4
|
<%= form_tag(jobs_path, method: :get, id: "filter-form") do %>
|
|
5
|
+
<%= hidden_field_tag :sort, "slowest" if sorted_by_slowest? %>
|
|
5
6
|
<%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
|
|
6
7
|
<% end %>
|
|
7
8
|
</div>
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
<thead>
|
|
24
25
|
<tr>
|
|
25
26
|
<th>Job</th>
|
|
26
|
-
<th style="width: 100px; text-align: right;"
|
|
27
|
+
<th style="width: 100px; text-align: right;"><%= duration_sort_link %></th>
|
|
27
28
|
<th style="width: 120px; text-align: right;">Happened</th>
|
|
28
29
|
</tr>
|
|
29
30
|
</thead>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<h1 class="page-title"><%= page_title %></h1>
|
|
3
3
|
<div class="filters">
|
|
4
4
|
<%= form_tag(requests_path, method: :get, id: "filter-form") do %>
|
|
5
|
+
<%= hidden_field_tag :sort, "slowest" if sorted_by_slowest? %>
|
|
5
6
|
<%= select_tag :origin,
|
|
6
7
|
options_for_select([
|
|
7
8
|
["All Origins", ""],
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
<th>Name</th>
|
|
55
56
|
<th style="width: 70px;">Verb</th>
|
|
56
57
|
<th style="width: 80px; text-align: right;">Status</th>
|
|
57
|
-
<th style="width: 100px; text-align: right;"
|
|
58
|
+
<th style="width: 100px; text-align: right;"><%= duration_sort_link %></th>
|
|
58
59
|
<th style="width: 120px; text-align: right;">Happened</th>
|
|
59
60
|
</tr>
|
|
60
61
|
</thead>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<%
|
|
2
2
|
# Get root span properties
|
|
3
3
|
root_props = @root_span ? @root_span[:properties] : {}
|
|
4
|
-
http_method = root_props["http.request.method"]
|
|
5
|
-
http_status = root_props["http.response.status_code"]
|
|
6
|
-
http_target = root_props["url.path"]
|
|
4
|
+
http_method = root_props["http.request.method"] || root_props["http.method"]
|
|
5
|
+
http_status = root_props["http.response.status_code"] || root_props["http.status_code"]
|
|
6
|
+
http_target = root_props["url.path"] || root_props["http.target"]
|
|
7
7
|
controller = root_props["code.namespace"]
|
|
8
8
|
action = root_props["code.function"]
|
|
9
9
|
|
|
@@ -306,6 +306,7 @@
|
|
|
306
306
|
name: span[:name],
|
|
307
307
|
duration_ms: span[:duration_ms],
|
|
308
308
|
offset_ms: (span[:start_timestamp] - root_start) / 1_000_000.0,
|
|
309
|
+
bookkeeping: bookkeeping_query?(span),
|
|
309
310
|
properties: span[:properties]
|
|
310
311
|
}
|
|
311
312
|
}.to_json.html_safe %>;
|
|
@@ -334,9 +335,10 @@
|
|
|
334
335
|
output.push('> Given this Rails request trace, how would you improve performance? What information is missing that would help diagnose issues?');
|
|
335
336
|
output.push('');
|
|
336
337
|
|
|
337
|
-
|
|
338
|
+
// The method and path, else the span name, which often has the method already.
|
|
339
|
+
const title = requestData.http_method && requestData.http_target ? requestData.http_method + ' ' + requestData.http_target : requestData.name;
|
|
338
340
|
const status = requestData.http_status || '200';
|
|
339
|
-
output.push('# ' +
|
|
341
|
+
output.push('# ' + title + ' [' + status + '] - ' + formatDuration(requestData.duration_ms));
|
|
340
342
|
if (requestData.controller && requestData.action) {
|
|
341
343
|
output.push('Controller: ' + requestData.controller + '#' + requestData.action);
|
|
342
344
|
}
|
|
@@ -346,10 +348,17 @@
|
|
|
346
348
|
output.push('');
|
|
347
349
|
output.push('- **Total:** ' + formatDuration(requestData.duration_ms));
|
|
348
350
|
|
|
349
|
-
|
|
351
|
+
// Count and rank the app's queries the way flare requests does, so
|
|
352
|
+
// schema lookups and BEGIN/COMMIT don't pass for slow app queries.
|
|
353
|
+
const sqlSpans = spansData.filter(function(s) { return getSpanCategory(s.name) === 'sql' && !s.bookkeeping; });
|
|
350
354
|
if (sqlSpans.length > 0) {
|
|
351
355
|
const sqlTotalMs = sqlSpans.reduce(function(sum, s) { return sum + (s.duration_ms || 0); }, 0);
|
|
352
|
-
|
|
356
|
+
const bookkeepingCount = spansData.filter(function(s) { return s.bookkeeping; }).length;
|
|
357
|
+
let sqlLine = '- **SQL:** ' + sqlSpans.length + ' queries, ' + formatDuration(sqlTotalMs) + ' total';
|
|
358
|
+
if (bookkeepingCount > 0) {
|
|
359
|
+
sqlLine += ' (plus ' + bookkeepingCount + ' schema and transaction statements from Active Record)';
|
|
360
|
+
}
|
|
361
|
+
output.push(sqlLine);
|
|
353
362
|
|
|
354
363
|
const slowest = sqlSpans.slice().sort(function(a, b) { return (b.duration_ms || 0) - (a.duration_ms || 0); }).slice(0, 3);
|
|
355
364
|
if (slowest.length > 0 && slowest[0].duration_ms > 1) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
<h1 class="page-title"><%= page_title %></h1>
|
|
7
7
|
<div class="filters">
|
|
8
8
|
<%= form_tag(current_path, method: :get, id: "filter-form") do %>
|
|
9
|
+
<%= hidden_field_tag :sort, "slowest" if sorted_by_slowest? %>
|
|
9
10
|
<%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
|
|
10
11
|
<% end %>
|
|
11
12
|
</div>
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
<% elsif @category == "cache" %>
|
|
34
35
|
<th style="width: 100px; text-align: right;">Operation</th>
|
|
35
36
|
<% end %>
|
|
36
|
-
<th style="width: 100px; text-align: right;"
|
|
37
|
+
<th style="width: 100px; text-align: right;"><%= duration_sort_link %></th>
|
|
37
38
|
<th style="width: 120px; text-align: right;">Happened</th>
|
|
38
39
|
</tr>
|
|
39
40
|
</thead>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "output"
|
|
4
|
+
require_relative "project"
|
|
4
5
|
|
|
5
6
|
module Flare
|
|
6
7
|
class DoctorCommand
|
|
7
8
|
include CLI::Output
|
|
9
|
+
include CLI::Project
|
|
8
10
|
|
|
9
11
|
def run
|
|
10
12
|
puts bold("Flare Doctor")
|
|
@@ -62,7 +64,7 @@ module Flare
|
|
|
62
64
|
|
|
63
65
|
contents = File.read(gitignore_path)
|
|
64
66
|
missing = []
|
|
65
|
-
missing << ".env"
|
|
67
|
+
missing << ".env" if key_in_dotenv? && !env_ignored?(contents)
|
|
66
68
|
missing << "flare.sqlite3*" unless contents.include?("flare.sqlite3")
|
|
67
69
|
|
|
68
70
|
if missing.empty?
|
|
@@ -99,16 +101,7 @@ module Flare
|
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
def key_configured?
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
env_path = File.join(Dir.pwd, ".env")
|
|
105
|
-
File.exist?(env_path) && File.read(env_path).match?(/^FLARE_KEY=.+/)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def credentials_exist?
|
|
109
|
-
%w[config/credentials.yml.enc config/credentials/production.yml.enc].any? do |path|
|
|
110
|
-
File.exist?(File.join(Dir.pwd, path))
|
|
111
|
-
end
|
|
104
|
+
(ENV["FLARE_KEY"] && !ENV["FLARE_KEY"].empty?) || key_in_dotenv?
|
|
112
105
|
end
|
|
113
106
|
|
|
114
107
|
def spans_expected?
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Flare
|
|
7
|
+
module CLI
|
|
8
|
+
# Read-only access to the local development spans database, for commands
|
|
9
|
+
# that inspect it from the terminal. Explains a missing or unreadable
|
|
10
|
+
# database and exits rather than printing a backtrace.
|
|
11
|
+
module LocalSpans
|
|
12
|
+
DEFAULT_LIMIT = 10
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Parses the options these commands share, returning the other arguments.
|
|
17
|
+
# Pass last_help for commands that read several requests.
|
|
18
|
+
def parse_options(argv, banner:, last_help: nil)
|
|
19
|
+
@limit = DEFAULT_LIMIT
|
|
20
|
+
@json = false
|
|
21
|
+
@database_path = default_database_path
|
|
22
|
+
|
|
23
|
+
OptionParser.new do |opts|
|
|
24
|
+
opts.banner = banner
|
|
25
|
+
if last_help
|
|
26
|
+
opts.on("--last N", Integer, "#{last_help} (default: #{DEFAULT_LIMIT})") do |n|
|
|
27
|
+
raise OptionParser::InvalidArgument, "#{n} (must be at least 1)" if n < 1
|
|
28
|
+
|
|
29
|
+
@limit = n
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
opts.on("--json", "Print JSON instead of text") { @json = true }
|
|
33
|
+
opts.on("--db PATH", "Spans database (default: db/flare.sqlite3)") { |path| @database_path = File.expand_path(path) }
|
|
34
|
+
end.parse!(argv)
|
|
35
|
+
rescue OptionParser::ParseError => e
|
|
36
|
+
$stderr.puts e.message
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def default_database_path
|
|
41
|
+
File.join(Dir.pwd, "db", "flare.sqlite3")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The fields every request-reading command reports, so their JSON stays
|
|
45
|
+
# in step.
|
|
46
|
+
def request_summary(span, method:, path:, status:, controller:, action:, queries:)
|
|
47
|
+
{
|
|
48
|
+
trace_id: span[:trace_id],
|
|
49
|
+
method: method,
|
|
50
|
+
path: path,
|
|
51
|
+
status: status,
|
|
52
|
+
duration_ms: span[:duration_ms].round(1),
|
|
53
|
+
queries: queries,
|
|
54
|
+
controller: controller,
|
|
55
|
+
action: action,
|
|
56
|
+
started_at: Time.at(span[:start_timestamp] / 1_000_000_000r).iso8601(3),
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def recent_requests(storage, limit)
|
|
61
|
+
requests = storage.list_requests(limit: limit)
|
|
62
|
+
query_counts = storage.count_queries_by_trace(requests.map { |r| r[:trace_id] })
|
|
63
|
+
|
|
64
|
+
requests.map do |r|
|
|
65
|
+
request_summary(r, method: r[:http_method], path: r[:http_target], status: r[:http_status],
|
|
66
|
+
controller: r[:controller], action: r[:action], queries: query_counts.fetch(r[:trace_id], 0))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def source_location(props)
|
|
71
|
+
return unless props["code.filepath"]
|
|
72
|
+
|
|
73
|
+
[props["code.filepath"], props["code.lineno"]].compact.join(":")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def request_heading(request, title)
|
|
77
|
+
[bold(title), request[:status], format("%.1fms", request[:duration_ms]), endpoint(request)].compact.join(" ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def endpoint(request)
|
|
81
|
+
name = [request[:controller], request[:action]].compact.join("#")
|
|
82
|
+
name unless name.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def read_local_spans(database_path)
|
|
86
|
+
unless File.exist?(database_path)
|
|
87
|
+
$stderr.puts "No Flare spans database at #{database_path}."
|
|
88
|
+
$stderr.puts "Start your app in development and make a request, or pass --db PATH."
|
|
89
|
+
exit 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
require "sqlite3"
|
|
94
|
+
rescue LoadError
|
|
95
|
+
$stderr.puts "The sqlite3 gem is required. Add `gem \"sqlite3\"` to your Gemfile and run with `bundle exec`."
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
98
|
+
require_relative "../storage"
|
|
99
|
+
require_relative "../storage/sqlite"
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
yield Storage::SQLite.new(database_path, readonly: true)
|
|
103
|
+
rescue SQLite3::Exception => e
|
|
104
|
+
$stderr.puts "Could not read #{database_path}: #{e.message}"
|
|
105
|
+
exit 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "local_spans"
|
|
5
|
+
require_relative "output"
|
|
6
|
+
|
|
7
|
+
module Flare
|
|
8
|
+
# Finds queries repeated within recent requests in the local development
|
|
9
|
+
# spans database, and the code that ran them, so you (or a coding agent) can
|
|
10
|
+
# spot an N+1 and confirm a fix without opening /flare.
|
|
11
|
+
class Nplus1Command
|
|
12
|
+
include CLI::LocalSpans
|
|
13
|
+
include CLI::Output
|
|
14
|
+
|
|
15
|
+
MINIMUM_REPEATS = 2
|
|
16
|
+
|
|
17
|
+
def initialize(argv)
|
|
18
|
+
parse_options(argv, banner: "Usage: flare nplus1 [options]", last_help: "Number of requests to check")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
requests = read_local_spans(@database_path) { |storage| load_requests(storage) }
|
|
23
|
+
|
|
24
|
+
if @json
|
|
25
|
+
puts JSON.pretty_generate(requests)
|
|
26
|
+
elsif requests.empty?
|
|
27
|
+
puts "No repeated queries in recent requests (checked up to #{@limit})."
|
|
28
|
+
else
|
|
29
|
+
requests.each { |request| print_request(request) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_requests(storage)
|
|
36
|
+
recent_requests(storage, @limit).filter_map do |request|
|
|
37
|
+
repeated = repeated_queries(storage.spans_for_trace(request[:trace_id]))
|
|
38
|
+
request.merge(repeated: repeated) if repeated.any?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def repeated_queries(spans)
|
|
43
|
+
spans.select { |span| query?(span) }
|
|
44
|
+
.group_by { |span| shape(span[:properties]["db.statement"]) }
|
|
45
|
+
.select { |_, queries| queries.size >= MINIMUM_REPEATS }
|
|
46
|
+
.map { |statement, queries| summarize(statement, queries) }
|
|
47
|
+
.sort_by { |query| [-query[:count], -query[:total_ms]] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def query?(span)
|
|
51
|
+
props = span[:properties]
|
|
52
|
+
statement = props["db.statement"]
|
|
53
|
+
return false unless statement && Storage::SQLite::SPAN_CATEGORIES["queries"].any? { |pattern| span[:name].downcase.include?(pattern) }
|
|
54
|
+
|
|
55
|
+
# Active Record's bookkeeping repeats by design. Transaction statements
|
|
56
|
+
# from elsewhere have no name, so they're also matched by their text.
|
|
57
|
+
!Storage::SQLite::BOOKKEEPING_QUERY_NAMES.include?(props["name"]) && !Storage::SQLite::TRANSACTION_STATEMENTS.include?(statement.to_s.strip.upcase)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The statement with its values taken out, so one query run with different
|
|
61
|
+
# values reads as the same query.
|
|
62
|
+
def shape(statement)
|
|
63
|
+
statement.to_s
|
|
64
|
+
.gsub(%r{/\*.*?\*/}m, " ")
|
|
65
|
+
.gsub(/'(?:[^']|'')*'/, "?")
|
|
66
|
+
.gsub(/\$\d+/, "?")
|
|
67
|
+
.gsub(/\b\d+(?:\.\d+)?\b/, "?")
|
|
68
|
+
.gsub(/\bIN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/i, "IN (?)")
|
|
69
|
+
.gsub(/\s+/, " ")
|
|
70
|
+
.strip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def summarize(statement, queries)
|
|
74
|
+
{
|
|
75
|
+
statement: statement,
|
|
76
|
+
count: queries.size,
|
|
77
|
+
total_ms: queries.sum { |query| query[:duration_ms] }.round(1),
|
|
78
|
+
sources: sources(queries),
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sources(queries)
|
|
83
|
+
queries.filter_map { |query| source_location(query[:properties]) }.tally
|
|
84
|
+
.sort_by { |_, count| -count }
|
|
85
|
+
.map { |location, count| { location: location, count: count } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def print_request(request)
|
|
89
|
+
puts "#{request_heading(request, "#{request[:method]} #{request[:path]}")} #{dim(request[:trace_id])}"
|
|
90
|
+
|
|
91
|
+
request[:repeated].each do |query|
|
|
92
|
+
puts format("%4dx %7.1fms %s", query[:count], query[:total_ms], query[:statement])
|
|
93
|
+
query[:sources].each { |source| puts dim(format("%7dx %s", source[:count], source[:location])) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
module CLI
|
|
5
|
+
# What the commands need to know about the app they're run in.
|
|
6
|
+
module Project
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Also matches /.env*, the rule in a new Rails app's .gitignore. The last
|
|
10
|
+
# rule wins, as it does in git, so a later !.env means it isn't ignored.
|
|
11
|
+
def env_ignored?(gitignore)
|
|
12
|
+
last_rule = gitignore.scan(%r{^!?/?\.env\*?$}).last
|
|
13
|
+
!last_rule.nil? && !last_rule.start_with?("!")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A FLARE_KEY in .env, whether setup saved it there or someone added it
|
|
17
|
+
# by hand. Setup and doctor both treat that .env as needing to be ignored.
|
|
18
|
+
def key_in_dotenv?
|
|
19
|
+
env_path = File.join(Dir.pwd, ".env")
|
|
20
|
+
File.exist?(env_path) && File.read(env_path).match?(/^FLARE_KEY=.+/)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def credentials_exist?
|
|
24
|
+
Dir.glob("config/credentials{.yml.enc,/*.yml.enc}", base: Dir.pwd).any?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "local_spans"
|
|
5
|
+
require_relative "output"
|
|
6
|
+
|
|
7
|
+
module Flare
|
|
8
|
+
# Shows what one request did, from the local development spans database, so
|
|
9
|
+
# you (or a coding agent) can read its timeline without opening /flare. Trace
|
|
10
|
+
# ids come from `flare requests`.
|
|
11
|
+
class RequestCommand
|
|
12
|
+
include CLI::LocalSpans
|
|
13
|
+
include CLI::Output
|
|
14
|
+
|
|
15
|
+
USAGE = "Usage: flare request <trace_id> [options]"
|
|
16
|
+
|
|
17
|
+
def initialize(argv)
|
|
18
|
+
arguments = parse_options(argv, banner: USAGE)
|
|
19
|
+
|
|
20
|
+
unless arguments.size == 1
|
|
21
|
+
$stderr.puts USAGE
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
@trace_id = arguments.first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
request = read_local_spans(@database_path) { |storage| load_request(storage) }
|
|
29
|
+
|
|
30
|
+
unless request
|
|
31
|
+
$stderr.puts "No request with trace id #{@trace_id}. Run `flare requests` to list recent ones."
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if @json
|
|
36
|
+
puts JSON.pretty_generate(request)
|
|
37
|
+
else
|
|
38
|
+
print_request(request)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def load_request(storage)
|
|
45
|
+
root = storage.find_request(@trace_id)
|
|
46
|
+
return unless root
|
|
47
|
+
|
|
48
|
+
spans = storage.spans_for_trace(@trace_id)
|
|
49
|
+
props = root[:properties]
|
|
50
|
+
|
|
51
|
+
request_summary(root,
|
|
52
|
+
method: props["http.request.method"] || props["http.method"],
|
|
53
|
+
path: props["url.path"] || props["http.target"],
|
|
54
|
+
status: props["http.response.status_code"] || props["http.status_code"],
|
|
55
|
+
controller: props["code.namespace"],
|
|
56
|
+
action: props["code.function"],
|
|
57
|
+
queries: storage.count_queries_by_trace([@trace_id]).fetch(@trace_id, 0)).merge(
|
|
58
|
+
name: root[:name],
|
|
59
|
+
exceptions: spans.flat_map { |span| exceptions(span) },
|
|
60
|
+
spans: spans.reject { |span| span[:id] == root[:id] }.map { |span| timeline_entry(span, root) },
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def exceptions(span)
|
|
65
|
+
span[:events].select { |event| event[:name] == "exception" }.map do |event|
|
|
66
|
+
{
|
|
67
|
+
type: event[:properties]["exception.type"],
|
|
68
|
+
message: event[:properties]["exception.message"],
|
|
69
|
+
stacktrace: event[:properties]["exception.stacktrace"],
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def timeline_entry(span, root)
|
|
75
|
+
props = span[:properties]
|
|
76
|
+
{
|
|
77
|
+
name: span[:name],
|
|
78
|
+
offset_ms: ((span[:start_timestamp] - root[:start_timestamp]) / 1_000_000.0).round(1),
|
|
79
|
+
duration_ms: span[:duration_ms].round(1),
|
|
80
|
+
detail: detail(props),
|
|
81
|
+
source: source_location(props),
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def detail(props)
|
|
86
|
+
statement = props["db.statement"]
|
|
87
|
+
return statement.to_s.gsub(/\s+/, " ").strip if statement
|
|
88
|
+
|
|
89
|
+
identifier = props["identifier"]
|
|
90
|
+
return identifier.to_s.sub(%r{\A.*/app/views/}, "") if identifier
|
|
91
|
+
|
|
92
|
+
props["http.url"] || props["url.full"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def print_request(request)
|
|
96
|
+
title = request[:method] ? "#{request[:method]} #{request[:path]}" : request[:name]
|
|
97
|
+
puts request_heading(request, title)
|
|
98
|
+
puts "Started #{request[:started_at]} #{request[:queries]} queries"
|
|
99
|
+
|
|
100
|
+
request[:exceptions].each do |exception|
|
|
101
|
+
puts red("Exception: #{[exception[:type], exception[:message]].compact.join(": ")}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return if request[:spans].empty?
|
|
105
|
+
|
|
106
|
+
puts
|
|
107
|
+
puts dim(format("%9s %9s", "offset", "duration"))
|
|
108
|
+
request[:spans].each { |span| puts format_span(span) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def format_span(span)
|
|
112
|
+
label = span[:detail] ? "#{span[:name]}: #{span[:detail]}" : span[:name]
|
|
113
|
+
[
|
|
114
|
+
format("%7.1fms %7.1fms", span[:offset_ms], span[:duration_ms]),
|
|
115
|
+
label,
|
|
116
|
+
span[:source] && dim(span[:source]),
|
|
117
|
+
].compact.join(" ")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|