lens-rails 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +25 -1
- data/lib/lens/rails/configuration.rb +16 -1
- data/lib/lens/rails/error_exporter.rb +158 -0
- data/lib/lens/rails/log_exporter.rb +87 -45
- data/lib/lens/rails/metrics_exporter.rb +67 -18
- data/lib/lens/rails/railtie.rb +35 -12
- data/lib/lens/rails/requests_exporter.rb +139 -32
- data/lib/lens/rails/sanitizer.rb +80 -0
- data/lib/lens/rails/version.rb +1 -1
- data/lib/lens/rails.rb +4 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d90077b98ae4006d57f5621f0f3f44d61193161757bf28be6e386b5f560cdbd8
|
|
4
|
+
data.tar.gz: cff65959840d06f3a25e8fefec6067e3da6b1b73f4a0aefe9d6e82bdb68863db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f6df7f5351a3f46423ba69a97f366fafe61364b30380a40787f7acfb7b5f7f0e5d11288f0a420c8f70c97913c7ccdc8e63c9bfe8c4369dfa68cae42d77db2f4
|
|
7
|
+
data.tar.gz: 35eb63272c6e3401c2712f3aaaee7c1eaef0e0873d3c7d330ca437121828ec300e765bf2596f36ef4a64d85bb25288f872728b52bb6bb074912032cb0a6ace3b
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# lens-rails
|
|
2
2
|
|
|
3
|
-
Zero-config APM integration for [Lens](https://github.com/gustech/lens). Drop this gem into any Rails app and logs, metrics, and
|
|
3
|
+
Zero-config APM integration for [Lens](https://github.com/gustech/lens). Drop this gem into any Rails app and logs, metrics, requests, jobs, and errors start flowing to your Lens instance automatically — no OpenTelemetry SDK, no protobuf, no additional processes.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -27,9 +27,12 @@ That's it. No initializer required.
|
|
|
27
27
|
| **Metrics** | Rack middleware | Request count, error count, mean/p50/p95 duration — flushed every 30 s |
|
|
28
28
|
| **HTTP requests** | `process_action.action_controller` | Controller, action, method, path, status, duration, SQL summary, operation waterfall |
|
|
29
29
|
| **Background jobs** | `perform.active_job` | Job class, queue, duration, SQL summary, operation waterfall |
|
|
30
|
+
| **Errors** | `Rails.error` | All reported exceptions — class, message, backtrace, context, severity |
|
|
30
31
|
|
|
31
32
|
Each HTTP request and job record includes a flat **operation waterfall**: one entry per SQL query (name + SQL text + duration + offset from request start) and per view render.
|
|
32
33
|
|
|
34
|
+
Errors are deduplicated server-side by fingerprint (app + class + message) and surfaced with a resolve workflow.
|
|
35
|
+
|
|
33
36
|
## Configuration
|
|
34
37
|
|
|
35
38
|
To override defaults, add an initializer:
|
|
@@ -64,9 +67,29 @@ Lens::Rails.configure do |config|
|
|
|
64
67
|
|
|
65
68
|
# Maximum time to wait for a final flush on process shutdown (seconds). Default: 5.
|
|
66
69
|
config.shutdown_timeout = 5
|
|
70
|
+
|
|
71
|
+
# Exception classes to suppress from error reporting.
|
|
72
|
+
# Defaults to common Rails routing and auth errors (RoutingError, RecordNotFound, etc.).
|
|
73
|
+
config.error_ignore_classes = %w[
|
|
74
|
+
ActionController::RoutingError
|
|
75
|
+
ActiveRecord::RecordNotFound
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# Queue size for error records (drop-on-full). Default: 1_000.
|
|
79
|
+
config.max_error_buffer = 1_000
|
|
67
80
|
end
|
|
68
81
|
```
|
|
69
82
|
|
|
83
|
+
## Reporting errors explicitly
|
|
84
|
+
|
|
85
|
+
The gem subscribes to `Rails.error` automatically, so any exception reported through that interface (including unhandled exceptions in requests and jobs) is captured. To report an error directly:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Lens.error(exception, context: { user_id: current_user.id })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This delegates to `Rails.error.report` with `handled: true`, so the exception is not re-raised.
|
|
92
|
+
|
|
70
93
|
## How it works
|
|
71
94
|
|
|
72
95
|
The gem wires up three components via a Railtie:
|
|
@@ -74,6 +97,7 @@ The gem wires up three components via a Railtie:
|
|
|
74
97
|
- **`LogExporter`** — broadcasts into `Rails.logger` and batches records to `POST /v1/logs` every 5 s.
|
|
75
98
|
- **`MetricsExporter`** — Rack middleware that tracks request durations via reservoir sampling and flushes to `POST /v1/metrics` every 30 s.
|
|
76
99
|
- **`RequestsExporter`** — subscribes to `ActiveSupport::Notifications` (`process_action`, `sql.active_record`, `render_template`, `perform.active_job`) and flushes request and job records to `POST /v1/requests` every 30 s.
|
|
100
|
+
- **`ErrorExporter`** — registers as a `Rails.error` subscriber and ships error records to `POST /v1/errors`. Context hashes are sanitized (circular refs, depth limits, 64 KB string cap) before serialization.
|
|
77
101
|
|
|
78
102
|
All payloads are gzip-compressed JSON. Background flush threads restart automatically after a Puma or Unicorn fork.
|
|
79
103
|
|
|
@@ -5,7 +5,8 @@ module Lens
|
|
|
5
5
|
:max_log_buffer, :min_log_severity,
|
|
6
6
|
:sql_threshold_ms, :sql_ignore,
|
|
7
7
|
:exporter_open_timeout, :exporter_read_timeout,
|
|
8
|
-
:shutdown_timeout
|
|
8
|
+
:shutdown_timeout,
|
|
9
|
+
:error_ignore_classes, :max_error_buffer
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
12
|
@url = ENV["LENS_URL"]
|
|
@@ -18,6 +19,20 @@ module Lens
|
|
|
18
19
|
@exporter_open_timeout = 2
|
|
19
20
|
@exporter_read_timeout = 2
|
|
20
21
|
@shutdown_timeout = 5
|
|
22
|
+
@error_ignore_classes = %w[
|
|
23
|
+
ActionController::RoutingError
|
|
24
|
+
AbstractController::ActionNotFound
|
|
25
|
+
ActionController::MethodNotAllowed
|
|
26
|
+
ActionController::UnknownHttpMethod
|
|
27
|
+
ActionController::UnknownFormat
|
|
28
|
+
ActionController::InvalidAuthenticityToken
|
|
29
|
+
ActionController::InvalidCrossOriginRequest
|
|
30
|
+
ActionDispatch::Http::Parameters::ParseError
|
|
31
|
+
ActionController::BadRequest
|
|
32
|
+
ActionController::ParameterMissing
|
|
33
|
+
ActiveRecord::RecordNotFound
|
|
34
|
+
]
|
|
35
|
+
@max_error_buffer = 1_000
|
|
21
36
|
end
|
|
22
37
|
|
|
23
38
|
def configured?
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "lens/rails/sanitizer"
|
|
7
|
+
|
|
8
|
+
module Lens
|
|
9
|
+
module Rails
|
|
10
|
+
# Implements the Rails.error subscriber interface. Buffers error reports on
|
|
11
|
+
# a SizedQueue and ships them in batches to POST /v1/errors via a persistent
|
|
12
|
+
# HTTP connection on a dedicated worker thread.
|
|
13
|
+
class ErrorExporter
|
|
14
|
+
BATCH_SIZE = 200
|
|
15
|
+
|
|
16
|
+
def initialize(url:, token:, service_name:,
|
|
17
|
+
ignore_classes: [],
|
|
18
|
+
max_buffer: 1_000,
|
|
19
|
+
open_timeout: 2, read_timeout: 2)
|
|
20
|
+
@uri = URI("#{url}/v1/errors")
|
|
21
|
+
@token = token
|
|
22
|
+
@service_name = service_name
|
|
23
|
+
@ignore_classes = ignore_classes
|
|
24
|
+
@max_buffer = max_buffer
|
|
25
|
+
@open_timeout = open_timeout
|
|
26
|
+
@read_timeout = read_timeout
|
|
27
|
+
@queue = SizedQueue.new(max_buffer)
|
|
28
|
+
@http = nil
|
|
29
|
+
@fork_mutex = Mutex.new
|
|
30
|
+
start_worker
|
|
31
|
+
Lens::Rails.register_flushable(self)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Rails.error subscriber interface.
|
|
35
|
+
def report(error, handled:, severity: :error, context: {}, source: nil)
|
|
36
|
+
return if @ignore_classes.include?(error.class.name)
|
|
37
|
+
|
|
38
|
+
rec = {
|
|
39
|
+
"class" => error.class.name,
|
|
40
|
+
"message" => error.message.to_s.first(2_000),
|
|
41
|
+
"backtrace" => error.backtrace&.first(50) || [],
|
|
42
|
+
"context" => Sanitizer.sanitize(context || {}),
|
|
43
|
+
"severity" => severity.to_s,
|
|
44
|
+
"source" => source,
|
|
45
|
+
"occurred_at" => Time.now.iso8601(3),
|
|
46
|
+
}.compact
|
|
47
|
+
|
|
48
|
+
ensure_worker!
|
|
49
|
+
@queue.push(rec, true)
|
|
50
|
+
rescue ThreadError
|
|
51
|
+
# Buffer full — drop silently
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def shutdown(timeout:)
|
|
55
|
+
@queue.close
|
|
56
|
+
@worker&.join(timeout)
|
|
57
|
+
rescue
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ensure_worker!
|
|
61
|
+
return if @worker&.alive?
|
|
62
|
+
@fork_mutex.synchronize do
|
|
63
|
+
return if @worker&.alive?
|
|
64
|
+
restart_flush_thread
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def restart_flush_thread
|
|
69
|
+
begin
|
|
70
|
+
@worker&.kill
|
|
71
|
+
rescue
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
close_http
|
|
75
|
+
@queue = SizedQueue.new(@max_buffer)
|
|
76
|
+
start_worker
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def start_worker
|
|
82
|
+
@worker = Thread.new do
|
|
83
|
+
Thread.current.name = "lens-errors-worker"
|
|
84
|
+
Thread.current.report_on_exception = true
|
|
85
|
+
run_worker
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_worker
|
|
90
|
+
loop do
|
|
91
|
+
batch = collect_batch
|
|
92
|
+
send_batch(batch)
|
|
93
|
+
end
|
|
94
|
+
rescue ClosedQueueError
|
|
95
|
+
# Normal shutdown
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def collect_batch
|
|
99
|
+
item = @queue.pop
|
|
100
|
+
raise ClosedQueueError if item.nil?
|
|
101
|
+
batch = [item]
|
|
102
|
+
loop do
|
|
103
|
+
break if batch.size >= BATCH_SIZE
|
|
104
|
+
batch << @queue.pop(true)
|
|
105
|
+
rescue ThreadError, ClosedQueueError
|
|
106
|
+
break
|
|
107
|
+
end
|
|
108
|
+
batch
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def send_batch(items)
|
|
112
|
+
return if items.empty?
|
|
113
|
+
|
|
114
|
+
body = JSON.generate(errors: items, service: @service_name, version: Lens::Rails::VERSION)
|
|
115
|
+
req = Net::HTTP::Post.new(@uri)
|
|
116
|
+
req["Content-Type"] = "application/octet-stream"
|
|
117
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
118
|
+
req.body = Zlib.gzip(body)
|
|
119
|
+
|
|
120
|
+
Thread.current[:lens_in_export] = true
|
|
121
|
+
begin
|
|
122
|
+
post_with_retry(req)
|
|
123
|
+
ensure
|
|
124
|
+
Thread.current[:lens_in_export] = false
|
|
125
|
+
end
|
|
126
|
+
rescue => e
|
|
127
|
+
warn "lens-errors-worker: #{e.class}: #{e.message}"
|
|
128
|
+
close_http
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def post_with_retry(req)
|
|
132
|
+
http.request(req)
|
|
133
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED
|
|
134
|
+
close_http
|
|
135
|
+
http.request(req)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def http
|
|
139
|
+
return @http if @http&.active?
|
|
140
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
141
|
+
@http.use_ssl = @uri.scheme == "https"
|
|
142
|
+
@http.open_timeout = @open_timeout
|
|
143
|
+
@http.read_timeout = @read_timeout
|
|
144
|
+
@http.keep_alive_timeout = 30
|
|
145
|
+
@http.start
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close_http
|
|
149
|
+
begin
|
|
150
|
+
@http&.finish
|
|
151
|
+
rescue
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
@http = nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "net/http"
|
|
2
4
|
require "json"
|
|
3
5
|
require "zlib"
|
|
4
|
-
require "lens/rails/backoff_loop"
|
|
5
6
|
|
|
6
7
|
module Lens
|
|
7
8
|
module Rails
|
|
8
|
-
# Logger sink that
|
|
9
|
-
#
|
|
9
|
+
# Logger sink that buffers log records in a thread-safe Queue and ships
|
|
10
|
+
# them to POST /v1/logs via a single persistent HTTPS connection.
|
|
11
|
+
# A dedicated worker thread blocks on the queue and drains in batches,
|
|
12
|
+
# so there are no timer wakeups that can interfere with Puma response writes.
|
|
10
13
|
class LogExporter < ::Logger
|
|
11
14
|
SEVERITY_TEXT = {
|
|
12
15
|
::Logger::DEBUG => "DEBUG",
|
|
@@ -17,7 +20,7 @@ module Lens
|
|
|
17
20
|
::Logger::UNKNOWN => "UNKNOWN"
|
|
18
21
|
}.freeze
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
BATCH_SIZE = 500
|
|
21
24
|
|
|
22
25
|
def initialize(url:, token:, service_name:,
|
|
23
26
|
max_buffer: 10_000,
|
|
@@ -28,15 +31,13 @@ module Lens
|
|
|
28
31
|
@token = token
|
|
29
32
|
@service_name = service_name
|
|
30
33
|
@uri = URI("#{url}/v1/logs")
|
|
31
|
-
@max_buffer = max_buffer
|
|
32
34
|
@open_timeout = open_timeout
|
|
33
35
|
@read_timeout = read_timeout
|
|
34
36
|
@min_severity = min_severity
|
|
35
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
|
|
39
|
-
restart_flush_thread
|
|
37
|
+
@max_buffer = max_buffer
|
|
38
|
+
@queue = SizedQueue.new(max_buffer)
|
|
39
|
+
@http = nil
|
|
40
|
+
start_worker
|
|
40
41
|
Lens::Rails.register_flushable(self)
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -55,29 +56,68 @@ module Lens
|
|
|
55
56
|
body: message.to_s.gsub(/\e\[[0-9;]*m/, ""),
|
|
56
57
|
controller: Thread.current[:lens_controller],
|
|
57
58
|
action: Thread.current[:lens_action],
|
|
58
|
-
request_id: Thread.current[:lens_request_id]
|
|
59
|
+
request_id: Thread.current[:lens_request_id],
|
|
60
|
+
job_id: Thread.current[:lens_job_id]
|
|
59
61
|
}.compact
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
@queue.push(record, true)
|
|
64
|
+
rescue ThreadError
|
|
65
|
+
# Buffer full, drop record silently
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def shutdown(timeout:)
|
|
69
|
+
@queue.close
|
|
70
|
+
@worker&.join(timeout)
|
|
71
|
+
rescue
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def restart_flush_thread
|
|
75
|
+
begin
|
|
76
|
+
@worker&.kill
|
|
77
|
+
rescue
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
close_http
|
|
81
|
+
@queue = SizedQueue.new(@max_buffer)
|
|
82
|
+
start_worker
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def start_worker
|
|
88
|
+
@worker = Thread.new do
|
|
89
|
+
Thread.current.name = "lens-log-worker"
|
|
90
|
+
Thread.current.report_on_exception = true
|
|
91
|
+
run_worker
|
|
69
92
|
end
|
|
93
|
+
end
|
|
70
94
|
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
def run_worker
|
|
96
|
+
loop do
|
|
97
|
+
batch = collect_batch
|
|
98
|
+
send_batch(batch)
|
|
99
|
+
end
|
|
100
|
+
rescue ClosedQueueError
|
|
101
|
+
# Queue empty and closed — normal shutdown
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def collect_batch
|
|
105
|
+
item = @queue.pop
|
|
106
|
+
raise ClosedQueueError if item.nil?
|
|
107
|
+
batch = [item]
|
|
108
|
+
loop do
|
|
109
|
+
break if batch.size >= BATCH_SIZE
|
|
110
|
+
batch << @queue.pop(true)
|
|
111
|
+
rescue ThreadError, ClosedQueueError
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
batch
|
|
73
115
|
end
|
|
74
116
|
|
|
75
|
-
def
|
|
76
|
-
records = @mutex.synchronize { @buffer.tap { @buffer = [] } }
|
|
117
|
+
def send_batch(records)
|
|
77
118
|
return if records.empty?
|
|
78
119
|
|
|
79
120
|
body = JSON.generate(logs: records, service: @service_name, version: Lens::Rails::VERSION)
|
|
80
|
-
|
|
81
121
|
req = Net::HTTP::Post.new(@uri)
|
|
82
122
|
req["Content-Type"] = "application/octet-stream"
|
|
83
123
|
req["Authorization"] = "Bearer #{@token}"
|
|
@@ -85,37 +125,39 @@ module Lens
|
|
|
85
125
|
|
|
86
126
|
Thread.current[:lens_in_export] = true
|
|
87
127
|
begin
|
|
88
|
-
|
|
89
|
-
use_ssl: @uri.scheme == "https",
|
|
90
|
-
open_timeout: @open_timeout,
|
|
91
|
-
read_timeout: @read_timeout) { |http| http.request(req) }
|
|
128
|
+
post_with_retry(req)
|
|
92
129
|
ensure
|
|
93
130
|
Thread.current[:lens_in_export] = false
|
|
94
131
|
end
|
|
132
|
+
rescue => e
|
|
133
|
+
warn "lens-log-worker: #{e.class}: #{e.message}"
|
|
134
|
+
close_http
|
|
95
135
|
end
|
|
96
136
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
rescue
|
|
137
|
+
def post_with_retry(req)
|
|
138
|
+
http.request(req)
|
|
139
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED
|
|
140
|
+
close_http
|
|
141
|
+
http.request(req)
|
|
100
142
|
end
|
|
101
143
|
|
|
102
|
-
def
|
|
103
|
-
|
|
144
|
+
def http
|
|
145
|
+
return @http if @http&.active?
|
|
146
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
147
|
+
@http.use_ssl = @uri.scheme == "https"
|
|
148
|
+
@http.open_timeout = @open_timeout
|
|
149
|
+
@http.read_timeout = @read_timeout
|
|
150
|
+
@http.keep_alive_timeout = 30
|
|
151
|
+
@http.start
|
|
104
152
|
end
|
|
105
153
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if now - @last_drop_warn_at >= DROP_WARN_INTERVAL
|
|
112
|
-
@last_drop_warn_at = now
|
|
113
|
-
true
|
|
114
|
-
else
|
|
115
|
-
false
|
|
116
|
-
end
|
|
154
|
+
def close_http
|
|
155
|
+
begin
|
|
156
|
+
@http&.finish
|
|
157
|
+
rescue
|
|
158
|
+
nil
|
|
117
159
|
end
|
|
118
|
-
|
|
160
|
+
@http = nil
|
|
119
161
|
end
|
|
120
162
|
end
|
|
121
163
|
end
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "net/http"
|
|
2
4
|
require "json"
|
|
3
5
|
require "zlib"
|
|
4
|
-
require "lens/rails/backoff_loop"
|
|
5
6
|
|
|
6
7
|
module Lens
|
|
7
8
|
module Rails
|
|
8
|
-
# Rack middleware that tracks request counts and durations,
|
|
9
|
-
#
|
|
9
|
+
# Rack middleware that tracks request counts and durations, flushing
|
|
10
|
+
# aggregated metrics to POST /v1/metrics every 30s via a persistent
|
|
11
|
+
# HTTPS connection.
|
|
10
12
|
class MetricsExporter
|
|
11
13
|
RESERVOIR_SIZE = 1024
|
|
14
|
+
FLUSH_INTERVAL = 30
|
|
12
15
|
|
|
13
16
|
def initialize(app, url:, token:, service_name:,
|
|
14
17
|
open_timeout: 2,
|
|
@@ -20,8 +23,9 @@ module Lens
|
|
|
20
23
|
@open_timeout = open_timeout
|
|
21
24
|
@read_timeout = read_timeout
|
|
22
25
|
@mutex = Mutex.new
|
|
26
|
+
@http = nil
|
|
23
27
|
reset_window
|
|
24
|
-
|
|
28
|
+
start_flush_thread
|
|
25
29
|
Lens::Rails.register_flushable(self)
|
|
26
30
|
end
|
|
27
31
|
|
|
@@ -56,11 +60,42 @@ module Lens
|
|
|
56
60
|
Thread.current[:lens_request_id] = nil
|
|
57
61
|
end
|
|
58
62
|
|
|
63
|
+
def shutdown(timeout:)
|
|
64
|
+
@flush_thread&.kill
|
|
65
|
+
Thread.new { flush }.join(timeout)
|
|
66
|
+
rescue
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def restart_flush_thread
|
|
70
|
+
begin
|
|
71
|
+
@flush_thread&.kill
|
|
72
|
+
rescue
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
close_http
|
|
76
|
+
start_flush_thread
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def start_flush_thread
|
|
82
|
+
@flush_thread = Thread.new do
|
|
83
|
+
Thread.current.name = "lens-metrics-flush"
|
|
84
|
+
Thread.current.report_on_exception = true
|
|
85
|
+
loop do
|
|
86
|
+
sleep FLUSH_INTERVAL
|
|
87
|
+
flush
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
59
92
|
def flush
|
|
60
93
|
snapshot = @mutex.synchronize do
|
|
61
|
-
s = {
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
s = {
|
|
95
|
+
count: @request_count, errors: @error_count,
|
|
96
|
+
sum: @duration_sum, min: @duration_min, max: @duration_max,
|
|
97
|
+
samples: @samples, queue_samples: @queue_samples, queue_sum: @queue_sum
|
|
98
|
+
}
|
|
64
99
|
reset_window
|
|
65
100
|
s
|
|
66
101
|
end
|
|
@@ -92,7 +127,6 @@ module Lens
|
|
|
92
127
|
end
|
|
93
128
|
|
|
94
129
|
body = JSON.generate(metrics: metrics, service: @service_name, version: Lens::Rails::VERSION)
|
|
95
|
-
|
|
96
130
|
req = Net::HTTP::Post.new(@uri)
|
|
97
131
|
req["Content-Type"] = "application/octet-stream"
|
|
98
132
|
req["Authorization"] = "Bearer #{@token}"
|
|
@@ -100,25 +134,40 @@ module Lens
|
|
|
100
134
|
|
|
101
135
|
Thread.current[:lens_in_export] = true
|
|
102
136
|
begin
|
|
103
|
-
|
|
104
|
-
use_ssl: @uri.scheme == "https",
|
|
105
|
-
open_timeout: @open_timeout,
|
|
106
|
-
read_timeout: @read_timeout) { |http| http.request(req) }
|
|
137
|
+
post_with_retry(req)
|
|
107
138
|
ensure
|
|
108
139
|
Thread.current[:lens_in_export] = false
|
|
109
140
|
end
|
|
141
|
+
rescue => e
|
|
142
|
+
warn "lens-metrics-flush: #{e.class}: #{e.message}"
|
|
143
|
+
close_http
|
|
110
144
|
end
|
|
111
145
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
rescue
|
|
146
|
+
def post_with_retry(req)
|
|
147
|
+
http.request(req)
|
|
148
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED
|
|
149
|
+
close_http
|
|
150
|
+
http.request(req)
|
|
115
151
|
end
|
|
116
152
|
|
|
117
|
-
def
|
|
118
|
-
|
|
153
|
+
def http
|
|
154
|
+
return @http if @http&.active?
|
|
155
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
156
|
+
@http.use_ssl = @uri.scheme == "https"
|
|
157
|
+
@http.open_timeout = @open_timeout
|
|
158
|
+
@http.read_timeout = @read_timeout
|
|
159
|
+
@http.keep_alive_timeout = 30
|
|
160
|
+
@http.start
|
|
119
161
|
end
|
|
120
162
|
|
|
121
|
-
|
|
163
|
+
def close_http
|
|
164
|
+
begin
|
|
165
|
+
@http&.finish
|
|
166
|
+
rescue
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
@http = nil
|
|
170
|
+
end
|
|
122
171
|
|
|
123
172
|
def reset_window
|
|
124
173
|
@request_count = 0
|
data/lib/lens/rails/railtie.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "lens/rails/log_exporter"
|
|
2
2
|
require "lens/rails/metrics_exporter"
|
|
3
3
|
require "lens/rails/requests_exporter"
|
|
4
|
+
require "lens/rails/error_exporter"
|
|
4
5
|
|
|
5
6
|
module Lens
|
|
6
7
|
module Rails
|
|
@@ -10,17 +11,21 @@ module Lens
|
|
|
10
11
|
cfg = Lens::Rails.configuration
|
|
11
12
|
next unless cfg.configured?
|
|
12
13
|
|
|
13
|
-
::Rails.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
read_timeout: cfg.exporter_read_timeout
|
|
22
|
-
)
|
|
14
|
+
log_exporter = Lens::Rails::LogExporter.new(
|
|
15
|
+
url: cfg.url,
|
|
16
|
+
token: cfg.app_token,
|
|
17
|
+
service_name: cfg.service_name,
|
|
18
|
+
max_buffer: cfg.max_log_buffer,
|
|
19
|
+
min_severity: cfg.min_log_severity,
|
|
20
|
+
open_timeout: cfg.exporter_open_timeout,
|
|
21
|
+
read_timeout: cfg.exporter_read_timeout,
|
|
23
22
|
)
|
|
23
|
+
|
|
24
|
+
if ::Rails.logger.respond_to?(:broadcast_to)
|
|
25
|
+
::Rails.logger.broadcast_to(log_exporter)
|
|
26
|
+
else
|
|
27
|
+
::Rails.logger = ActiveSupport::BroadcastLogger.new(::Rails.logger, log_exporter)
|
|
28
|
+
end
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
# Rack middleware for request metrics.
|
|
@@ -34,7 +39,7 @@ module Lens
|
|
|
34
39
|
token: cfg.app_token,
|
|
35
40
|
service_name: cfg.service_name,
|
|
36
41
|
open_timeout: cfg.exporter_open_timeout,
|
|
37
|
-
read_timeout: cfg.exporter_read_timeout
|
|
42
|
+
read_timeout: cfg.exporter_read_timeout,
|
|
38
43
|
)
|
|
39
44
|
end
|
|
40
45
|
|
|
@@ -56,10 +61,28 @@ module Lens
|
|
|
56
61
|
sql_threshold_ms: cfg.sql_threshold_ms,
|
|
57
62
|
sql_ignore: cfg.sql_ignore,
|
|
58
63
|
open_timeout: cfg.exporter_open_timeout,
|
|
59
|
-
read_timeout: cfg.exporter_read_timeout
|
|
64
|
+
read_timeout: cfg.exporter_read_timeout,
|
|
60
65
|
)
|
|
61
66
|
end
|
|
62
67
|
|
|
68
|
+
# Subscribe to Rails.error to capture all reported exceptions.
|
|
69
|
+
initializer "lens.configure_errors", after: :load_config_initializers do
|
|
70
|
+
cfg = Lens::Rails.configuration
|
|
71
|
+
next unless cfg.configured?
|
|
72
|
+
|
|
73
|
+
exporter = Lens::Rails::ErrorExporter.new(
|
|
74
|
+
url: cfg.url,
|
|
75
|
+
token: cfg.app_token,
|
|
76
|
+
service_name: cfg.service_name,
|
|
77
|
+
ignore_classes: cfg.error_ignore_classes,
|
|
78
|
+
max_buffer: cfg.max_error_buffer,
|
|
79
|
+
open_timeout: cfg.exporter_open_timeout,
|
|
80
|
+
read_timeout: cfg.exporter_read_timeout,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
::Rails.error.subscribe(exporter)
|
|
84
|
+
end
|
|
85
|
+
|
|
63
86
|
# Flush pending data on clean shutdown, bounded by shutdown_timeout.
|
|
64
87
|
initializer "lens.at_exit", after: :load_config_initializers do
|
|
65
88
|
cfg = Lens::Rails.configuration
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "net/http"
|
|
2
4
|
require "json"
|
|
3
5
|
require "zlib"
|
|
4
|
-
require "lens/rails/backoff_loop"
|
|
5
6
|
|
|
6
7
|
module Lens
|
|
7
8
|
module Rails
|
|
@@ -14,12 +15,14 @@ module Lens
|
|
|
14
15
|
|
|
15
16
|
def call(env)
|
|
16
17
|
wall_start = Time.now
|
|
18
|
+
Thread.current[:lens_request_id] = env["action_dispatch.request_id"]
|
|
17
19
|
Thread.current[:lens_request_ops] = []
|
|
18
20
|
Thread.current[:lens_request_start] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
21
|
Thread.current[:lens_request_wall_start] = wall_start
|
|
20
22
|
Thread.current[:lens_queue_time_ms] = parse_queue_time(env, wall_start)
|
|
21
23
|
@app.call(env)
|
|
22
24
|
ensure
|
|
25
|
+
Thread.current[:lens_request_id] = nil
|
|
23
26
|
Thread.current[:lens_request_ops] = nil
|
|
24
27
|
Thread.current[:lens_request_start] = nil
|
|
25
28
|
Thread.current[:lens_request_wall_start] = nil
|
|
@@ -30,8 +33,6 @@ module Lens
|
|
|
30
33
|
|
|
31
34
|
private
|
|
32
35
|
|
|
33
|
-
# Parses X-Request-Start set by kamal-proxy and nginx.
|
|
34
|
-
# Formats: "t=1234567890.123456" (seconds) or "1234567890123" (milliseconds).
|
|
35
36
|
def parse_queue_time(env, wall_start)
|
|
36
37
|
raw = env["HTTP_X_REQUEST_START"]
|
|
37
38
|
return nil unless raw
|
|
@@ -45,57 +46,138 @@ module Lens
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# ActiveJob concern injected via on_load(:active_job) in the Railtie.
|
|
48
|
-
# Opens a per-job operation accumulator around every perform call.
|
|
49
49
|
module JobTracking
|
|
50
50
|
extend ActiveSupport::Concern
|
|
51
51
|
|
|
52
52
|
included do
|
|
53
|
-
|
|
53
|
+
before_enqueue { @lens_request_id = Thread.current[:lens_request_id] }
|
|
54
|
+
|
|
55
|
+
around_perform do |job, block|
|
|
54
56
|
Thread.current[:lens_job_ops] = []
|
|
55
57
|
Thread.current[:lens_job_start] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
58
|
Thread.current[:lens_job_wall_start] = Time.now
|
|
59
|
+
Thread.current[:lens_job_request_id] = job.lens_request_id
|
|
60
|
+
Thread.current[:lens_job_id] = job.job_id
|
|
57
61
|
block.call
|
|
58
62
|
ensure
|
|
59
63
|
Thread.current[:lens_job_ops] = nil
|
|
60
64
|
Thread.current[:lens_job_start] = nil
|
|
61
65
|
Thread.current[:lens_job_wall_start] = nil
|
|
66
|
+
Thread.current[:lens_job_request_id] = nil
|
|
67
|
+
Thread.current[:lens_job_id] = nil
|
|
62
68
|
end
|
|
63
69
|
end
|
|
70
|
+
|
|
71
|
+
def lens_request_id
|
|
72
|
+
@lens_request_id
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def serialize
|
|
76
|
+
super.merge("lens_request_id" => @lens_request_id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deserialize(job_data)
|
|
80
|
+
@lens_request_id = job_data.delete("lens_request_id")
|
|
81
|
+
super
|
|
82
|
+
end
|
|
64
83
|
end
|
|
65
84
|
|
|
66
85
|
# Subscribes to ActionController, ActionView, ActiveRecord, and ActiveJob
|
|
67
|
-
# notifications and
|
|
86
|
+
# notifications and pushes collected records onto a Queue. A dedicated worker
|
|
87
|
+
# thread drains the queue in batches and ships them to POST /v1/requests via
|
|
88
|
+
# a single persistent HTTPS connection.
|
|
68
89
|
class RequestsExporter
|
|
69
90
|
SKIP_SCHEMA = /\A(SCHEMA|ActiveRecord::SchemaMigration|ActiveRecord::InternalMetadata)\z/
|
|
70
91
|
SKIP_JOB_CLASS = /\ASolidQueue::/
|
|
71
92
|
|
|
93
|
+
BATCH_SIZE = 200
|
|
94
|
+
|
|
72
95
|
def initialize(url:, token:, service_name:,
|
|
73
96
|
sql_threshold_ms: 0.0, sql_ignore: [],
|
|
97
|
+
max_buffer: 5_000,
|
|
74
98
|
open_timeout: 2, read_timeout: 2)
|
|
75
99
|
@uri = URI("#{url}/v1/requests")
|
|
76
100
|
@token = token
|
|
77
101
|
@service_name = service_name
|
|
78
102
|
@sql_threshold_ms = sql_threshold_ms
|
|
79
103
|
@sql_ignore = sql_ignore
|
|
104
|
+
@max_buffer = max_buffer
|
|
80
105
|
@open_timeout = open_timeout
|
|
81
106
|
@read_timeout = read_timeout
|
|
82
|
-
@
|
|
83
|
-
@
|
|
84
|
-
@
|
|
107
|
+
@queue = SizedQueue.new(max_buffer)
|
|
108
|
+
@http = nil
|
|
109
|
+
@fork_mutex = Mutex.new
|
|
85
110
|
subscribe!
|
|
86
|
-
|
|
111
|
+
start_worker
|
|
87
112
|
Lens::Rails.register_flushable(self)
|
|
88
113
|
end
|
|
89
114
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
115
|
+
def shutdown(timeout:)
|
|
116
|
+
@queue.close
|
|
117
|
+
@worker&.join(timeout)
|
|
118
|
+
rescue
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ensure_worker!
|
|
122
|
+
return if @worker&.alive?
|
|
123
|
+
@fork_mutex.synchronize do
|
|
124
|
+
return if @worker&.alive?
|
|
125
|
+
restart_flush_thread
|
|
93
126
|
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def restart_flush_thread
|
|
130
|
+
begin
|
|
131
|
+
@worker&.kill
|
|
132
|
+
rescue
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
close_http
|
|
136
|
+
@queue = SizedQueue.new(@max_buffer)
|
|
137
|
+
start_worker
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def start_worker
|
|
143
|
+
@worker = Thread.new do
|
|
144
|
+
Thread.current.name = "lens-requests-worker"
|
|
145
|
+
Thread.current.report_on_exception = true
|
|
146
|
+
run_worker
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run_worker
|
|
151
|
+
loop do
|
|
152
|
+
batch = collect_batch
|
|
153
|
+
send_batch(batch)
|
|
154
|
+
end
|
|
155
|
+
rescue ClosedQueueError
|
|
156
|
+
# Queue empty and closed — normal shutdown
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def collect_batch
|
|
160
|
+
item = @queue.pop
|
|
161
|
+
raise ClosedQueueError if item.nil?
|
|
162
|
+
batch = [item]
|
|
163
|
+
loop do
|
|
164
|
+
break if batch.size >= BATCH_SIZE
|
|
165
|
+
batch << @queue.pop(true)
|
|
166
|
+
rescue ThreadError, ClosedQueueError
|
|
167
|
+
break
|
|
168
|
+
end
|
|
169
|
+
batch
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def send_batch(items)
|
|
173
|
+
return if items.empty?
|
|
174
|
+
|
|
175
|
+
requests = items.filter_map { |i| i[:data] if i[:type] == :request }
|
|
176
|
+
jobs = items.filter_map { |i| i[:data] if i[:type] == :job }
|
|
94
177
|
return if requests.empty? && jobs.empty?
|
|
95
178
|
|
|
96
179
|
body = JSON.generate(requests: requests, jobs: jobs,
|
|
97
180
|
service: @service_name, version: Lens::Rails::VERSION)
|
|
98
|
-
|
|
99
181
|
req = Net::HTTP::Post.new(@uri)
|
|
100
182
|
req["Content-Type"] = "application/octet-stream"
|
|
101
183
|
req["Authorization"] = "Bearer #{@token}"
|
|
@@ -103,53 +185,63 @@ module Lens
|
|
|
103
185
|
|
|
104
186
|
Thread.current[:lens_in_export] = true
|
|
105
187
|
begin
|
|
106
|
-
|
|
107
|
-
use_ssl: @uri.scheme == "https",
|
|
108
|
-
open_timeout: @open_timeout,
|
|
109
|
-
read_timeout: @read_timeout) { |http| http.request(req) }
|
|
188
|
+
post_with_retry(req)
|
|
110
189
|
ensure
|
|
111
190
|
Thread.current[:lens_in_export] = false
|
|
112
191
|
end
|
|
192
|
+
rescue => e
|
|
193
|
+
warn "lens-requests-worker: #{e.class}: #{e.message}"
|
|
194
|
+
close_http
|
|
113
195
|
end
|
|
114
196
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
rescue
|
|
197
|
+
def post_with_retry(req)
|
|
198
|
+
http.request(req)
|
|
199
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED
|
|
200
|
+
close_http
|
|
201
|
+
http.request(req)
|
|
118
202
|
end
|
|
119
203
|
|
|
120
|
-
def
|
|
121
|
-
|
|
204
|
+
def http
|
|
205
|
+
return @http if @http&.active?
|
|
206
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
207
|
+
@http.use_ssl = @uri.scheme == "https"
|
|
208
|
+
@http.open_timeout = @open_timeout
|
|
209
|
+
@http.read_timeout = @read_timeout
|
|
210
|
+
@http.keep_alive_timeout = 30
|
|
211
|
+
@http.start
|
|
122
212
|
end
|
|
123
213
|
|
|
124
|
-
|
|
214
|
+
def close_http
|
|
215
|
+
begin
|
|
216
|
+
@http&.finish
|
|
217
|
+
rescue
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
@http = nil
|
|
221
|
+
end
|
|
125
222
|
|
|
126
223
|
def subscribe!
|
|
127
|
-
# Tag current thread with controller/action for the log exporter to pick up.
|
|
128
224
|
ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |event|
|
|
129
225
|
next if Thread.current[:lens_in_export]
|
|
130
226
|
Thread.current[:lens_controller] = event.payload[:controller]
|
|
131
227
|
Thread.current[:lens_action] = event.payload[:action]
|
|
132
228
|
end
|
|
133
229
|
|
|
134
|
-
# Accumulate SQL operations onto the current request or job.
|
|
135
230
|
ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
136
231
|
next if Thread.current[:lens_in_export]
|
|
137
232
|
record_sql(event)
|
|
138
233
|
end
|
|
139
234
|
|
|
140
|
-
# Accumulate view renders onto the current request.
|
|
141
235
|
ActiveSupport::Notifications.subscribe("render_template.action_view") do |event|
|
|
142
236
|
next if Thread.current[:lens_in_export]
|
|
143
237
|
record_view(event)
|
|
144
238
|
end
|
|
145
239
|
|
|
146
|
-
# Finalize an HTTP request record.
|
|
147
240
|
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |event|
|
|
148
241
|
next if Thread.current[:lens_in_export]
|
|
149
242
|
record_request(event)
|
|
150
243
|
end
|
|
151
244
|
|
|
152
|
-
# Finalize a background job record.
|
|
153
245
|
ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
|
|
154
246
|
next if Thread.current[:lens_in_export]
|
|
155
247
|
record_job(event)
|
|
@@ -198,7 +290,7 @@ module Lens
|
|
|
198
290
|
"controller" => payload[:controller],
|
|
199
291
|
"action" => payload[:action],
|
|
200
292
|
"method" => payload[:method],
|
|
201
|
-
"path" => payload[:path],
|
|
293
|
+
"path" => route_pattern(payload[:request], payload[:path].to_s),
|
|
202
294
|
"status" => payload[:status],
|
|
203
295
|
"duration_ms" => event.duration.round(2),
|
|
204
296
|
"sql_count" => sql_ops.size,
|
|
@@ -210,7 +302,10 @@ module Lens
|
|
|
210
302
|
"operations" => ops
|
|
211
303
|
}.compact
|
|
212
304
|
|
|
213
|
-
|
|
305
|
+
ensure_worker!
|
|
306
|
+
@queue.push({type: :request, data: rec}, true)
|
|
307
|
+
rescue ThreadError
|
|
308
|
+
# Buffer full, drop record silently
|
|
214
309
|
end
|
|
215
310
|
|
|
216
311
|
def record_job(event)
|
|
@@ -228,6 +323,7 @@ module Lens
|
|
|
228
323
|
|
|
229
324
|
rec = {
|
|
230
325
|
"id" => job.job_id,
|
|
326
|
+
"request_id" => Thread.current[:lens_job_request_id],
|
|
231
327
|
"job_class" => job.class.name,
|
|
232
328
|
"queue" => job.queue_name,
|
|
233
329
|
"duration_ms" => event.duration.round(2),
|
|
@@ -239,7 +335,10 @@ module Lens
|
|
|
239
335
|
"operations" => ops
|
|
240
336
|
}.compact
|
|
241
337
|
|
|
242
|
-
|
|
338
|
+
ensure_worker!
|
|
339
|
+
@queue.push({type: :job, data: rec}, true)
|
|
340
|
+
rescue ThreadError
|
|
341
|
+
# Buffer full, drop record silently
|
|
243
342
|
end
|
|
244
343
|
|
|
245
344
|
def build_error(exception, exception_object)
|
|
@@ -248,6 +347,14 @@ module Lens
|
|
|
248
347
|
backtrace = exception_object&.backtrace&.first(20) || []
|
|
249
348
|
{"class" => klass, "message" => message, "backtrace" => backtrace}
|
|
250
349
|
end
|
|
350
|
+
|
|
351
|
+
def route_pattern(request, raw_path)
|
|
352
|
+
if request.respond_to?(:route_uri_pattern)
|
|
353
|
+
request.route_uri_pattern&.chomp("(.:format)")
|
|
354
|
+
end || raw_path.split("?", 2).first
|
|
355
|
+
rescue
|
|
356
|
+
raw_path.split("?", 2).first
|
|
357
|
+
end
|
|
251
358
|
end
|
|
252
359
|
end
|
|
253
360
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from solid_errors/sanitizer.rb (originally from honeybadger-ruby).
|
|
4
|
+
# Handles circular refs, BasicObject, depth limits, huge strings, and
|
|
5
|
+
# object_id stripping before serializing context hashes to JSON.
|
|
6
|
+
|
|
7
|
+
module Lens
|
|
8
|
+
module Rails
|
|
9
|
+
class Sanitizer
|
|
10
|
+
BASIC_OBJECT = "#<BasicObject>".freeze
|
|
11
|
+
DEPTH = "[DEPTH]".freeze
|
|
12
|
+
RAISED = "[RAISED]".freeze
|
|
13
|
+
RECURSION = "[RECURSION]".freeze
|
|
14
|
+
TRUNCATED = "[TRUNCATED]".freeze
|
|
15
|
+
MAX_STRING = 65_536
|
|
16
|
+
|
|
17
|
+
def self.sanitize(data)
|
|
18
|
+
@instance ||= new
|
|
19
|
+
@instance.sanitize(data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(max_depth: 20)
|
|
23
|
+
@max_depth = max_depth
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sanitize(data, depth = 0, stack = nil)
|
|
27
|
+
return BASIC_OBJECT if basic_object?(data)
|
|
28
|
+
|
|
29
|
+
if recursive?(data)
|
|
30
|
+
return RECURSION if stack&.include?(data.object_id)
|
|
31
|
+
stack = stack ? stack.dup : Set.new
|
|
32
|
+
stack << data.object_id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
case data
|
|
36
|
+
when Hash
|
|
37
|
+
return DEPTH if depth >= @max_depth
|
|
38
|
+
data.each_with_object({}) do |(k, v), h|
|
|
39
|
+
h[k.is_a?(Symbol) ? k : sanitize(k, depth + 1, stack)] = sanitize(v, depth + 1, stack)
|
|
40
|
+
end
|
|
41
|
+
when Array, Set
|
|
42
|
+
return DEPTH if depth >= @max_depth
|
|
43
|
+
data.to_a.map { |v| sanitize(v, depth + 1, stack) }
|
|
44
|
+
when Numeric, TrueClass, FalseClass, NilClass
|
|
45
|
+
data
|
|
46
|
+
when String
|
|
47
|
+
sanitize_string(data)
|
|
48
|
+
else
|
|
49
|
+
klass = data.class
|
|
50
|
+
begin
|
|
51
|
+
str = String(data)
|
|
52
|
+
rescue
|
|
53
|
+
return RAISED
|
|
54
|
+
end
|
|
55
|
+
return "#<#{klass.name}>" if str.include?("#<") && str.include?(">")
|
|
56
|
+
sanitize_string(str)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def basic_object?(obj)
|
|
63
|
+
obj.respond_to?(:to_s)
|
|
64
|
+
false
|
|
65
|
+
rescue
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def recursive?(data)
|
|
70
|
+
data.is_a?(Hash) || data.is_a?(Array) || data.is_a?(Set)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sanitize_string(str)
|
|
74
|
+
str = str.gsub(/#<(.*?):0x.*?>/, '#<\1>')
|
|
75
|
+
return str if str.size <= MAX_STRING
|
|
76
|
+
str[0...MAX_STRING] + TRUNCATED
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/lens/rails/version.rb
CHANGED
data/lib/lens/rails.rb
CHANGED
|
@@ -24,6 +24,10 @@ module Lens
|
|
|
24
24
|
flushables.each { |f| f.shutdown(timeout: timeout) }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def error(exception, context: {}, severity: :error, handled: true)
|
|
28
|
+
::Rails.error.report(exception, context: context, severity: severity, handled: handled)
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
private
|
|
28
32
|
|
|
29
33
|
def flushables
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lens-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Axel Gustav
|
|
@@ -21,10 +21,12 @@ files:
|
|
|
21
21
|
- lib/lens/rails.rb
|
|
22
22
|
- lib/lens/rails/backoff_loop.rb
|
|
23
23
|
- lib/lens/rails/configuration.rb
|
|
24
|
+
- lib/lens/rails/error_exporter.rb
|
|
24
25
|
- lib/lens/rails/log_exporter.rb
|
|
25
26
|
- lib/lens/rails/metrics_exporter.rb
|
|
26
27
|
- lib/lens/rails/railtie.rb
|
|
27
28
|
- lib/lens/rails/requests_exporter.rb
|
|
29
|
+
- lib/lens/rails/sanitizer.rb
|
|
28
30
|
- lib/lens/rails/version.rb
|
|
29
31
|
homepage: https://codefloe.com/GusTech/lens-rails
|
|
30
32
|
licenses:
|