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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8eb495874fecf193c205e02c4254d2229ad264cc3e640bba5239940ac3de8e5d
|
|
4
|
+
data.tar.gz: 71218afa6c8972dcf6cf76dc3dddc059a5197a7542a05cf8d0ad8a8f964c290f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '035922584796dd1033486f6723cf859ae0d9f8dd169221817c120f66d3352ad5bbea6793f154d2963476a77e58b7f1c5a3635bf965821d245176271bd4d752de'
|
|
7
|
+
data.tar.gz: 72338f6d8503a8b464f86de6e5397023716d55a810623eefe1d9b81cfdeae4ef3829f15b1228ccb1bdbacf0d957938d54a9e345a5c08f6ba09a7c68cd4b2624e
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 John Nunemaker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Flare — App Monitoring
|
|
2
|
+
|
|
3
|
+
Light up what's slowing you down. [flare.am](https://flare.am)
|
|
4
|
+
|
|
5
|
+
App monitoring for Rails. Captures requests, queries, jobs, cache, views, HTTP calls, mail, and exceptions in development with a waterfall visualization dashboard. Sends lightweight aggregated metrics in production.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **HTTP Requests** - Track all incoming requests with status codes, durations, and controller actions
|
|
10
|
+
- **Background Jobs** - Monitor ActiveJob processing with queue names and execution times
|
|
11
|
+
- **Database Queries** - See all SQL queries with the source location that triggered them
|
|
12
|
+
- **Cache Operations** - Track cache reads, writes, hits, and misses
|
|
13
|
+
- **View Rendering** - Monitor template and partial rendering times
|
|
14
|
+
- **HTTP Client Calls** - See outgoing HTTP requests to external services
|
|
15
|
+
- **Email Delivery** - Track ActionMailer sends with recipients and subjects
|
|
16
|
+
- **Exceptions** - View errors with full stacktraces
|
|
17
|
+
|
|
18
|
+
Each span shows a waterfall visualization of child operations, making it easy to understand request timing and identify bottlenecks.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add Flare to your Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "flare"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The local development dashboard uses SQLite to store spans. If your app doesn't already have `sqlite3` in its Gemfile, add it to the development group:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
group :development do
|
|
32
|
+
gem "sqlite3"
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then run:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle install
|
|
40
|
+
flare setup
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The setup command will:
|
|
44
|
+
|
|
45
|
+
1. Authenticate with flare.am to configure metrics
|
|
46
|
+
2. Create a config initializer with sensible defaults
|
|
47
|
+
3. Update .gitignore
|
|
48
|
+
|
|
49
|
+
Start your Rails server and visit `/flare` to see the dashboard.
|
|
50
|
+
|
|
51
|
+
### Manual Configuration
|
|
52
|
+
|
|
53
|
+
If you prefer to skip the setup wizard, just add the gem and visit `/flare` in development. The dashboard works out of the box with no configuration needed.
|
|
54
|
+
|
|
55
|
+
To enable metrics, set `FLARE_KEY` in your environment (get one at [flare.am](https://flare.am)).
|
|
56
|
+
|
|
57
|
+
### CLI Commands
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
flare setup # Authenticate and configure Flare
|
|
61
|
+
flare doctor # Check your setup for issues
|
|
62
|
+
flare status # Show current configuration
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
All configuration is optional. Flare works out of the box with sensible defaults.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Flare.configure do |config|
|
|
71
|
+
# Enable or disable Flare (default: true)
|
|
72
|
+
config.enabled = true
|
|
73
|
+
|
|
74
|
+
# How long to keep spans in hours (default: 24)
|
|
75
|
+
config.retention_hours = 24
|
|
76
|
+
|
|
77
|
+
# Maximum number of spans to store (default: 10000)
|
|
78
|
+
config.max_spans = 10_000
|
|
79
|
+
|
|
80
|
+
# Path to the SQLite database (default: db/flare.sqlite3)
|
|
81
|
+
config.database_path = Rails.root.join("db", "flare.sqlite3").to_s
|
|
82
|
+
|
|
83
|
+
# Ignore specific requests (receives a Rack::Request, return true to ignore)
|
|
84
|
+
config.ignore_request = ->(request) {
|
|
85
|
+
request.path.start_with?("/health")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Subscribe to custom notification prefixes (default: ["app."])
|
|
89
|
+
config.subscribe_patterns << "mycompany."
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Custom Instrumentation
|
|
94
|
+
|
|
95
|
+
Flare automatically captures Rails internals, but you can also instrument your own code. Use `ActiveSupport::Notifications.instrument` with an `app.` prefix:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# In your application code
|
|
99
|
+
ActiveSupport::Notifications.instrument("app.geocoding", address: address) do
|
|
100
|
+
geocoder.lookup(address)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
ActiveSupport::Notifications.instrument("app.stripe.charge", amount: 1000) do
|
|
104
|
+
Stripe::Charge.create(amount: 1000, currency: "usd")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
ActiveSupport::Notifications.instrument("app.send_sms", to: phone) do
|
|
108
|
+
twilio.messages.create(to: phone, body: message)
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This works in all environments - in production it's essentially a no-op, in development Flare automatically captures and displays it.
|
|
113
|
+
|
|
114
|
+
### Custom Notification Prefixes
|
|
115
|
+
|
|
116
|
+
By default, Flare subscribes to notifications starting with `app.`. You can add additional prefixes:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
Flare.configure do |config|
|
|
120
|
+
config.subscribe_patterns << "mycompany."
|
|
121
|
+
config.subscribe_patterns << "external_service."
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## How It Works
|
|
126
|
+
|
|
127
|
+
Flare uses [OpenTelemetry](https://opentelemetry.io/) for instrumentation. It automatically configures:
|
|
128
|
+
|
|
129
|
+
- `OpenTelemetry::Instrumentation::Rack` - HTTP requests
|
|
130
|
+
- `OpenTelemetry::Instrumentation::ActiveSupport` - Notifications (SQL, cache, mail)
|
|
131
|
+
- `OpenTelemetry::Instrumentation::ActionPack` - Controller actions
|
|
132
|
+
- `OpenTelemetry::Instrumentation::ActionView` - View rendering
|
|
133
|
+
- `OpenTelemetry::Instrumentation::ActiveJob` - Background jobs
|
|
134
|
+
- `OpenTelemetry::Instrumentation::Net::HTTP` - Outgoing HTTP calls
|
|
135
|
+
|
|
136
|
+
Spans are stored in a local SQLite database (`db/flare.sqlite3` by default) and automatically pruned based on retention settings.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
|
|
141
|
+
|
|
142
|
+
## Contributing
|
|
143
|
+
|
|
144
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/jnunemaker/flare.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
layout "flare/application"
|
|
8
|
+
|
|
9
|
+
helper_method :show_redis_tab?
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Only show the Redis tab if:
|
|
14
|
+
# 1. The Redis client library is loaded
|
|
15
|
+
# 2. There are Redis spans in the database
|
|
16
|
+
def show_redis_tab?
|
|
17
|
+
return false unless defined?(::Redis)
|
|
18
|
+
|
|
19
|
+
Flare.storage.count_spans_by_category("redis") > 0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
class JobsController < ApplicationController
|
|
5
|
+
around_action :untrace_request
|
|
6
|
+
|
|
7
|
+
helper_method :current_section, :page_title
|
|
8
|
+
|
|
9
|
+
PER_PAGE = 50
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@offset = params[:offset].to_i
|
|
13
|
+
filter_params = {
|
|
14
|
+
name: params[:name].presence
|
|
15
|
+
}
|
|
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)
|
|
18
|
+
@total_count = Flare.storage.count_jobs(**filter_params)
|
|
19
|
+
@has_next = jobs.size > PER_PAGE
|
|
20
|
+
@jobs = jobs.first(PER_PAGE)
|
|
21
|
+
@has_prev = @offset > 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
@job = Flare.storage.find_job(params[:id])
|
|
26
|
+
|
|
27
|
+
if @job.blank?
|
|
28
|
+
redirect_to jobs_path, alert: "Job not found"
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@spans = Flare.storage.spans_for_trace(params[:id])
|
|
33
|
+
|
|
34
|
+
# Find the root span (the job itself) with full properties
|
|
35
|
+
@root_span = @spans.find { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
|
|
36
|
+
|
|
37
|
+
# Child spans (everything except the root)
|
|
38
|
+
@child_spans = @spans.reject { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def untrace_request
|
|
44
|
+
Flare.untraced { yield }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current_section
|
|
48
|
+
"jobs"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def page_title
|
|
52
|
+
"Jobs"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
class RequestsController < ApplicationController
|
|
5
|
+
around_action :untrace_request
|
|
6
|
+
|
|
7
|
+
helper_method :current_section, :page_title, :current_origin
|
|
8
|
+
|
|
9
|
+
PER_PAGE = 50
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@offset = params[:offset].to_i
|
|
13
|
+
filter_params = {
|
|
14
|
+
status: params[:status].presence,
|
|
15
|
+
method: params[:method].presence,
|
|
16
|
+
name: params[:name].presence,
|
|
17
|
+
origin: current_origin
|
|
18
|
+
}
|
|
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)
|
|
21
|
+
@total_count = Flare.storage.count_requests(**filter_params)
|
|
22
|
+
@has_next = requests.size > PER_PAGE
|
|
23
|
+
@requests = requests.first(PER_PAGE)
|
|
24
|
+
@has_prev = @offset > 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def show
|
|
28
|
+
@request = Flare.storage.find_request(params[:id])
|
|
29
|
+
|
|
30
|
+
if @request.blank?
|
|
31
|
+
redirect_to requests_path, alert: "Request not found"
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@spans = Flare.storage.spans_for_trace(params[:id])
|
|
36
|
+
|
|
37
|
+
# Find the root span (the request itself) with full properties
|
|
38
|
+
@root_span = @spans.find { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
|
|
39
|
+
|
|
40
|
+
# Child spans (everything except the root)
|
|
41
|
+
@child_spans = @spans.reject { |s| s[:parent_span_id] == Flare::MISSING_PARENT_ID }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear
|
|
45
|
+
Flare.storage.clear_all
|
|
46
|
+
redirect_to root_path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def untrace_request
|
|
52
|
+
Flare.untraced { yield }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def current_section
|
|
56
|
+
"requests"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def page_title
|
|
60
|
+
"Requests"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def current_origin
|
|
64
|
+
# If origin was explicitly set (even to empty for "All"), use that
|
|
65
|
+
if params.key?(:origin)
|
|
66
|
+
return params[:origin].presence # nil for "All Origins", "app" or "rails" otherwise
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Default to "app" to hide Rails framework noise
|
|
70
|
+
"app"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
class SpansController < ApplicationController
|
|
5
|
+
around_action :untrace_request
|
|
6
|
+
|
|
7
|
+
helper_method :current_section, :page_title, :category_config
|
|
8
|
+
|
|
9
|
+
PER_PAGE = 50
|
|
10
|
+
|
|
11
|
+
CATEGORIES = {
|
|
12
|
+
"queries" => { title: "Queries", icon: "database", badge_class: "sql" },
|
|
13
|
+
"cache" => { title: "Cache", icon: "archive", badge_class: "cache" },
|
|
14
|
+
"views" => { title: "Views", icon: "layout", badge_class: "view" },
|
|
15
|
+
"http" => { title: "HTTP", icon: "globe", badge_class: "http" },
|
|
16
|
+
"mail" => { title: "Mail", icon: "mail", badge_class: "mail" },
|
|
17
|
+
"redis" => { title: "Redis", icon: "database", badge_class: "other" },
|
|
18
|
+
"exceptions" => { title: "Exceptions", icon: "alert-triangle", badge_class: "exception" }
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def queries
|
|
22
|
+
list_spans("queries")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cache
|
|
26
|
+
list_spans("cache")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def views
|
|
30
|
+
list_spans("views")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def http
|
|
34
|
+
list_spans("http")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mail
|
|
38
|
+
list_spans("mail")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def redis
|
|
42
|
+
list_spans("redis")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def exceptions
|
|
46
|
+
list_spans("exceptions")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show
|
|
50
|
+
@span = Flare.storage.find_span(params[:id])
|
|
51
|
+
|
|
52
|
+
if @span.blank?
|
|
53
|
+
redirect_to requests_path, alert: "Span not found"
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@category = params[:category]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def list_spans(category)
|
|
63
|
+
@category = category
|
|
64
|
+
@offset = params[:offset].to_i
|
|
65
|
+
filter_params = { name: params[:name].presence }
|
|
66
|
+
|
|
67
|
+
spans = Flare.storage.list_spans_by_category(category, **filter_params, limit: PER_PAGE + 1, offset: @offset)
|
|
68
|
+
@total_count = Flare.storage.count_spans_by_category(category, **filter_params)
|
|
69
|
+
@has_next = spans.size > PER_PAGE
|
|
70
|
+
@spans = spans.first(PER_PAGE)
|
|
71
|
+
@has_prev = @offset > 0
|
|
72
|
+
|
|
73
|
+
# Load properties for display
|
|
74
|
+
span_ids = @spans.map { |s| s[:id] }
|
|
75
|
+
if span_ids.any?
|
|
76
|
+
all_properties = Flare.storage.load_properties_for_ids("Flare::Span", span_ids)
|
|
77
|
+
@spans.each do |span|
|
|
78
|
+
span[:properties] = all_properties[span[:id]] || {}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
render :index
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def untrace_request
|
|
86
|
+
Flare.untraced { yield }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def current_section
|
|
90
|
+
@category || "spans"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def page_title
|
|
94
|
+
category_config[:title]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def category_config
|
|
98
|
+
CATEGORIES[@category] || { title: "Spans", icon: "activity", badge_class: "other" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
def span_category(span)
|
|
6
|
+
name = span[:name].to_s.downcase
|
|
7
|
+
case name
|
|
8
|
+
when /sql\.active_record/, /mysql/, /postgres/, /sqlite/
|
|
9
|
+
"sql"
|
|
10
|
+
when /cache/
|
|
11
|
+
"cache"
|
|
12
|
+
when /render/, /view/
|
|
13
|
+
"view"
|
|
14
|
+
when /http/, /net_http/, /faraday/
|
|
15
|
+
"http"
|
|
16
|
+
when /mail/
|
|
17
|
+
"mailer"
|
|
18
|
+
when /job/, /active_job/
|
|
19
|
+
"job"
|
|
20
|
+
when /action_controller/, /process_action/
|
|
21
|
+
"controller"
|
|
22
|
+
else
|
|
23
|
+
"other"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def span_display_info(span, category)
|
|
28
|
+
props = span[:properties] || {}
|
|
29
|
+
case category
|
|
30
|
+
when "queries"
|
|
31
|
+
stmt = props["db.statement"]&.to_s
|
|
32
|
+
name = props["name"]&.to_s
|
|
33
|
+
db_name = props["db.name"]&.to_s
|
|
34
|
+
source_loc = if props["code.filepath"] && props["code.lineno"]
|
|
35
|
+
"#{props["code.filepath"]}:#{props["code.lineno"]}"
|
|
36
|
+
end
|
|
37
|
+
secondary = [name.presence, db_name.presence, source_loc].compact.join(" \u00b7 ").presence
|
|
38
|
+
if stmt.present?
|
|
39
|
+
{ primary: stmt, secondary: secondary }
|
|
40
|
+
elsif name.present?
|
|
41
|
+
{ primary: name, secondary: [db_name.presence, source_loc].compact.join(" \u00b7 ").presence }
|
|
42
|
+
else
|
|
43
|
+
{ primary: span[:name], secondary: [db_name.presence, source_loc].compact.join(" \u00b7 ").presence }
|
|
44
|
+
end
|
|
45
|
+
when "cache"
|
|
46
|
+
key = props["key"]&.to_s
|
|
47
|
+
op = span[:name].to_s.sub(".active_support", "").sub("cache_", "")
|
|
48
|
+
store = props["store"]&.to_s&.sub(/^ActiveSupport::Cache::/, "")
|
|
49
|
+
{ primary: key.presence || span[:name], secondary: store, cache_op: op }
|
|
50
|
+
when "views"
|
|
51
|
+
identifier = props["identifier"] || props["code.filepath"]
|
|
52
|
+
primary = identifier ? identifier.to_s.sub(/^.*\/app\/views\//, "") : span[:name]
|
|
53
|
+
{ primary: primary, secondary: nil }
|
|
54
|
+
when "http"
|
|
55
|
+
full_url = props["http.url"] || ""
|
|
56
|
+
target = props["http.target"] || ""
|
|
57
|
+
host = props["http.host"] || props["net.peer.name"] || props["peer.service"]
|
|
58
|
+
uri = URI.parse(full_url) rescue nil
|
|
59
|
+
if uri && uri.host
|
|
60
|
+
domain = uri.host
|
|
61
|
+
path = uri.path.presence || "/"
|
|
62
|
+
path = "#{path}?#{uri.query}" if uri.query.present?
|
|
63
|
+
else
|
|
64
|
+
domain = host
|
|
65
|
+
path = target.presence || full_url
|
|
66
|
+
end
|
|
67
|
+
method = props["http.method"]
|
|
68
|
+
status = props["http.status_code"]
|
|
69
|
+
{ primary: path.to_s.truncate(100), secondary: domain, http_method: method, http_status: status }
|
|
70
|
+
when "mail"
|
|
71
|
+
mailer = props["mailer"]
|
|
72
|
+
action = props["action"]
|
|
73
|
+
subject = props["subject"]
|
|
74
|
+
if mailer && action
|
|
75
|
+
{ primary: "#{mailer}##{action}", secondary: subject }
|
|
76
|
+
else
|
|
77
|
+
{ primary: span[:name], secondary: nil }
|
|
78
|
+
end
|
|
79
|
+
when "redis"
|
|
80
|
+
cmd = props["db.statement"]&.to_s
|
|
81
|
+
{ primary: cmd.presence || span[:name], secondary: nil }
|
|
82
|
+
when "exceptions"
|
|
83
|
+
exc_type = span[:exception_type]
|
|
84
|
+
exc_message = span[:exception_message]
|
|
85
|
+
primary = if exc_type.present? && exc_message.present?
|
|
86
|
+
"#{exc_type}: #{exc_message}"
|
|
87
|
+
elsif exc_message.present?
|
|
88
|
+
exc_message
|
|
89
|
+
elsif exc_type.present?
|
|
90
|
+
exc_type
|
|
91
|
+
else
|
|
92
|
+
span[:name]
|
|
93
|
+
end
|
|
94
|
+
stacktrace = span[:exception_stacktrace].to_s
|
|
95
|
+
first_app_line = stacktrace.split("\n").find { |line| line.include?("/app/") } || stacktrace.split("\n").first
|
|
96
|
+
secondary = first_app_line&.strip.to_s.truncate(200)
|
|
97
|
+
{ primary: primary, secondary: secondary }
|
|
98
|
+
else
|
|
99
|
+
{ primary: span[:name], secondary: nil }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def format_duration(ms)
|
|
104
|
+
return "-" if ms.nil?
|
|
105
|
+
|
|
106
|
+
if ms >= 1000
|
|
107
|
+
"#{(ms / 1000.0).round(1)}s"
|
|
108
|
+
else
|
|
109
|
+
"#{ms.round(1)}ms"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def format_content(data, indent = 0)
|
|
114
|
+
return "" if data.nil?
|
|
115
|
+
|
|
116
|
+
lines = []
|
|
117
|
+
prefix = " " * indent
|
|
118
|
+
|
|
119
|
+
case data
|
|
120
|
+
when Hash
|
|
121
|
+
data.each do |key, value|
|
|
122
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
|
123
|
+
lines << "#{prefix}#{key}:"
|
|
124
|
+
lines << format_content(value, indent + 1)
|
|
125
|
+
else
|
|
126
|
+
formatted_value = format_value(value)
|
|
127
|
+
if formatted_value.include?("\n")
|
|
128
|
+
lines << "#{prefix}#{key}:"
|
|
129
|
+
formatted_value.each_line do |line|
|
|
130
|
+
lines << "#{prefix} #{line.rstrip}"
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
lines << "#{prefix}#{key}: #{formatted_value}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
when Array
|
|
138
|
+
data.each do |item|
|
|
139
|
+
if item.is_a?(Hash) || item.is_a?(Array)
|
|
140
|
+
lines << "#{prefix}-"
|
|
141
|
+
lines << format_content(item, indent + 1)
|
|
142
|
+
else
|
|
143
|
+
lines << "#{prefix}- #{format_value(item)}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
lines << "#{prefix}#{format_value(data)}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
lines.join("\n")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def format_value(value)
|
|
156
|
+
case value
|
|
157
|
+
when nil
|
|
158
|
+
"null"
|
|
159
|
+
when true, false
|
|
160
|
+
value.to_s
|
|
161
|
+
when Numeric
|
|
162
|
+
value.to_s
|
|
163
|
+
else
|
|
164
|
+
value.to_s
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1 class="page-title"><%= page_title %></h1>
|
|
3
|
+
<div class="filters">
|
|
4
|
+
<%= form_tag(jobs_path, method: :get, id: "filter-form") do %>
|
|
5
|
+
<%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="page-body">
|
|
11
|
+
<div class="card">
|
|
12
|
+
<div class="card-body">
|
|
13
|
+
<% if @jobs.blank? %>
|
|
14
|
+
<div class="empty-state">
|
|
15
|
+
<div class="empty-state-icon">
|
|
16
|
+
<i class="bi bi-briefcase" style="font-size: 1.5rem;"></i>
|
|
17
|
+
</div>
|
|
18
|
+
<h3>No jobs found</h3>
|
|
19
|
+
<p>Data will appear here as your application processes background jobs.</p>
|
|
20
|
+
</div>
|
|
21
|
+
<% else %>
|
|
22
|
+
<table class="data-table">
|
|
23
|
+
<thead>
|
|
24
|
+
<tr>
|
|
25
|
+
<th>Job</th>
|
|
26
|
+
<th style="width: 100px; text-align: right;">Duration</th>
|
|
27
|
+
<th style="width: 120px; text-align: right;">Happened</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
<% @jobs.each do |job| %>
|
|
32
|
+
<% started_at = Time.at(job[:start_timestamp] / 1_000_000_000.0) rescue nil %>
|
|
33
|
+
<%
|
|
34
|
+
display_name = job[:job_class] || job[:name].to_s.sub(/ (process|publish)$/, '')
|
|
35
|
+
%>
|
|
36
|
+
<tr onclick="navigateIfNotSelecting('<%= job_path(job[:trace_id]) %>')" style="cursor: pointer;">
|
|
37
|
+
<td class="path-cell">
|
|
38
|
+
<span class="job-name path-text" title="<%= display_name %>"><%= display_name %></span>
|
|
39
|
+
<% queue = job[:queue_name] || "default" %>
|
|
40
|
+
<span class="path-subtext" title="<%= queue %>"><%= queue %></span>
|
|
41
|
+
</td>
|
|
42
|
+
<td class="duration" style="text-align: right;"><%= format_duration(job[:duration_ms]) %></td>
|
|
43
|
+
<td class="timestamp" style="text-align: right;"><%= started_at ? time_ago_in_words(started_at) : "-" %></td>
|
|
44
|
+
</tr>
|
|
45
|
+
<% end %>
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
|
|
49
|
+
<% if @total_count > 0 %>
|
|
50
|
+
<div class="pagination">
|
|
51
|
+
<div class="pagination-side">
|
|
52
|
+
<% if @has_prev %>
|
|
53
|
+
<%= link_to "Previous", jobs_path(request.query_parameters.merge(offset: [@offset - 50, 0].max)), class: "pagination-link" %>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="pagination-info">
|
|
57
|
+
<%= number_with_delimiter(@offset + 1) %> - <%= number_with_delimiter([@offset + @jobs.size, @total_count].min) %> of <%= number_with_delimiter(@total_count) %>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="pagination-side" style="text-align: right;">
|
|
60
|
+
<% if @has_next %>
|
|
61
|
+
<%= link_to "Next", jobs_path(request.query_parameters.merge(offset: @offset + 50)), class: "pagination-link" %>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|