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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -1
  3. data/app/controllers/flare/jobs_controller.rb +1 -1
  4. data/app/controllers/flare/requests_controller.rb +1 -1
  5. data/app/controllers/flare/spans_controller.rb +1 -1
  6. data/app/helpers/flare/application_helper.rb +23 -0
  7. data/app/views/flare/jobs/index.html.erb +2 -1
  8. data/app/views/flare/requests/index.html.erb +2 -1
  9. data/app/views/flare/requests/show.html.erb +16 -7
  10. data/app/views/flare/spans/index.html.erb +2 -1
  11. data/lib/flare/cli/doctor_command.rb +4 -11
  12. data/lib/flare/cli/local_spans.rb +110 -0
  13. data/lib/flare/cli/nplus1_command.rb +97 -0
  14. data/lib/flare/cli/project.rb +28 -0
  15. data/lib/flare/cli/request_command.rb +120 -0
  16. data/lib/flare/cli/requests_command.rb +45 -0
  17. data/lib/flare/cli/setup_command.rb +64 -41
  18. data/lib/flare/cli/status_command.rb +5 -2
  19. data/lib/flare/cli.rb +12 -0
  20. data/lib/flare/client_headers.rb +7 -3
  21. data/lib/flare/configuration.rb +20 -0
  22. data/lib/flare/export_filter.rb +273 -0
  23. data/lib/flare/filtering_span_processor.rb +14 -0
  24. data/lib/flare/http_transport.rb +4 -0
  25. data/lib/flare/metric_span_processor.rb +51 -35
  26. data/lib/flare/metric_submitter.rb +9 -1
  27. data/lib/flare/recording_batch_span_processor.rb +9 -1
  28. data/lib/flare/redis_statement_processor.rb +29 -0
  29. data/lib/flare/revision.rb +82 -0
  30. data/lib/flare/source_location.rb +21 -16
  31. data/lib/flare/sql_mask.rb +235 -0
  32. data/lib/flare/sqlite_exporter.rb +12 -8
  33. data/lib/flare/storage/sqlite.rb +195 -144
  34. data/lib/flare/storage.rb +1 -40
  35. data/lib/flare/store_statement.rb +136 -0
  36. data/lib/flare/trace_blob.rb +9 -5
  37. data/lib/flare/trace_exporter.rb +18 -3
  38. data/lib/flare/upload_url_pool.rb +2 -15
  39. data/lib/flare/utf8.rb +20 -0
  40. data/lib/flare/version.rb +1 -1
  41. data/lib/flare.rb +21 -2
  42. data/public/flare-assets/flare.css +10 -60
  43. metadata +45 -32
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c94277634cce4170817b717f3ff6b3cb99ac980908f51ed6aaa6ba07a8c59de6
4
- data.tar.gz: 4d882034612ac608246ea1a3f232a5f491c283d97b6082a97115f60f0b80fdf5
3
+ metadata.gz: 1bc922e7eef0dab2d93ab0a6d765a6d5cc5f1dbf40f322774fe6e8ceb146d626
4
+ data.tar.gz: 9bab57b668d51b8c7d2047611443bf36dfb77fa76d7827acc4444749c622529b
5
5
  SHA512:
6
- metadata.gz: f8cfaf0827068925802b90582a3c12a2513649db82b23e51509ed689c111cfcbcd7397c2c260d13934dadfe3a2899481925875faf0bc75f3262c326fa8718e63
7
- data.tar.gz: 38727869b8f7ebf8283354a7c696aded643a5da8260bce61d2ddacb4e6e441868d72eed7c01766b9e8d83d2d95954b1fab2fd75e8cdd1bf7adfabef3d23e4ae5
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/jnunemaker/flare.
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;">Duration</th>
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;">Duration</th>
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
- const method = requestData.http_method || 'GET';
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('# ' + method + ' ' + requestData.name + ' [' + status + '] - ' + formatDuration(requestData.duration_ms));
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
- const sqlSpans = spansData.filter(function(s) { return getSpanCategory(s.name) === 'sql'; });
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
- output.push('- **SQL:** ' + sqlSpans.length + ' queries, ' + formatDuration(sqlTotalMs) + ' total');
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;">Duration</th>
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" unless contents.match?(/^\.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
- return true if ENV["FLARE_KEY"] && !ENV["FLARE_KEY"].empty?
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