flare 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/app/controllers/flare/application_controller.rb +22 -0
- data/app/controllers/flare/jobs_controller.rb +55 -0
- data/app/controllers/flare/requests_controller.rb +73 -0
- data/app/controllers/flare/spans_controller.rb +101 -0
- data/app/helpers/flare/application_helper.rb +168 -0
- data/app/views/flare/jobs/index.html.erb +69 -0
- data/app/views/flare/jobs/show.html.erb +323 -0
- data/app/views/flare/requests/index.html.erb +120 -0
- data/app/views/flare/requests/show.html.erb +498 -0
- data/app/views/flare/spans/index.html.erb +112 -0
- data/app/views/flare/spans/show.html.erb +184 -0
- data/app/views/layouts/flare/application.html.erb +126 -0
- data/config/routes.rb +20 -0
- data/exe/flare +9 -0
- data/lib/flare/backoff_policy.rb +73 -0
- data/lib/flare/cli/doctor_command.rb +129 -0
- data/lib/flare/cli/output.rb +45 -0
- data/lib/flare/cli/setup_command.rb +404 -0
- data/lib/flare/cli/status_command.rb +47 -0
- data/lib/flare/cli.rb +50 -0
- data/lib/flare/configuration.rb +121 -0
- data/lib/flare/engine.rb +43 -0
- data/lib/flare/http_metrics_config.rb +101 -0
- data/lib/flare/metric_counter.rb +45 -0
- data/lib/flare/metric_flusher.rb +124 -0
- data/lib/flare/metric_key.rb +42 -0
- data/lib/flare/metric_span_processor.rb +470 -0
- data/lib/flare/metric_storage.rb +42 -0
- data/lib/flare/metric_submitter.rb +221 -0
- data/lib/flare/source_location.rb +113 -0
- data/lib/flare/sqlite_exporter.rb +279 -0
- data/lib/flare/storage/sqlite.rb +789 -0
- data/lib/flare/storage.rb +54 -0
- data/lib/flare/version.rb +5 -0
- data/lib/flare.rb +411 -0
- data/public/flare-assets/flare.css +1245 -0
- data/public/flare-assets/images/flipper.png +0 -0
- metadata +240 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "json"
|
|
10
|
+
require "fileutils"
|
|
11
|
+
require_relative "output"
|
|
12
|
+
|
|
13
|
+
module Flare
|
|
14
|
+
class SetupCommand
|
|
15
|
+
include CLI::Output
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = "https://flare.am"
|
|
18
|
+
TIMEOUT_SECONDS = 300 # 5 minutes
|
|
19
|
+
|
|
20
|
+
INITIALIZER_CONTENT = <<~RUBY
|
|
21
|
+
# frozen_string_literal: true
|
|
22
|
+
|
|
23
|
+
Flare.configure do |config|
|
|
24
|
+
# ── Spans (local development dashboard) ────────────────────────────
|
|
25
|
+
# Spans capture detailed trace data and are stored in a local SQLite
|
|
26
|
+
# database. Enabled by default in development only. Visit /flare in
|
|
27
|
+
# your browser to see the dashboard.
|
|
28
|
+
|
|
29
|
+
# Enable or disable spans (default: true in development)
|
|
30
|
+
# config.spans_enabled = true
|
|
31
|
+
|
|
32
|
+
# How long to keep spans in hours (default: 24)
|
|
33
|
+
# config.retention_hours = 24
|
|
34
|
+
|
|
35
|
+
# Maximum number of spans to store (default: 10000)
|
|
36
|
+
# config.max_spans = 10000
|
|
37
|
+
|
|
38
|
+
# Path to the SQLite database (default: db/flare.sqlite3)
|
|
39
|
+
# config.database_path = Rails.root.join("db", "flare.sqlite3").to_s
|
|
40
|
+
|
|
41
|
+
# Ignore specific requests (receives a Rack::Request, return true to ignore)
|
|
42
|
+
# config.ignore_request = ->(request) {
|
|
43
|
+
# request.path.start_with?("/health")
|
|
44
|
+
# }
|
|
45
|
+
|
|
46
|
+
# ── Metrics (remote monitoring) ────────────────────────────────────
|
|
47
|
+
# Metrics aggregate span data into counts, durations, and error rates.
|
|
48
|
+
# Enabled by default in development and production (disabled in test).
|
|
49
|
+
# Sent to flare.am when FLARE_KEY is configured.
|
|
50
|
+
|
|
51
|
+
# Enable or disable metrics (default: true except in test)
|
|
52
|
+
# config.metrics_enabled = true
|
|
53
|
+
|
|
54
|
+
# How often to flush metrics in seconds (default: 60)
|
|
55
|
+
# config.metrics_flush_interval = 60
|
|
56
|
+
|
|
57
|
+
# ── Custom Instrumentation ─────────────────────────────────────────
|
|
58
|
+
# Subscribe to additional notification prefixes (default: ["app."])
|
|
59
|
+
# config.subscribe_patterns << "mycompany."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
63
|
+
# Custom Instrumentation
|
|
64
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
65
|
+
#
|
|
66
|
+
# Use ActiveSupport::Notifications.instrument with an "app." prefix
|
|
67
|
+
# anywhere in your code. Flare captures these in development and
|
|
68
|
+
# displays them in the dashboard.
|
|
69
|
+
#
|
|
70
|
+
# ActiveSupport::Notifications.instrument("app.geocoding", address: address) do
|
|
71
|
+
# geocoder.lookup(address)
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# ActiveSupport::Notifications.instrument("app.stripe.charge", amount: 1000) do
|
|
75
|
+
# Stripe::Charge.create(amount: 1000, currency: "usd")
|
|
76
|
+
# end
|
|
77
|
+
RUBY
|
|
78
|
+
|
|
79
|
+
def initialize(force: false)
|
|
80
|
+
@force = force
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run
|
|
84
|
+
authenticate
|
|
85
|
+
create_initializer
|
|
86
|
+
add_gitignore_entries
|
|
87
|
+
|
|
88
|
+
puts
|
|
89
|
+
puts "#{green("Setup complete!")}"
|
|
90
|
+
puts
|
|
91
|
+
puts bold("What's next:")
|
|
92
|
+
puts " 1. Start your Rails server (#{dim("bin/rails server")})"
|
|
93
|
+
puts " 2. Make a few requests to your app"
|
|
94
|
+
puts " 3. Visit #{bold("/flare")} to see the dashboard"
|
|
95
|
+
puts
|
|
96
|
+
puts dim(" The dashboard auto-mounts at /flare in development.")
|
|
97
|
+
puts dim(" Metrics are sent to flare.am when FLARE_KEY is configured.")
|
|
98
|
+
puts
|
|
99
|
+
puts " Run #{bold("flare doctor")} to verify your setup."
|
|
100
|
+
puts " Run #{bold("flare status")} to see your configuration."
|
|
101
|
+
rescue Interrupt
|
|
102
|
+
puts
|
|
103
|
+
puts "Setup cancelled."
|
|
104
|
+
exit 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# --- Auth ---
|
|
110
|
+
|
|
111
|
+
def authenticate
|
|
112
|
+
env_path = File.join(Dir.pwd, ".env")
|
|
113
|
+
|
|
114
|
+
if !@force && File.exist?(env_path) && File.read(env_path).match?(/^FLARE_KEY=.+/)
|
|
115
|
+
puts "#{checkmark} FLARE_KEY already set in .env, skipping auth."
|
|
116
|
+
puts " Run with --force to re-authenticate."
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
server = nil
|
|
121
|
+
state = SecureRandom.hex(32)
|
|
122
|
+
code_verifier = SecureRandom.urlsafe_base64(32)
|
|
123
|
+
code_challenge = Base64.urlsafe_encode64(
|
|
124
|
+
Digest::SHA256.digest(code_verifier),
|
|
125
|
+
padding: false
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
129
|
+
port = server.addr[1]
|
|
130
|
+
|
|
131
|
+
host = ENV.fetch("FLARE_HOST", DEFAULT_HOST)
|
|
132
|
+
authorize_url = "#{host}/cli/authorize?state=#{state}&port=#{port}&code_challenge=#{code_challenge}"
|
|
133
|
+
|
|
134
|
+
puts "Opening browser to authorize Flare..."
|
|
135
|
+
open_browser(authorize_url)
|
|
136
|
+
puts
|
|
137
|
+
puts "If the browser didn't open, visit:"
|
|
138
|
+
puts " #{authorize_url}"
|
|
139
|
+
puts
|
|
140
|
+
puts "Waiting for authorization (up to 5 minutes)..."
|
|
141
|
+
|
|
142
|
+
auth_code = wait_for_callback(server, state, TIMEOUT_SECONDS)
|
|
143
|
+
|
|
144
|
+
unless auth_code
|
|
145
|
+
puts "Timed out waiting for authorization."
|
|
146
|
+
exit 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
token = exchange_code(auth_code, code_verifier)
|
|
150
|
+
save_token(token)
|
|
151
|
+
ensure
|
|
152
|
+
server&.close
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def wait_for_callback(server, expected_state, timeout)
|
|
156
|
+
deadline = Time.now + timeout
|
|
157
|
+
|
|
158
|
+
while Time.now < deadline
|
|
159
|
+
readable = IO.select([server], nil, nil, 1)
|
|
160
|
+
next unless readable
|
|
161
|
+
|
|
162
|
+
client = server.accept
|
|
163
|
+
request_line = client.gets
|
|
164
|
+
|
|
165
|
+
unless request_line&.start_with?("GET /callback")
|
|
166
|
+
client.print "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"
|
|
167
|
+
client.close
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Read remaining headers (required by HTTP spec)
|
|
172
|
+
while (line = client.gets) && line != "\r\n"; end
|
|
173
|
+
|
|
174
|
+
params = parse_query_string(request_line)
|
|
175
|
+
returned_state = params["state"]
|
|
176
|
+
code = params["code"]
|
|
177
|
+
error = params["error"]
|
|
178
|
+
|
|
179
|
+
if error
|
|
180
|
+
client.print http_response(error_page(error))
|
|
181
|
+
client.close
|
|
182
|
+
return nil
|
|
183
|
+
elsif returned_state != expected_state
|
|
184
|
+
client.print http_response(error_page("State mismatch. Please try again."))
|
|
185
|
+
client.close
|
|
186
|
+
return nil
|
|
187
|
+
elsif code.nil? || code.empty?
|
|
188
|
+
client.print http_response(error_page("No authorization code received."))
|
|
189
|
+
client.close
|
|
190
|
+
return nil
|
|
191
|
+
else
|
|
192
|
+
client.print http_response(success_page)
|
|
193
|
+
client.close
|
|
194
|
+
return code
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_query_string(request_line)
|
|
202
|
+
return {} unless request_line
|
|
203
|
+
path = request_line.split(" ")[1]
|
|
204
|
+
return {} unless path
|
|
205
|
+
query = URI(path).query
|
|
206
|
+
return {} unless query
|
|
207
|
+
URI.decode_www_form(query).to_h
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def http_response(body)
|
|
211
|
+
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def exchange_code(auth_code, code_verifier)
|
|
215
|
+
host = ENV.fetch("FLARE_HOST", DEFAULT_HOST)
|
|
216
|
+
uri = URI("#{host}/api/cli/exchange")
|
|
217
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
218
|
+
http.use_ssl = uri.scheme == "https"
|
|
219
|
+
|
|
220
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
221
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
222
|
+
request.set_form_data(code: auth_code, code_verifier: code_verifier)
|
|
223
|
+
|
|
224
|
+
response = http.request(request)
|
|
225
|
+
|
|
226
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
227
|
+
$stderr.puts "Failed to exchange code for token: #{response.code} #{response.message}"
|
|
228
|
+
$stderr.puts response.body if response.body
|
|
229
|
+
exit 1
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
data = JSON.parse(response.body)
|
|
233
|
+
token = data["token"]
|
|
234
|
+
|
|
235
|
+
if token.nil? || token.empty?
|
|
236
|
+
$stderr.puts "No token received from server."
|
|
237
|
+
exit 1
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
token
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# --- Token storage ---
|
|
244
|
+
|
|
245
|
+
def save_token(token)
|
|
246
|
+
puts
|
|
247
|
+
puts "Where would you like to save the token?"
|
|
248
|
+
puts " 1. .env file"
|
|
249
|
+
puts " 2. Rails credentials"
|
|
250
|
+
puts " 3. Print token"
|
|
251
|
+
print "Choose (1/2/3): "
|
|
252
|
+
|
|
253
|
+
choice = $stdin.gets&.strip
|
|
254
|
+
|
|
255
|
+
case choice
|
|
256
|
+
when "1"
|
|
257
|
+
@saved_to_dotenv = true
|
|
258
|
+
save_to_dotenv(token)
|
|
259
|
+
when "2"
|
|
260
|
+
print_credentials_instructions(token)
|
|
261
|
+
when "3"
|
|
262
|
+
print_token(token)
|
|
263
|
+
else
|
|
264
|
+
puts "Invalid choice."
|
|
265
|
+
save_token(token)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def save_to_dotenv(token)
|
|
270
|
+
env_path = File.join(Dir.pwd, ".env")
|
|
271
|
+
|
|
272
|
+
if File.exist?(env_path)
|
|
273
|
+
contents = File.read(env_path)
|
|
274
|
+
if contents.match?(/^FLARE_KEY=/)
|
|
275
|
+
contents.gsub!(/^FLARE_KEY=.*$/, "FLARE_KEY=#{token}")
|
|
276
|
+
else
|
|
277
|
+
contents = contents.chomp + "\nFLARE_KEY=#{token}\n"
|
|
278
|
+
end
|
|
279
|
+
File.write(env_path, contents)
|
|
280
|
+
puts " Token saved to .env"
|
|
281
|
+
else
|
|
282
|
+
print " .env file not found. Create it? (y/n): "
|
|
283
|
+
answer = $stdin.gets&.strip&.downcase
|
|
284
|
+
if answer == "y" || answer == "yes"
|
|
285
|
+
File.write(env_path, "FLARE_KEY=#{token}\n")
|
|
286
|
+
puts " Created .env with FLARE_KEY"
|
|
287
|
+
else
|
|
288
|
+
print_dotenv_instructions(token)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def print_dotenv_instructions(token)
|
|
294
|
+
puts
|
|
295
|
+
puts "Add the following to your .env file:"
|
|
296
|
+
puts
|
|
297
|
+
puts " FLARE_KEY=#{token}"
|
|
298
|
+
puts
|
|
299
|
+
puts "Make sure .env is loaded in your app, for example with the dotenv gem:"
|
|
300
|
+
puts
|
|
301
|
+
puts " # Gemfile"
|
|
302
|
+
puts " gem \"dotenv-rails\", groups: [:development, :test]"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def print_credentials_instructions(token)
|
|
306
|
+
puts
|
|
307
|
+
puts "Add the following to your Rails credentials:"
|
|
308
|
+
puts
|
|
309
|
+
puts " bin/rails credentials:edit"
|
|
310
|
+
puts
|
|
311
|
+
puts " flare:"
|
|
312
|
+
puts " key: #{token}"
|
|
313
|
+
puts
|
|
314
|
+
puts "Or for a specific environment:"
|
|
315
|
+
puts
|
|
316
|
+
puts " bin/rails credentials:edit --environment production"
|
|
317
|
+
puts
|
|
318
|
+
puts " flare:"
|
|
319
|
+
puts " key: #{token}"
|
|
320
|
+
puts
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def print_token(token)
|
|
324
|
+
puts
|
|
325
|
+
puts " FLARE_KEY=#{token}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# --- Project setup ---
|
|
329
|
+
|
|
330
|
+
def create_initializer
|
|
331
|
+
path = File.join(Dir.pwd, "config/initializers/flare.rb")
|
|
332
|
+
existed = File.exist?(path)
|
|
333
|
+
|
|
334
|
+
if existed && !@force
|
|
335
|
+
puts "#{checkmark} config/initializers/flare.rb already exists"
|
|
336
|
+
puts " Run with --force to overwrite."
|
|
337
|
+
return
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
341
|
+
File.write(path, INITIALIZER_CONTENT)
|
|
342
|
+
|
|
343
|
+
if existed
|
|
344
|
+
puts "#{checkmark} Overwrote config/initializers/flare.rb"
|
|
345
|
+
else
|
|
346
|
+
puts "#{checkmark} Created config/initializers/flare.rb"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def add_gitignore_entries
|
|
351
|
+
gitignore_path = File.join(Dir.pwd, ".gitignore")
|
|
352
|
+
return unless File.exist?(gitignore_path)
|
|
353
|
+
|
|
354
|
+
contents = File.read(gitignore_path)
|
|
355
|
+
entries_to_add = []
|
|
356
|
+
|
|
357
|
+
entries_to_add << ".env" if @saved_to_dotenv && !contents.match?(/^\.env$/)
|
|
358
|
+
entries_to_add << "flare.sqlite3*" unless contents.include?("flare.sqlite3*")
|
|
359
|
+
|
|
360
|
+
return if entries_to_add.empty?
|
|
361
|
+
|
|
362
|
+
File.open(gitignore_path, "a") do |f|
|
|
363
|
+
f.puts "" unless contents.end_with?("\n")
|
|
364
|
+
entries_to_add.each { |entry| f.puts entry }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
puts "#{checkmark} Added #{entries_to_add.join(", ")} to .gitignore"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# --- Browser ---
|
|
371
|
+
|
|
372
|
+
def open_browser(url)
|
|
373
|
+
case RUBY_PLATFORM
|
|
374
|
+
when /darwin/ then system("open", url)
|
|
375
|
+
when /linux/ then system("xdg-open", url)
|
|
376
|
+
when /mingw|mswin/ then system("start", url)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def success_page
|
|
381
|
+
page_shell("Authorized!", "You can close this window.")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def error_page(message)
|
|
385
|
+
escaped = message.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
386
|
+
page_shell("Error", escaped)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def page_shell(title, subtitle)
|
|
390
|
+
<<~HTML
|
|
391
|
+
<!DOCTYPE html>
|
|
392
|
+
<html>
|
|
393
|
+
<head><title>Flare</title></head>
|
|
394
|
+
<body style="font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f3ef;">
|
|
395
|
+
<div style="text-align: center; background: #fff; border-radius: 16px; padding: 48px 56px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);">
|
|
396
|
+
<h1 style="color: #3d3529; font-size: 28px; font-weight: 700; margin: 0 0 8px;">#{title}</h1>
|
|
397
|
+
<p style="color: #8a8078; font-size: 15px; margin: 0;">#{subtitle}</p>
|
|
398
|
+
</div>
|
|
399
|
+
</body>
|
|
400
|
+
</html>
|
|
401
|
+
HTML
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "output"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
|
|
6
|
+
module Flare
|
|
7
|
+
class StatusCommand
|
|
8
|
+
include CLI::Output
|
|
9
|
+
|
|
10
|
+
def run
|
|
11
|
+
puts bold("Flare v#{VERSION}")
|
|
12
|
+
puts
|
|
13
|
+
|
|
14
|
+
puts bold("Environment")
|
|
15
|
+
puts " RAILS_ENV: #{ENV.fetch("RAILS_ENV", dim("not set"))}"
|
|
16
|
+
puts " FLARE_KEY: #{key_status}"
|
|
17
|
+
puts " FLARE_URL: #{ENV.fetch("FLARE_URL", dim("https://flare.am (default)"))}"
|
|
18
|
+
puts
|
|
19
|
+
|
|
20
|
+
puts bold("Files")
|
|
21
|
+
puts " Initializer: #{file_status("config/initializers/flare.rb")}"
|
|
22
|
+
puts " .env: #{file_status(".env")}"
|
|
23
|
+
puts " .gitignore: #{file_status(".gitignore")}"
|
|
24
|
+
puts " Database: #{file_status("db/flare.sqlite3")}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def key_status
|
|
30
|
+
if ENV["FLARE_KEY"] && !ENV["FLARE_KEY"].empty?
|
|
31
|
+
green("set via ENV")
|
|
32
|
+
else
|
|
33
|
+
env_path = File.join(Dir.pwd, ".env")
|
|
34
|
+
if File.exist?(env_path) && File.read(env_path).match?(/^FLARE_KEY=.+/)
|
|
35
|
+
green("set in .env")
|
|
36
|
+
else
|
|
37
|
+
red("not configured")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def file_status(relative_path)
|
|
43
|
+
path = File.join(Dir.pwd, relative_path)
|
|
44
|
+
File.exist?(path) ? green("exists") : dim("not found")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/flare/cli.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "version"
|
|
4
|
+
|
|
5
|
+
module Flare
|
|
6
|
+
module CLI
|
|
7
|
+
COMMANDS = {
|
|
8
|
+
"setup" => "Authenticate and configure Flare for this project",
|
|
9
|
+
"doctor" => "Check your Flare setup for issues",
|
|
10
|
+
"status" => "Show current Flare configuration",
|
|
11
|
+
"version" => "Print the Flare version",
|
|
12
|
+
"help" => "Show this help message",
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.start(argv)
|
|
16
|
+
command = argv.first
|
|
17
|
+
|
|
18
|
+
case command
|
|
19
|
+
when "setup"
|
|
20
|
+
require_relative "cli/setup_command"
|
|
21
|
+
force = argv.include?("--force")
|
|
22
|
+
SetupCommand.new(force: force).run
|
|
23
|
+
when "doctor"
|
|
24
|
+
require_relative "cli/doctor_command"
|
|
25
|
+
DoctorCommand.new.run
|
|
26
|
+
when "status"
|
|
27
|
+
require_relative "cli/status_command"
|
|
28
|
+
StatusCommand.new.run
|
|
29
|
+
when "version", "-v", "--version"
|
|
30
|
+
puts "flare #{Flare::VERSION}"
|
|
31
|
+
when "help", nil, "-h", "--help"
|
|
32
|
+
print_help
|
|
33
|
+
else
|
|
34
|
+
$stderr.puts "Unknown command: #{command}"
|
|
35
|
+
$stderr.puts
|
|
36
|
+
print_help
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.print_help
|
|
42
|
+
puts "Usage: flare <command>"
|
|
43
|
+
puts
|
|
44
|
+
puts "Commands:"
|
|
45
|
+
COMMANDS.each do |name, description|
|
|
46
|
+
puts " %-12s %s" % [name, description]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_metrics_config"
|
|
4
|
+
|
|
5
|
+
module Flare
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :enabled
|
|
8
|
+
attr_accessor :retention_hours
|
|
9
|
+
attr_accessor :max_spans
|
|
10
|
+
attr_accessor :ignore_request
|
|
11
|
+
attr_writer :database_path
|
|
12
|
+
|
|
13
|
+
# Spans: detailed trace data stored in SQLite (default: development only)
|
|
14
|
+
# Metrics: aggregated counters in memory, flushed periodically (default: production only)
|
|
15
|
+
attr_accessor :spans_enabled
|
|
16
|
+
attr_accessor :metrics_enabled
|
|
17
|
+
attr_accessor :metrics_flush_interval # seconds between flushes (default: 60)
|
|
18
|
+
|
|
19
|
+
# Metrics HTTP submission settings
|
|
20
|
+
attr_accessor :url # URL of the Flare metrics service
|
|
21
|
+
attr_accessor :key # API key for authentication
|
|
22
|
+
attr_accessor :metrics_timeout # HTTP timeout in seconds (default: 5)
|
|
23
|
+
attr_accessor :metrics_gzip # Whether to gzip payloads (default: true)
|
|
24
|
+
|
|
25
|
+
# Default patterns to auto-subscribe to for custom instrumentation
|
|
26
|
+
# Use "app." prefix in your ActiveSupport::Notifications.instrument calls
|
|
27
|
+
DEFAULT_SUBSCRIBE_PATTERNS = %w[app.].freeze
|
|
28
|
+
|
|
29
|
+
attr_accessor :subscribe_patterns
|
|
30
|
+
|
|
31
|
+
# HTTP metrics path tracking configuration.
|
|
32
|
+
# Controls which outgoing HTTP paths are tracked with detail vs collapsed to "*".
|
|
33
|
+
attr_reader :http_metrics_config
|
|
34
|
+
|
|
35
|
+
# Enable debug logging to see what Flare is doing.
|
|
36
|
+
# Set FLARE_DEBUG=1 or configure debug: true in your initializer.
|
|
37
|
+
attr_accessor :debug
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@enabled = true
|
|
41
|
+
@retention_hours = 24
|
|
42
|
+
@max_spans = 10_000
|
|
43
|
+
@database_path = nil
|
|
44
|
+
@ignore_request = ->(request) { false }
|
|
45
|
+
@subscribe_patterns = DEFAULT_SUBSCRIBE_PATTERNS.dup
|
|
46
|
+
@debug = ENV["FLARE_DEBUG"] == "1"
|
|
47
|
+
@http_metrics_config = HttpMetricsConfig::DEFAULT
|
|
48
|
+
|
|
49
|
+
# Environment-based defaults:
|
|
50
|
+
# - Development: spans ON (detailed debugging), metrics ON
|
|
51
|
+
# - Production: spans OFF (too expensive), metrics ON
|
|
52
|
+
# - Test: spans OFF, metrics OFF
|
|
53
|
+
@spans_enabled = rails_development?
|
|
54
|
+
@metrics_enabled = !rails_test?
|
|
55
|
+
@metrics_flush_interval = 60 # seconds
|
|
56
|
+
|
|
57
|
+
# Metrics HTTP submission defaults
|
|
58
|
+
@url = ENV.fetch("FLARE_URL", credentials_url || "https://flare.am")
|
|
59
|
+
@key = ENV["FLARE_KEY"]
|
|
60
|
+
@metrics_timeout = 5
|
|
61
|
+
@metrics_gzip = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if metrics can be submitted (endpoint and API key configured)
|
|
65
|
+
def metrics_submission_configured?
|
|
66
|
+
!@url.nil? && !@url.empty? &&
|
|
67
|
+
!@key.nil? && !@key.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def database_path
|
|
71
|
+
@database_path || default_database_path
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Configure HTTP metrics path tracking.
|
|
75
|
+
#
|
|
76
|
+
# config.http_metrics do |http|
|
|
77
|
+
# http.host "api.stripe.com" do |h|
|
|
78
|
+
# h.allow %r{/v1/customers}
|
|
79
|
+
# h.allow %r{/v1/charges}
|
|
80
|
+
# h.map %r{/v1/connect/[\w-]+/transfers}, "/v1/connect/:account/transfers"
|
|
81
|
+
# end
|
|
82
|
+
# http.host "api.github.com", :all
|
|
83
|
+
# end
|
|
84
|
+
def http_metrics(&block)
|
|
85
|
+
# Clone defaults on first customization so user additions merge with built-in defaults
|
|
86
|
+
if @http_metrics_config.equal?(HttpMetricsConfig::DEFAULT)
|
|
87
|
+
@http_metrics_config = @http_metrics_config.dup
|
|
88
|
+
end
|
|
89
|
+
yield @http_metrics_config
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def rails_development?
|
|
95
|
+
defined?(Rails) && Rails.env.development?
|
|
96
|
+
rescue StandardError
|
|
97
|
+
false # Default to false for safety - avoids enabling spans unexpectedly in production
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rails_test?
|
|
101
|
+
defined?(Rails) && Rails.env.test?
|
|
102
|
+
rescue StandardError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def credentials_url
|
|
107
|
+
return nil unless defined?(Rails) && Rails.application&.credentials
|
|
108
|
+
Rails.application.credentials.dig(:flare, :url)
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def default_database_path
|
|
114
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
115
|
+
Rails.root.join("db", "flare.sqlite3").to_s
|
|
116
|
+
else
|
|
117
|
+
"flare.sqlite3"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/flare/engine.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Flare
|
|
6
|
+
|
|
7
|
+
# Load secrets from Rails credentials if not already set via ENV
|
|
8
|
+
initializer "flare.defaults", before: :load_config_initializers do |app|
|
|
9
|
+
ENV["FLARE_KEY"] ||= app.credentials.dig(:flare, :key)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Serve static assets from the engine's public directory
|
|
13
|
+
initializer "flare.static_assets" do |app|
|
|
14
|
+
app.middleware.use(
|
|
15
|
+
Rack::Static,
|
|
16
|
+
urls: ["/flare-assets"],
|
|
17
|
+
root: root.join("public"),
|
|
18
|
+
cascade: true
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Phase 1: Configure OTel SDK and instrumentations before middleware is
|
|
23
|
+
# built so Rack/ActionPack can insert their middleware.
|
|
24
|
+
initializer "flare.opentelemetry", before: :build_middleware_stack do
|
|
25
|
+
Flare.configure_opentelemetry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Phase 2: Start the metrics flusher after all initializers have run
|
|
29
|
+
# so user config (metrics_enabled, flush_interval, etc.) is applied.
|
|
30
|
+
config.after_initialize do
|
|
31
|
+
Flare.start_metrics_flusher
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Auto-mount routes in development/test
|
|
35
|
+
initializer "flare.routes", before: :add_routing_paths do |app|
|
|
36
|
+
if Rails.env.development? || Rails.env.test?
|
|
37
|
+
app.routes.prepend do
|
|
38
|
+
mount Flare::Engine => "/flare"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|