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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3feb70c30ca8f978b2de8669531cb66b22e2d4d019b711e1ed4aaf744743006
4
- data.tar.gz: 44e46c8d29db7456d1b622f97d5c1df6256e142994b32d82059bfb8ca23b33d2
3
+ metadata.gz: d90077b98ae4006d57f5621f0f3f44d61193161757bf28be6e386b5f560cdbd8
4
+ data.tar.gz: cff65959840d06f3a25e8fefec6067e3da6b1b73f4a0aefe9d6e82bdb68863db
5
5
  SHA512:
6
- metadata.gz: 0b8ac809f6c4383c19ccac2bcf44870051d936eb9fe7d93f73660f57455493412c16bf6db964d2f074014ce9f7bb9b06d4d7ed13656d83c94d752cee26781e4c
7
- data.tar.gz: ef5d900a3f546f8428d96bf679f11577871a93c83b335e315d1550c3b5ca0c7ecb353215b461858ea5467eb5a0a61ad3284687edeea6b76946d590f8bb75b939
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 request traces start flowing to your Lens instance automatically — no OpenTelemetry SDK, no protobuf, no additional processes.
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 batches log records and ships them to Lens via POST /v1/logs.
9
- # Wired into Rails.logger via broadcast in the Railtie.
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
- DROP_WARN_INTERVAL = 60.0
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
- @buffer = []
36
- @dropped = 0
37
- @last_drop_warn_at = 0.0
38
- @mutex = Mutex.new
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
- dropped_now = 0
62
- @mutex.synchronize do
63
- if @buffer.size >= @max_buffer
64
- @buffer.shift
65
- @dropped += 1
66
- dropped_now = @dropped
67
- end
68
- @buffer << record
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
- maybe_warn_dropped(dropped_now) if dropped_now.positive?
72
- true
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 flush
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
- Net::HTTP.start(@uri.host, @uri.port,
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 shutdown(timeout:)
98
- Thread.new { flush }.join(timeout)
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 restart_flush_thread
103
- BackoffLoop.new(base: 5, max: 60, name: "lens-log-flush").start { flush }
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
- private
107
-
108
- def maybe_warn_dropped(total_dropped)
109
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
- should_warn = @mutex.synchronize do
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
- Kernel.warn "Lens log buffer full: #{total_dropped} records dropped" if should_warn
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
- # flushing them to Lens every 30 s via POST /v1/metrics.
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
- restart_flush_thread
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 = {count: @request_count, errors: @error_count,
62
- sum: @duration_sum, min: @duration_min, max: @duration_max,
63
- samples: @samples, queue_samples: @queue_samples, queue_sum: @queue_sum}
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
- Net::HTTP.start(@uri.host, @uri.port,
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 shutdown(timeout:)
113
- Thread.new { flush }.join(timeout)
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 restart_flush_thread
118
- BackoffLoop.new(base: 30, max: 120, name: "lens-metrics-flush").start { flush }
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
- private
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
@@ -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.logger.broadcast_to(
14
- 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
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
- around_perform do |_job, block|
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 flushes collected records to POST /v1/requests.
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
- @mutex = Mutex.new
83
- @requests = []
84
- @jobs = []
107
+ @queue = SizedQueue.new(max_buffer)
108
+ @http = nil
109
+ @fork_mutex = Mutex.new
85
110
  subscribe!
86
- restart_flush_thread
111
+ start_worker
87
112
  Lens::Rails.register_flushable(self)
88
113
  end
89
114
 
90
- def flush
91
- requests, jobs = @mutex.synchronize do
92
- [@requests.tap { @requests = [] }, @jobs.tap { @jobs = [] }]
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
- Net::HTTP.start(@uri.host, @uri.port,
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 shutdown(timeout:)
116
- Thread.new { flush }.join(timeout)
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 restart_flush_thread
121
- BackoffLoop.new(base: 30, max: 120, name: "lens-requests-flush").start { flush }
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
- private
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
- @mutex.synchronize { @requests << rec }
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
- @mutex.synchronize { @jobs << rec }
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
@@ -1,5 +1,5 @@
1
1
  module Lens
2
2
  module Rails
3
- VERSION = "0.1.0"
3
+ VERSION = "0.1.1"
4
4
  end
5
5
  end
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.0
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: