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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/app/controllers/flare/application_controller.rb +22 -0
  6. data/app/controllers/flare/jobs_controller.rb +55 -0
  7. data/app/controllers/flare/requests_controller.rb +73 -0
  8. data/app/controllers/flare/spans_controller.rb +101 -0
  9. data/app/helpers/flare/application_helper.rb +168 -0
  10. data/app/views/flare/jobs/index.html.erb +69 -0
  11. data/app/views/flare/jobs/show.html.erb +323 -0
  12. data/app/views/flare/requests/index.html.erb +120 -0
  13. data/app/views/flare/requests/show.html.erb +498 -0
  14. data/app/views/flare/spans/index.html.erb +112 -0
  15. data/app/views/flare/spans/show.html.erb +184 -0
  16. data/app/views/layouts/flare/application.html.erb +126 -0
  17. data/config/routes.rb +20 -0
  18. data/exe/flare +9 -0
  19. data/lib/flare/backoff_policy.rb +73 -0
  20. data/lib/flare/cli/doctor_command.rb +129 -0
  21. data/lib/flare/cli/output.rb +45 -0
  22. data/lib/flare/cli/setup_command.rb +404 -0
  23. data/lib/flare/cli/status_command.rb +47 -0
  24. data/lib/flare/cli.rb +50 -0
  25. data/lib/flare/configuration.rb +121 -0
  26. data/lib/flare/engine.rb +43 -0
  27. data/lib/flare/http_metrics_config.rb +101 -0
  28. data/lib/flare/metric_counter.rb +45 -0
  29. data/lib/flare/metric_flusher.rb +124 -0
  30. data/lib/flare/metric_key.rb +42 -0
  31. data/lib/flare/metric_span_processor.rb +470 -0
  32. data/lib/flare/metric_storage.rb +42 -0
  33. data/lib/flare/metric_submitter.rb +221 -0
  34. data/lib/flare/source_location.rb +113 -0
  35. data/lib/flare/sqlite_exporter.rb +279 -0
  36. data/lib/flare/storage/sqlite.rb +789 -0
  37. data/lib/flare/storage.rb +54 -0
  38. data/lib/flare/version.rb +5 -0
  39. data/lib/flare.rb +411 -0
  40. data/public/flare-assets/flare.css +1245 -0
  41. data/public/flare-assets/images/flipper.png +0 -0
  42. metadata +240 -0
@@ -0,0 +1,184 @@
1
+ <%
2
+ props = @span[:properties] || {}
3
+ events = @span[:events] || []
4
+
5
+ started_at = Time.at(@span[:start_timestamp] / 1_000_000_000.0) rescue nil
6
+
7
+ # Determine badge class based on category
8
+ badge_class = case @category
9
+ when "queries" then "sql"
10
+ when "cache" then "cache"
11
+ when "views" then "view"
12
+ when "http" then "http"
13
+ when "mail" then "mail"
14
+ when "exceptions" then "exception"
15
+ else "other"
16
+ end
17
+
18
+ # Get display name
19
+ display_name = @span[:name]
20
+
21
+ # Get statement/content for code block
22
+ statement = case @category
23
+ when "queries"
24
+ props["db.statement"]
25
+ when "redis"
26
+ props["db.statement"]
27
+ when "http"
28
+ nil # No statement block for HTTP
29
+ else
30
+ nil
31
+ end
32
+
33
+ # Root span link
34
+ root_path = if @span[:root_trace_id]
35
+ if @span[:root_kind] == "consumer"
36
+ job_path(@span[:root_trace_id])
37
+ else
38
+ request_path(@span[:root_trace_id])
39
+ end
40
+ end
41
+
42
+ root_label = if @span[:root_trace_id]
43
+ if @span[:root_kind] == "consumer"
44
+ # For jobs, code.namespace contains the job class name
45
+ @span[:root_controller] || @span[:root_name].to_s.sub(/ (process|publish)$/, '')
46
+ elsif @span[:root_controller] && @span[:root_action]
47
+ # For requests, show controller#action
48
+ "#{@span[:root_controller]}##{@span[:root_action]}"
49
+ else
50
+ @span[:root_name]
51
+ end
52
+ end
53
+ %>
54
+
55
+ <div class="page-header">
56
+ <div class="detail-header">
57
+ <div class="detail-header-left">
58
+ <div class="detail-title">
59
+ <span class="badge badge-<%= badge_class %>"><%= @category&.singularize&.titleize || "Span" %></span>
60
+ <h1><%= display_name %></h1>
61
+ </div>
62
+ <div class="detail-meta">
63
+ <div class="meta-item">
64
+ <span class="meta-label">Duration</span>
65
+ <span class="meta-value mono"><%= format_duration(@span[:duration_ms]) %></span>
66
+ </div>
67
+ <div class="meta-item">
68
+ <span class="meta-label">Time</span>
69
+ <span class="meta-value"><%= started_at&.strftime("%b %d, %Y %H:%M:%S") %></span>
70
+ </div>
71
+ <% if root_path %>
72
+ <div class="meta-item">
73
+ <span class="meta-label">Parent</span>
74
+ <span class="meta-value"><a href="<%= root_path %>"><%= root_label %></a></span>
75
+ </div>
76
+ <% end %>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ <div class="page-body">
83
+ <div class="card">
84
+ <%
85
+ # Filter properties for display
86
+ display_props = props.reject do |key, value|
87
+ key == "db.statement" || key == "code.stacktrace" || value.is_a?(Hash) || value.is_a?(Array)
88
+ end
89
+ %>
90
+ <div class="details-sections">
91
+ <% if display_props.any? %>
92
+ <!-- Properties Section -->
93
+ <div class="details-section">
94
+ <div class="section-header">
95
+ <div class="section-icon properties">
96
+ <i class="bi bi-file-text"></i>
97
+ </div>
98
+ <span class="section-title">Properties</span>
99
+ </div>
100
+ <div class="section-grid">
101
+ <% display_props.each do |key, value| %>
102
+ <div class="detail-item">
103
+ <span class="detail-label"><%= key.to_s.split(".").last.titleize %></span>
104
+ <span class="detail-value <%= 'mono' if key.include?('.') %>" title="<%= value %>"><%= value %></span>
105
+ </div>
106
+ <% end %>
107
+ </div>
108
+ </div>
109
+ <% end %>
110
+
111
+ <% if statement.present? %>
112
+ <!-- Statement Section -->
113
+ <div class="details-section">
114
+ <div class="section-header">
115
+ <div class="section-icon statement">
116
+ <i class="bi bi-code-slash"></i>
117
+ </div>
118
+ <span class="section-title">Statement</span>
119
+ </div>
120
+ <div class="statement-block">
121
+ <pre><code><%= statement %></code></pre>
122
+ </div>
123
+ </div>
124
+ <% end %>
125
+
126
+ <% if props["code.stacktrace"].present? %>
127
+ <!-- Trace Section -->
128
+ <div class="details-section">
129
+ <div class="section-header">
130
+ <div class="section-icon trace">
131
+ <i class="bi bi-stack"></i>
132
+ </div>
133
+ <span class="section-title">Source Trace</span>
134
+ </div>
135
+ <ul class="stacktrace-list">
136
+ <% props["code.stacktrace"].split("\n").each do |line| %>
137
+ <li>
138
+ <% if line =~ /\A(.+):(\d+) in `(.+)'\z/ %>
139
+ <span class="file-path"><%= $1 %></span><span class="line-number">:<%= $2 %></span> in <span class="method-name">`<%= $3 %>'</span>
140
+ <% elsif line =~ /\A(.+):(\d+)\z/ %>
141
+ <span class="file-path"><%= $1 %></span><span class="line-number">:<%= $2 %></span>
142
+ <% else %>
143
+ <%= line %>
144
+ <% end %>
145
+ </li>
146
+ <% end %>
147
+ </ul>
148
+ </div>
149
+ <% end %>
150
+
151
+ <% if events.any? %>
152
+ <!-- Events Section -->
153
+ <div class="details-section">
154
+ <div class="section-header">
155
+ <div class="section-icon events">
156
+ <i class="bi bi-lightning"></i>
157
+ </div>
158
+ <span class="section-title">Events (<%= events.size %>)</span>
159
+ </div>
160
+ <% events.each do |event| %>
161
+ <div class="event-item">
162
+ <div class="event-name"><%= event[:name] %></div>
163
+ <% if event[:properties].present? %>
164
+ <div class="event-props">
165
+ <% event[:properties].each do |key, value| %>
166
+ <div><strong><%= key %>:</strong> <%= value.to_s.truncate(500) %></div>
167
+ <% end %>
168
+ </div>
169
+ <% end %>
170
+ </div>
171
+ <% end %>
172
+ </div>
173
+ <% end %>
174
+
175
+ <% if display_props.blank? && statement.blank? && props["code.stacktrace"].blank? && events.empty? %>
176
+ <div class="details-section">
177
+ <div class="empty-clues" style="padding: 1.25rem;">
178
+ <p>No additional details available for this span.</p>
179
+ </div>
180
+ </div>
181
+ <% end %>
182
+ </div>
183
+ </div>
184
+ </div>
@@ -0,0 +1,126 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Flare</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
8
+ <link href="/flare-assets/flare.css" rel="stylesheet">
9
+ </head>
10
+ <body>
11
+ <div class="app-layout">
12
+ <aside class="sidebar">
13
+ <div class="sidebar-header">
14
+ <a href="<%= flare.root_path %>" class="logo">
15
+ <div class="logo-icon">
16
+ <i class="bi bi-train-front"></i>
17
+ </div>
18
+ <span class="logo-text">Flare</span>
19
+ </a>
20
+ </div>
21
+
22
+ <nav class="sidebar-nav">
23
+ <div class="nav-section">
24
+ <div class="nav-section-title">Traces</div>
25
+
26
+ <a href="<%= flare.requests_path %>" class="nav-item <%= 'active' if current_section == 'requests' %>">
27
+ <i class="bi bi-arrow-left-right"></i>
28
+ <span>Requests</span>
29
+ </a>
30
+
31
+ <a href="<%= flare.jobs_path %>" class="nav-item <%= 'active' if current_section == 'jobs' %>">
32
+ <i class="bi bi-briefcase"></i>
33
+ <span>Jobs</span>
34
+ </a>
35
+ </div>
36
+
37
+ <div class="nav-section">
38
+ <div class="nav-section-title">Spans</div>
39
+
40
+ <a href="<%= flare.exceptions_spans_path %>" class="nav-item <%= 'active' if current_section == 'exceptions' %>">
41
+ <i class="bi bi-exclamation-triangle"></i>
42
+ <span>Exceptions</span>
43
+ </a>
44
+
45
+ <a href="<%= flare.queries_spans_path %>" class="nav-item <%= 'active' if current_section == 'queries' %>">
46
+ <i class="bi bi-database"></i>
47
+ <span>Queries</span>
48
+ </a>
49
+
50
+ <a href="<%= flare.cache_spans_path %>" class="nav-item <%= 'active' if current_section == 'cache' %>">
51
+ <i class="bi bi-archive"></i>
52
+ <span>Cache</span>
53
+ </a>
54
+
55
+ <a href="<%= flare.views_spans_path %>" class="nav-item <%= 'active' if current_section == 'views' %>">
56
+ <i class="bi bi-layout-sidebar-inset"></i>
57
+ <span>Views</span>
58
+ </a>
59
+
60
+ <a href="<%= flare.http_spans_path %>" class="nav-item <%= 'active' if current_section == 'http' %>">
61
+ <i class="bi bi-globe"></i>
62
+ <span>HTTP</span>
63
+ </a>
64
+
65
+ <a href="<%= flare.mail_spans_path %>" class="nav-item <%= 'active' if current_section == 'mail' %>">
66
+ <i class="bi bi-envelope"></i>
67
+ <span>Mail</span>
68
+ </a>
69
+
70
+ <% if show_redis_tab? %>
71
+ <a href="<%= flare.redis_spans_path %>" class="nav-item <%= 'active' if current_section == 'redis' %>">
72
+ <i class="bi bi-layers"></i>
73
+ <span>Redis</span>
74
+ </a>
75
+ <% end %>
76
+ </div>
77
+ </nav>
78
+
79
+ <div class="sidebar-footer">
80
+ <a href="https://www.flippercloud.io?utm_source=flare&utm_medium=dashboard&utm_campaign=sidebar_ad" target="_blank" rel="noopener" class="sidebar-ad">
81
+ <div class="sidebar-ad-inner">
82
+ <div class="sidebar-ad-logo">
83
+ <img src="/flare-assets/images/flipper.png" alt="Flipper">
84
+ </div>
85
+ <div class="sidebar-ad-text">
86
+ <div class="sidebar-ad-title">Flipper</div>
87
+ Ship features safely.<br>Roll back instantly.
88
+ </div>
89
+ </div>
90
+ </a>
91
+ <%= button_to flare.clear_data_path, method: :delete, class: "btn-clear", data: { confirm: "Clear all data? This cannot be undone." } do %>
92
+ <i class="bi bi-trash3"></i>
93
+ <span>Clear Data</span>
94
+ <% end %>
95
+ </div>
96
+ </aside>
97
+
98
+ <main class="main-content">
99
+ <% if flash[:alert] %>
100
+ <div style="padding: 1.25rem 1.5rem 0;">
101
+ <div class="alert alert-danger"><%= flash[:alert] %></div>
102
+ </div>
103
+ <% end %>
104
+ <%= yield %>
105
+ </main>
106
+ </div>
107
+ <script>
108
+ // Handle data-confirm on buttons/forms
109
+ document.addEventListener('click', function(e) {
110
+ const button = e.target.closest('[data-confirm]');
111
+ if (button && !confirm(button.dataset.confirm)) {
112
+ e.preventDefault();
113
+ }
114
+ });
115
+
116
+ // Navigate to URL only if user isn't selecting text
117
+ function navigateIfNotSelecting(url) {
118
+ const selection = window.getSelection();
119
+ if (selection && selection.toString().length > 0) {
120
+ return; // User is selecting text, don't navigate
121
+ }
122
+ window.location = url;
123
+ }
124
+ </script>
125
+ </body>
126
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Flare::Engine.routes.draw do
4
+ resources :requests, only: [:index, :show]
5
+ resources :jobs, only: [:index, :show]
6
+
7
+ # Span category routes
8
+ get "spans/queries", to: "spans#queries", as: :queries_spans
9
+ get "spans/cache", to: "spans#cache", as: :cache_spans
10
+ get "spans/views", to: "spans#views", as: :views_spans
11
+ get "spans/http", to: "spans#http", as: :http_spans
12
+ get "spans/mail", to: "spans#mail", as: :mail_spans
13
+ get "spans/redis", to: "spans#redis", as: :redis_spans
14
+ get "spans/exceptions", to: "spans#exceptions", as: :exceptions_spans
15
+ get "spans/:id", to: "spans#show", as: :span
16
+
17
+ delete "clear", to: "requests#clear", as: :clear_data
18
+
19
+ root to: "requests#index"
20
+ end
data/exe/flare ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+ $stderr.sync = true
6
+
7
+ require_relative "../lib/flare/cli"
8
+
9
+ Flare::CLI.start(ARGV)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ # Exponential backoff with jitter for retry logic.
5
+ # Based on Flipper's implementation.
6
+ class BackoffPolicy
7
+ # Default minimum timeout between intervals in milliseconds
8
+ MIN_TIMEOUT_MS = 1_000 # 1 second
9
+
10
+ # Default maximum timeout between intervals in milliseconds
11
+ MAX_TIMEOUT_MS = 30_000 # 30 seconds
12
+
13
+ # Value to multiply the current interval with for each retry attempt
14
+ MULTIPLIER = 1.5
15
+
16
+ # Randomization factor to create a range around the retry interval
17
+ RANDOMIZATION_FACTOR = 0.5
18
+
19
+ attr_reader :min_timeout_ms, :max_timeout_ms, :multiplier, :randomization_factor
20
+ attr_reader :attempts
21
+
22
+ def initialize(options = {})
23
+ @min_timeout_ms = options.fetch(:min_timeout_ms) {
24
+ ENV.fetch("FLARE_BACKOFF_MIN_TIMEOUT_MS", MIN_TIMEOUT_MS).to_i
25
+ }
26
+ @max_timeout_ms = options.fetch(:max_timeout_ms) {
27
+ ENV.fetch("FLARE_BACKOFF_MAX_TIMEOUT_MS", MAX_TIMEOUT_MS).to_i
28
+ }
29
+ @multiplier = options.fetch(:multiplier) {
30
+ ENV.fetch("FLARE_BACKOFF_MULTIPLIER", MULTIPLIER).to_f
31
+ }
32
+ @randomization_factor = options.fetch(:randomization_factor) {
33
+ ENV.fetch("FLARE_BACKOFF_RANDOMIZATION_FACTOR", RANDOMIZATION_FACTOR).to_f
34
+ }
35
+
36
+ validate!
37
+ @attempts = 0
38
+ end
39
+
40
+ # Returns the next backoff interval in milliseconds.
41
+ def next_interval
42
+ interval = @min_timeout_ms * (@multiplier**@attempts)
43
+ interval = add_jitter(interval, @randomization_factor)
44
+
45
+ @attempts += 1
46
+
47
+ # Cap the interval to the max timeout
48
+ result = [interval, @max_timeout_ms].min
49
+ # Add small jitter even when maxed out
50
+ result == @max_timeout_ms ? add_jitter(result, 0.05) : result
51
+ end
52
+
53
+ def reset
54
+ @attempts = 0
55
+ end
56
+
57
+ private
58
+
59
+ def validate!
60
+ raise ArgumentError, ":min_timeout_ms must be >= 0" unless @min_timeout_ms >= 0
61
+ raise ArgumentError, ":max_timeout_ms must be >= 0" unless @max_timeout_ms >= 0
62
+ raise ArgumentError, ":min_timeout_ms must be <= :max_timeout_ms" unless @min_timeout_ms <= @max_timeout_ms
63
+ end
64
+
65
+ def add_jitter(base, randomization_factor)
66
+ random_number = rand
67
+ max_deviation = base * randomization_factor
68
+ deviation = random_number * max_deviation
69
+
70
+ random_number < 0.5 ? base - deviation : base + deviation
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "output"
4
+
5
+ module Flare
6
+ class DoctorCommand
7
+ include CLI::Output
8
+
9
+ def run
10
+ puts bold("Flare Doctor")
11
+ puts
12
+
13
+ results = []
14
+ results << check_initializer
15
+ results << check_key
16
+ results << check_gitignore
17
+ results << check_database if spans_expected?
18
+
19
+ puts
20
+ if results.all?
21
+ puts green("Everything looks good!")
22
+ else
23
+ puts "Run #{bold("flare setup")} to fix issues."
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def check_initializer
30
+ path = File.join(Dir.pwd, "config/initializers/flare.rb")
31
+ if File.exist?(path)
32
+ puts " #{checkmark} Initializer exists"
33
+ true
34
+ else
35
+ puts " #{xmark} Initializer not found"
36
+ puts " Run #{bold("flare setup")} to create one"
37
+ false
38
+ end
39
+ end
40
+
41
+ def check_key
42
+ if key_configured?
43
+ puts " #{checkmark} FLARE_KEY configured"
44
+ true
45
+ elsif credentials_exist?
46
+ puts " #{checkmark} FLARE_KEY not in ENV or .env (may be in Rails credentials)"
47
+ true
48
+ else
49
+ puts " #{xmark} FLARE_KEY not found"
50
+ puts " Run #{bold("flare setup")} to authenticate"
51
+ false
52
+ end
53
+ end
54
+
55
+ def check_gitignore
56
+ gitignore_path = File.join(Dir.pwd, ".gitignore")
57
+
58
+ unless File.exist?(gitignore_path)
59
+ puts " #{warn_mark} No .gitignore file found"
60
+ return true
61
+ end
62
+
63
+ contents = File.read(gitignore_path)
64
+ missing = []
65
+ missing << ".env" unless contents.match?(/^\.env$/)
66
+ missing << "flare.sqlite3*" unless contents.include?("flare.sqlite3")
67
+
68
+ if missing.empty?
69
+ puts " #{checkmark} .gitignore entries present"
70
+ true
71
+ else
72
+ puts " #{warn_mark} .gitignore missing: #{missing.join(", ")}"
73
+ puts " Run #{bold("flare setup")} to add them"
74
+ false
75
+ end
76
+ end
77
+
78
+ def check_database
79
+ db_path = File.join(Dir.pwd, "db", "flare.sqlite3")
80
+ db_dir = File.dirname(db_path)
81
+
82
+ if File.exist?(db_path)
83
+ if File.writable?(db_path)
84
+ puts " #{checkmark} Database exists and is writable"
85
+ true
86
+ else
87
+ puts " #{xmark} Database exists but is not writable"
88
+ puts " Check file permissions on #{db_path}"
89
+ false
90
+ end
91
+ elsif File.exist?(db_dir) && File.writable?(db_dir)
92
+ puts " #{checkmark} Database directory is writable (will be created on first request)"
93
+ true
94
+ else
95
+ puts " #{warn_mark} Database directory #{db_dir} does not exist"
96
+ puts " It will be created when you start your Rails server"
97
+ true
98
+ end
99
+ end
100
+
101
+ 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
112
+ end
113
+
114
+ def spans_expected?
115
+ # In production, spans are off by default — skip database check
116
+ env = ENV.fetch("RAILS_ENV", "development")
117
+ return false if env == "production"
118
+
119
+ # If explicitly disabled in the initializer, skip database check
120
+ init_path = File.join(Dir.pwd, "config/initializers/flare.rb")
121
+ if File.exist?(init_path)
122
+ content = File.read(init_path)
123
+ return false if content.match?(/^\s*config\.spans_enabled\s*=\s*false/)
124
+ end
125
+
126
+ true
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module CLI
5
+ module Output
6
+ private
7
+
8
+ def color?
9
+ $stdout.tty?
10
+ end
11
+
12
+ def green(text)
13
+ color? ? "\e[32m#{text}\e[0m" : text
14
+ end
15
+
16
+ def yellow(text)
17
+ color? ? "\e[33m#{text}\e[0m" : text
18
+ end
19
+
20
+ def red(text)
21
+ color? ? "\e[31m#{text}\e[0m" : text
22
+ end
23
+
24
+ def bold(text)
25
+ color? ? "\e[1m#{text}\e[0m" : text
26
+ end
27
+
28
+ def dim(text)
29
+ color? ? "\e[2m#{text}\e[0m" : text
30
+ end
31
+
32
+ def checkmark
33
+ green("✓")
34
+ end
35
+
36
+ def xmark
37
+ red("✗")
38
+ end
39
+
40
+ def warn_mark
41
+ yellow("!")
42
+ end
43
+ end
44
+ end
45
+ end