lognorth 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dbd243e69bd9758c158d11868e091edc0f0c629916cebc8094fdcfb74664b0b3
4
+ data.tar.gz: db2c6d4d5e68353da4ecb2757737595ec144c9ae002202bf3cde5abcacdfa978
5
+ SHA512:
6
+ metadata.gz: 1381eee25cc094217d00d294ee381846cdb0390110e2e727c104459c5199918ede21c6157b1dfbd723b39d8fde5cc45b74998e031b352df058d76676a437a57e
7
+ data.tar.gz: 70a5edefba779fd982adcaf91465c34ec89082242428c953485ba4a9855ddb7852f4950c6e01ba21636ac1c2bbfb6963ef66cbbc3208f6594abc85196b1914f7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 LogNorth
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # LogNorth Rails SDK
2
+
3
+ Send errors and logs from Rails to [LogNorth](https://lognorth.com) for monitoring and alerting.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "lognorth", github: "karloscodes/lognorth-sdk-rails"
9
+ ```
10
+
11
+ ## Rails Setup
12
+
13
+ Add credentials:
14
+
15
+ ```yaml
16
+ # config/credentials.yml.enc
17
+ lognorth:
18
+ url: https://your-lognorth-instance.com
19
+ api_key: your_api_key
20
+ ```
21
+
22
+ That's it. The gem auto-configures via Railtie.
23
+
24
+ ### Manual Configuration
25
+
26
+ ```ruby
27
+ # config/initializers/lognorth.rb
28
+ LogNorth.config(
29
+ ENV["LOGNORTH_URL"],
30
+ ENV["LOGNORTH_API_KEY"]
31
+ )
32
+ ```
33
+
34
+ ### Options
35
+
36
+ ```ruby
37
+ # config/application.rb
38
+ config.lognorth.enabled = true # Default: on everywhere except development and test
39
+ config.lognorth.middleware = true # Log HTTP requests
40
+ config.lognorth.error_subscriber = true # Report exceptions (Rails 7+)
41
+
42
+ # Paths to skip from request logging. Defaults to Rails' built-in
43
+ # health-check endpoint; set to [] to log everything or replace with
44
+ # your own list.
45
+ config.lognorth.ignored_paths = ["/up", "/healthz"]
46
+ ```
47
+
48
+ Default: `["/up"]` (Rails 7.1's auto-generated health check — swamped by
49
+ kamal-proxy and load-balancer pings otherwise). Setting `ignored_paths =
50
+ []` disables ignoring entirely. Matching is exact path or `path/…`
51
+ prefix, so `/up` also covers `/up/detail`.
52
+
53
+ ### Client errors are not errors
54
+
55
+ An exception that Rails answers with a 4xx is the request's fault, not the app's:
56
+ `ActiveRecord::RecordNotFound` (404), `ActionController::InvalidAuthenticityToken`
57
+ (422), `ActionController::ParameterMissing` (400). The SDK logs the request with
58
+ that status and does not report an error, so these never become issues or alerts.
59
+
60
+ Rails decides the status from `config.action_dispatch.rescue_responses`, and the SDK
61
+ asks the same map. To report one of them as an error, map it to a 5xx:
62
+
63
+ ```ruby
64
+ config.action_dispatch.rescue_responses["ActiveRecord::RecordNotFound"] = :internal_server_error
65
+ ```
66
+
67
+ ## Usage
68
+
69
+ ```ruby
70
+ # Log messages (batched, sent after 5s or at 10 events)
71
+ LogNorth.log("User signed up", { user_id: 123 })
72
+
73
+ # Report errors (sent at once)
74
+ begin
75
+ risky_operation
76
+ rescue => e
77
+ LogNorth.error("Payment failed", e, { order_id: 456 })
78
+ raise
79
+ end
80
+
81
+ # Manual flush (called automatically at exit)
82
+ LogNorth.flush
83
+ ```
84
+
85
+ ## Batching and delivery
86
+
87
+ Logging calls never block and never raise. They add the event to a queue in memory.
88
+ One background thread sends the queue, one request at a time.
89
+
90
+ - The thread sends when the queue holds 10 events, when you report an error, or 5 seconds after the first event.
91
+ - A request holds at most 500 events and 1 MB of JSON.
92
+ - The queue holds at most 10,000 events or 10 MB of JSON.
93
+ - The SDK trims an event to at most 64 KB before it enters the queue. It marks a trimmed event with `context.truncated = true`.
94
+
95
+ When a send fails, the SDK keeps the events and tries again later. Events keep their order.
96
+
97
+ - On a network error, a timeout, or a 5xx, it waits 1 second, then 2, then 4, up to 60.
98
+ - On a 429 or 503, it waits as long as the `Retry-After` header says.
99
+ - On a 401, 403, or 404, it waits 60 seconds, then up to 5 minutes. It writes one line to stderr, so you can fix the url or the key.
100
+ - On a 413 or other 4xx, it splits the batch in two and sends again. It drops a single event the server still refuses.
101
+
102
+ When the queue is full, the SDK drops the oldest event that is not an error. It keeps errors longest.
103
+ After the next successful send, it logs `LogNorth client dropped N events` with the counts.
104
+
105
+ At exit (including SIGTERM and SIGINT), the SDK sends what is left in the queue for up to 5 seconds.
106
+ It writes the number of events it could not send to stderr.
107
+
108
+ ## Rack (without Rails)
109
+
110
+ ```ruby
111
+ require "lognorth"
112
+
113
+ LogNorth.config("https://lognorth.example.com", "api_key")
114
+ use LogNorth::Middleware
115
+ ```
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LogNorth
6
+ module ActiveJobSubscriber
7
+ # Carries the trace ID from enqueue time (e.g. during a request)
8
+ # through to job execution, so the job shares the request's trace.
9
+ module TraceCarrier
10
+ def serialize
11
+ super.merge("lognorth_trace_id" => LogNorth::Client.current_trace_id)
12
+ end
13
+
14
+ def deserialize(job_data)
15
+ super
16
+ @lognorth_trace_id = job_data["lognorth_trace_id"]
17
+ end
18
+ end
19
+
20
+ def self.attach
21
+ ActiveJob::Base.prepend(TraceCarrier)
22
+
23
+ ActiveJob::Base.around_perform do |job, block|
24
+ trace_id = job.instance_variable_get(:@lognorth_trace_id) || SecureRandom.hex(8)
25
+ LogNorth::Client.current_trace_id = trace_id
26
+ started_at = Time.now
27
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+
29
+ block.call
30
+
31
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
32
+ LogNorth::Client.send_event(
33
+ "#{job.class.name} completed",
34
+ { job: job.class.name, queue: job.queue_name, job_id: job.job_id },
35
+ trace_id: trace_id,
36
+ duration_ms: duration_ms,
37
+ timestamp: started_at
38
+ )
39
+ LogNorth.flush
40
+ LogNorth::Client.current_trace_id = nil
41
+ rescue StandardError => e
42
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
43
+ LogNorth::Client.send_error_event(
44
+ "#{job.class.name} failed", e,
45
+ { job: job.class.name, queue: job.queue_name, job_id: job.job_id },
46
+ trace_id: trace_id,
47
+ duration_ms: duration_ms,
48
+ timestamp: started_at
49
+ )
50
+ LogNorth.flush
51
+ LogNorth::Client.current_trace_id = nil
52
+ raise
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module LogNorth
9
+ MAX_BUFFER = 10_000 # events in the queue
10
+ MAX_BUFFER_BYTES = 10 * 1024 * 1024 # JSON bytes in the queue
11
+ MAX_BATCH = 500 # events in one request
12
+ MAX_BATCH_BYTES = 1024 * 1024 # JSON bytes in one request
13
+ MAX_EVENT_BYTES = 64 * 1024
14
+ MAX_MESSAGE_CHARS = 1000
15
+ MAX_STACK_TRACE_BYTES = 16 * 1024
16
+ MAX_STRING_BYTES = 8 * 1024
17
+ # The context keys an event keeps when it is still too big after trimming.
18
+ ESSENTIAL_KEYS = %w[error error_class error_file error_line method path status environment].freeze
19
+
20
+ module Client
21
+ # An event in the queue, with its JSON size and whether it is an error.
22
+ Entry = Struct.new(:event, :bytes, :error)
23
+
24
+ @mutex = Mutex.new
25
+ @wakeup = ConditionVariable.new
26
+ # Held for the whole of a send, so only one request is in flight.
27
+ # Lock order: @send_lock first, then @mutex.
28
+ @send_lock = Mutex.new
29
+ @sender = nil
30
+ @buffer = [] # Entry objects, oldest first
31
+ @bytes = 0 # sum of the bytes of the entries in @buffer
32
+ @batch_sizes = [] # sizes of the batches put back at the front
33
+ @dropped = 0
34
+ @dropped_errors = 0
35
+ @first_at = nil # when the first event entered an empty queue
36
+ @urgent = false # send without waiting for the 5-second timer
37
+ @retry_at = nil # never send before this time
38
+ @failing = nil # nil, :retrying or :config: the state last written to stderr
39
+ @endpoint = nil
40
+ @api_key = nil
41
+ @environment = nil
42
+
43
+ # Wait times in seconds. Tests set them lower.
44
+ @flush_interval = 5
45
+ @first_backoff = 1
46
+ @max_backoff = 60
47
+ @first_config_backoff = 60
48
+ @max_config_backoff = 300
49
+ @shutdown_timeout = 5
50
+ @backoff = @first_backoff
51
+ @config_backoff = @first_config_backoff
52
+
53
+ class << self
54
+ attr_accessor :debug
55
+
56
+ def config(url, key, environment: nil)
57
+ @mutex.synchronize do
58
+ @endpoint = url.chomp("/")
59
+ @api_key = key
60
+ @environment = environment
61
+ end
62
+ log_debug("configured with url=#{url} env=#{environment.inspect}")
63
+ end
64
+
65
+ def configured?
66
+ @mutex.synchronize { !@endpoint.nil? && !@api_key.nil? }
67
+ end
68
+
69
+ def log(message, context = {})
70
+ send_event(message, context)
71
+ end
72
+
73
+ def error(message, exception, context = {})
74
+ send_error_event(message, exception, context)
75
+ end
76
+
77
+ def current_trace_id
78
+ Thread.current[:lognorth_trace_id]
79
+ end
80
+
81
+ def current_trace_id=(id)
82
+ Thread.current[:lognorth_trace_id] = id
83
+ end
84
+
85
+ def send_event(message, context = {}, trace_id: nil, duration_ms: nil, timestamp: nil)
86
+ return unless configured?
87
+
88
+ trace_id ||= current_trace_id
89
+ event = {
90
+ message: message,
91
+ timestamp: (timestamp || Time.now).utc.iso8601(3),
92
+ context: stamp_environment(context)
93
+ }
94
+ event[:trace_id] = trace_id if trace_id
95
+ event[:duration_ms] = duration_ms if duration_ms
96
+
97
+ enqueue(event)
98
+ end
99
+
100
+ def send_error_event(message, exception, context = {}, trace_id: nil, duration_ms: nil, timestamp: nil)
101
+ return unless configured?
102
+
103
+ trace_id ||= current_trace_id
104
+ error_file = ""
105
+ error_line = 0
106
+ error_caller = ""
107
+ if exception.backtrace&.first
108
+ if (match = exception.backtrace.first.match(/(.+):(\d+):in [`'](.+)'/))
109
+ error_file = File.basename(match[1])
110
+ error_line = match[2].to_i
111
+ error_caller = match[3]
112
+ end
113
+ end
114
+
115
+ event = {
116
+ message: message,
117
+ timestamp: (timestamp || Time.now).utc.iso8601(3),
118
+ context: stamp_environment(context.merge(
119
+ error: exception.message,
120
+ error_class: exception.class.name,
121
+ error_file: error_file,
122
+ error_line: error_line,
123
+ error_caller: error_caller,
124
+ stack_trace: exception.backtrace&.first(20)&.join("\n")
125
+ ))
126
+ }
127
+ event[:trace_id] = trace_id if trace_id
128
+ event[:duration_ms] = duration_ms if duration_ms
129
+
130
+ enqueue(event, urgent: true)
131
+ end
132
+
133
+ # Sends what is in the queue now, ignoring any backoff. Each batch gets
134
+ # one attempt, all within the shutdown timeout. Events that could not
135
+ # be sent stay in the queue. Returns how many events are left.
136
+ def flush
137
+ deadline = monotonic + @shutdown_timeout
138
+ @send_lock.synchronize do
139
+ loop do
140
+ left = deadline - monotonic
141
+ break if left <= 0
142
+
143
+ batch = @mutex.synchronize { take_batch }
144
+ break unless batch
145
+ break unless deliver(batch, timeout: left)
146
+ end
147
+ end
148
+ @mutex.synchronize { @buffer.size }
149
+ rescue StandardError => e
150
+ log_debug("flush failed: #{e.class}: #{e.message}")
151
+ @mutex.synchronize { @buffer.size }
152
+ end
153
+
154
+ # Called at process exit. Events still queued after the flush are lost.
155
+ def shutdown
156
+ left = flush
157
+ warn("[LogNorth] exiting with #{left} unsent event(s); they are lost") if left.positive?
158
+ end
159
+
160
+ private
161
+
162
+ # Adds context.environment when the client was configured with one.
163
+ # The server displays this on every event/issue/trace so users can tell
164
+ # which deployment a log came from.
165
+ def stamp_environment(context)
166
+ env = @mutex.synchronize { @environment }
167
+ return context unless env
168
+
169
+ context.merge(environment: env)
170
+ end
171
+
172
+ def enqueue(event, urgent: false)
173
+ entry = new_entry(trim(event))
174
+ @mutex.synchronize do
175
+ push(entry)
176
+ @urgent = true if urgent
177
+ start_sender
178
+ @wakeup.signal
179
+ end
180
+ rescue StandardError => e
181
+ log_debug("could not queue event: #{e.class}: #{e.message}")
182
+ end
183
+
184
+ def new_entry(event)
185
+ Entry.new(event, JSON.generate(event).bytesize, error_event?(event[:context]))
186
+ end
187
+
188
+ def error_event?(context)
189
+ return false unless context.is_a?(Hash)
190
+
191
+ status = fetch(context, :status)
192
+ !fetch(context, :error).nil? || !fetch(context, :error_class).nil? ||
193
+ (status.is_a?(Integer) && status >= 500)
194
+ end
195
+
196
+ def fetch(hash, key)
197
+ hash.key?(key) ? hash[key] : hash[key.to_s]
198
+ end
199
+
200
+ # Cuts an event down to at most MAX_EVENT_BYTES of JSON, so one huge
201
+ # event cannot fill the queue.
202
+ def trim(event)
203
+ trimmed = false
204
+ if event[:message].is_a?(String) && event[:message].length > MAX_MESSAGE_CHARS
205
+ event[:message] = event[:message][0, MAX_MESSAGE_CHARS]
206
+ trimmed = true
207
+ end
208
+
209
+ context = event[:context]
210
+ if context.is_a?(Hash)
211
+ context = context.to_h do |key, value|
212
+ limit = key.to_s == "stack_trace" ? MAX_STACK_TRACE_BYTES : MAX_STRING_BYTES
213
+ if value.is_a?(String) && value.bytesize > limit
214
+ trimmed = true
215
+ [key, value.byteslice(0, limit).scrub("")]
216
+ else
217
+ [key, value]
218
+ end
219
+ end
220
+ event[:context] = context
221
+ end
222
+
223
+ if JSON.generate(event).bytesize > MAX_EVENT_BYTES && context.is_a?(Hash)
224
+ event[:context] = context.select { |key, _| ESSENTIAL_KEYS.include?(key.to_s) }
225
+ trimmed = true
226
+ end
227
+
228
+ event[:context] = event[:context].merge(truncated: true) if trimmed
229
+ event
230
+ end
231
+
232
+ # Call with @mutex held. Adds an entry at the back.
233
+ def push(entry)
234
+ @first_at ||= monotonic
235
+ @buffer << entry
236
+ @bytes += entry.bytes
237
+ drop_oldest while over_limit?
238
+ end
239
+
240
+ # Call with @mutex held.
241
+ def over_limit?
242
+ @buffer.size > MAX_BUFFER || @bytes > MAX_BUFFER_BYTES
243
+ end
244
+
245
+ # Call with @mutex held. Drops the oldest event that is not an error,
246
+ # or the oldest error when the queue holds nothing else.
247
+ def drop_oldest
248
+ index = @buffer.index { |entry| !entry.error } || 0
249
+ entry = @buffer.delete_at(index)
250
+ @bytes -= entry.bytes
251
+ count_drop(entry, "queue full, dropping the oldest events")
252
+ end
253
+
254
+ # Call with @mutex held.
255
+ def count_drop(entry, reason)
256
+ warn("[LogNorth] #{reason}") if @dropped.zero?
257
+ @dropped += 1
258
+ @dropped_errors += 1 if entry.error
259
+ end
260
+
261
+ # Call with @mutex held. A forked child has no sender thread; this
262
+ # starts a new one there.
263
+ def start_sender
264
+ return if @sender&.alive?
265
+
266
+ @sender = Thread.new { run_sender }
267
+ end
268
+
269
+ def run_sender
270
+ loop do
271
+ @mutex.synchronize { @wakeup.wait(@mutex, seconds_until_due) until seconds_until_due&.zero? }
272
+ @send_lock.synchronize do
273
+ batch = @mutex.synchronize { take_batch if seconds_until_due&.zero? }
274
+ deliver(batch) if batch
275
+ end
276
+ end
277
+ rescue StandardError => e
278
+ log_debug("sender stopped: #{e.class}: #{e.message}")
279
+ end
280
+
281
+ # Call with @mutex held. 0 when the queue should be sent now, nil to
282
+ # wait for a new event, else the seconds left to wait.
283
+ def seconds_until_due
284
+ return nil if @buffer.empty?
285
+
286
+ now = monotonic
287
+ return @retry_at - now if @retry_at && now < @retry_at
288
+ return 0 if @urgent || @buffer.size >= 10 || @bytes > MAX_BUFFER_BYTES / 2
289
+
290
+ [@first_at + @flush_interval - now, 0].max
291
+ end
292
+
293
+ # Call with @mutex held. Takes entries from the front, up to MAX_BATCH
294
+ # events and MAX_BATCH_BYTES of JSON, and always at least one.
295
+ def take_batch
296
+ return nil if @buffer.empty?
297
+
298
+ max = [@batch_sizes.shift || MAX_BATCH, MAX_BATCH].min
299
+ count = 0
300
+ bytes = 12 # {"events":[]}
301
+ while count < @buffer.size && count < max
302
+ bytes += @buffer[count].bytes + 1
303
+ break if bytes > MAX_BATCH_BYTES && count.positive?
304
+
305
+ count += 1
306
+ end
307
+ batch = @buffer.shift(count)
308
+ @bytes -= batch.sum(&:bytes)
309
+ if @buffer.empty?
310
+ @first_at = nil
311
+ @urgent = false
312
+ @batch_sizes.clear
313
+ end
314
+ batch
315
+ end
316
+
317
+ # Call with @mutex held. Puts events back at the front, in order. The
318
+ # next send takes the same events again.
319
+ def put_back(batch, sizes = [batch.size])
320
+ @batch_sizes.unshift(*sizes)
321
+ @buffer = batch + @buffer
322
+ @bytes += batch.sum(&:bytes)
323
+ @first_at ||= monotonic
324
+ @urgent = true
325
+ drop_oldest while over_limit?
326
+ end
327
+
328
+ # Sends one batch and acts on the answer. Returns true when the next
329
+ # batch can go out at once.
330
+ def deliver(batch, timeout: nil)
331
+ code, retry_after = post(batch, timeout)
332
+ log_debug("response: #{code}")
333
+ @mutex.synchronize { handle_answer(batch, code, retry_after) }
334
+ rescue StandardError => e
335
+ log_debug("send failed: #{e.class}: #{e.message}")
336
+ @mutex.synchronize { retry_later(batch, "network error (#{e.class})", wait_with_jitter) }
337
+ end
338
+
339
+ # Call with @mutex held.
340
+ def handle_answer(batch, code, retry_after)
341
+ case code
342
+ when 200..299
343
+ succeeded
344
+ true
345
+ when 429, 503
346
+ # The server said when; the backoff stays for failures that do not say.
347
+ retry_later(batch, "server answered #{code}", retry_after || next_backoff)
348
+ when 408, 500..599
349
+ retry_later(batch, "server answered #{code}", wait_with_jitter)
350
+ when 401, 403, 404
351
+ misconfigured(batch, code)
352
+ when 400..499
353
+ reject(batch)
354
+ true
355
+ else # a redirect or an unknown answer: most likely a wrong url
356
+ misconfigured(batch, code)
357
+ end
358
+ end
359
+
360
+ # Call with @mutex held.
361
+ def misconfigured(batch, code)
362
+ retry_later(batch, "server answered #{code}, check the LogNorth url and api key",
363
+ next_config_backoff, state: :config)
364
+ end
365
+
366
+ # Call with @mutex held.
367
+ def succeeded
368
+ warn("[LogNorth] delivery recovered") if @failing
369
+ @failing = nil
370
+ @retry_at = nil
371
+ @backoff = @first_backoff
372
+ @config_backoff = @first_config_backoff
373
+ return if @dropped.zero?
374
+
375
+ report = {
376
+ message: "LogNorth client dropped #{@dropped} events",
377
+ timestamp: Time.now.utc.iso8601(3),
378
+ context: stamped({ dropped: @dropped, dropped_errors: @dropped_errors })
379
+ }
380
+ @dropped = 0
381
+ @dropped_errors = 0
382
+ push(new_entry(report))
383
+ @urgent = true
384
+ end
385
+
386
+ # Call with @mutex held. The server can never accept this batch as it
387
+ # is: split it, or drop it when it is a single event.
388
+ def reject(batch)
389
+ if batch.size > 1
390
+ half = (batch.size + 1) / 2
391
+ put_back(batch, [half, batch.size - half])
392
+ else
393
+ count_drop(batch.first, "server rejected an event, dropping it")
394
+ end
395
+ end
396
+
397
+ # Call with @mutex held. Returns false: the next send must wait.
398
+ def retry_later(batch, reason, wait, state: :retrying)
399
+ # The next send takes a full batch again; a refused batch splits again.
400
+ @batch_sizes.clear
401
+ put_back(batch, [])
402
+ @retry_at = monotonic + wait
403
+ warn("[LogNorth] #{reason}; keeping #{@buffer.size} event(s) and retrying") if @failing != state
404
+ @failing = state
405
+ false
406
+ end
407
+
408
+ # Call with @mutex held.
409
+ def stamped(context)
410
+ @environment ? context.merge(environment: @environment) : context
411
+ end
412
+
413
+ def next_backoff
414
+ wait = @backoff
415
+ @backoff = [@backoff * 2, @max_backoff].min
416
+ wait
417
+ end
418
+
419
+ def wait_with_jitter
420
+ next_backoff * rand(0.8..1.2)
421
+ end
422
+
423
+ def next_config_backoff
424
+ wait = @config_backoff
425
+ @config_backoff = [@config_backoff * 2, @max_config_backoff].min
426
+ wait
427
+ end
428
+
429
+ # Returns the status code and the Retry-After seconds (nil when the
430
+ # header is missing or not an integer).
431
+ def post(batch, timeout)
432
+ endpoint, api_key = @mutex.synchronize { [@endpoint, @api_key] }
433
+ uri = URI("#{endpoint}/api/v1/events/batch")
434
+ log_debug("sending #{batch.size} event(s) to #{uri}")
435
+
436
+ http = Net::HTTP.new(uri.host, uri.port)
437
+ http.use_ssl = uri.scheme == "https"
438
+ http.open_timeout = [5, timeout].compact.min
439
+ http.read_timeout = [10, timeout].compact.min
440
+ http.write_timeout = [10, timeout].compact.min
441
+
442
+ request = Net::HTTP::Post.new(uri)
443
+ request["Content-Type"] = "application/json"
444
+ request["Authorization"] = "Bearer #{api_key}"
445
+ request.body = JSON.generate(events: batch.map(&:event))
446
+
447
+ response = http.request(request)
448
+ header = response["Retry-After"]&.strip
449
+ retry_after = header.to_i.clamp(1, 300) if header&.match?(/\A\d+\z/)
450
+ [response.code.to_i, retry_after]
451
+ end
452
+
453
+ def monotonic
454
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
455
+ end
456
+
457
+ def warn(msg)
458
+ $stderr.puts msg
459
+ end
460
+
461
+ def log_debug(msg)
462
+ return unless @debug
463
+
464
+ $stdout.puts "[LogNorth] #{msg}"
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogNorth
4
+ class ErrorSubscriber
5
+ def report(error, handled:, severity:, context: {}, source: nil)
6
+ # Rails answers these with a 4xx (not found, bad CSRF token). The
7
+ # middleware logs the request with that status; it is not an error.
8
+ return if LogNorth.client_error?(error)
9
+
10
+ ctx = context.dup
11
+ ctx[:handled] = handled
12
+ ctx[:severity] = severity
13
+ ctx[:source] = source if source
14
+
15
+ LogNorth.error(error.message, error, ctx)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LogNorth
6
+ class Middleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ path = env["PATH_INFO"]
13
+
14
+ # Skip ignored paths (health checks, metrics, etc.)
15
+ if ignored_path?(path)
16
+ return @app.call(env)
17
+ end
18
+
19
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ start_time = Time.now
21
+ trace_id = env["HTTP_X_TRACE_ID"].to_s.strip
22
+ trace_id = SecureRandom.hex(8) if trace_id.empty?
23
+ LogNorth::Client.current_trace_id = trace_id
24
+
25
+ status, headers, response = @app.call(env)
26
+
27
+ # Don't track requests that didn't match any route (scanner noise)
28
+ if status == 404 && !env["action_controller.instance"]
29
+ LogNorth::Client.current_trace_id = nil
30
+ return [status, headers, response]
31
+ end
32
+
33
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
34
+ headers["X-Trace-ID"] = trace_id
35
+
36
+ context = { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status }
37
+ merge_route_info!(context, env)
38
+
39
+ LogNorth::Client.send_event(
40
+ "#{env['REQUEST_METHOD']} #{env['PATH_INFO']} → #{status}",
41
+ context,
42
+ trace_id: trace_id,
43
+ duration_ms: duration_ms,
44
+ timestamp: start_time
45
+ )
46
+
47
+ LogNorth.flush if status >= 500
48
+
49
+ LogNorth::Client.current_trace_id = nil
50
+ [status, headers, response]
51
+ rescue StandardError => e
52
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
53
+ if LogNorth.client_error?(e)
54
+ # Rails turns this into a 4xx further out. Log the request with that
55
+ # status, like any other request, instead of an error.
56
+ status = LogNorth.response_status_for(e)
57
+ context = { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status }
58
+ merge_route_info!(context, env)
59
+ LogNorth::Client.send_event(
60
+ "#{env['REQUEST_METHOD']} #{env['PATH_INFO']} → #{status}", context,
61
+ trace_id: trace_id, duration_ms: duration_ms, timestamp: start_time
62
+ )
63
+ LogNorth::Client.current_trace_id = nil
64
+ raise
65
+ end
66
+ LogNorth::Client.send_error_event(
67
+ "Request failed: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}", e,
68
+ { method: env["REQUEST_METHOD"], path: env["PATH_INFO"] },
69
+ trace_id: trace_id,
70
+ duration_ms: duration_ms,
71
+ timestamp: start_time
72
+ )
73
+ LogNorth::Client.current_trace_id = nil
74
+ raise
75
+ end
76
+
77
+ private
78
+
79
+ def ignored_path?(path)
80
+ ignored_paths = Rails.application.config.lognorth.ignored_paths rescue []
81
+ return false if ignored_paths.nil? || ignored_paths.empty?
82
+
83
+ ignored_paths.any? { |p| path == p || path.start_with?("#{p}/") }
84
+ end
85
+
86
+ # Rails populates action_controller.instance after dispatch so the
87
+ # controller/action names — and the named route when available —
88
+ # can be pulled straight off the env.
89
+ def merge_route_info!(context, env)
90
+ controller = env["action_controller.instance"]
91
+ return unless controller
92
+
93
+ klass = controller.class.name
94
+ action = controller.respond_to?(:action_name) ? controller.action_name : nil
95
+ context[:controller] = klass if klass
96
+ context[:action] = action if action
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogNorth
4
+ class Railtie < Rails::Railtie
5
+ config.lognorth = ActiveSupport::OrderedOptions.new
6
+ # nil = auto (production only). Set true/false to override.
7
+ config.lognorth.enabled = nil
8
+ config.lognorth.middleware = true
9
+ config.lognorth.error_subscriber = true
10
+ config.lognorth.active_job = true
11
+ # Rails 7.1+ auto-generates /up as a health-check endpoint; kamal-proxy
12
+ # and most load balancers hit it constantly and it would otherwise swamp
13
+ # the log feed. Set to [] to disable or pass your own list to replace.
14
+ config.lognorth.ignored_paths = ["/up"]
15
+
16
+ initializer "lognorth.middleware" do |app|
17
+ if LogNorth::Railtie.lognorth_enabled?(app) && app.config.lognorth.middleware
18
+ app.middleware.use LogNorth::Middleware
19
+ end
20
+ end
21
+
22
+ config.after_initialize do |app|
23
+ next unless LogNorth::Railtie.lognorth_enabled?(app)
24
+
25
+ url = app.config.lognorth.url || LogNorth::Railtie.dig_credential(app, :url)
26
+ key = app.config.lognorth.api_key || LogNorth::Railtie.dig_credential(app, :api_key)
27
+
28
+ unless url && key
29
+ Rails.logger.warn("[LogNorth] enabled but credentials missing — set Rails credentials lognorth.url/api_key or config.lognorth.{url,api_key}")
30
+ next
31
+ end
32
+
33
+ LogNorth.config(url, key, environment: Rails.env.to_s)
34
+ LogNorth::Client.debug = false # Rails.env.local? made tests/dev noisy
35
+
36
+ if app.config.lognorth.error_subscriber && Rails.version >= "7.0"
37
+ Rails.error.subscribe(LogNorth::ErrorSubscriber.new)
38
+ end
39
+
40
+ if app.config.lognorth.active_job && defined?(ActiveJob)
41
+ require "lognorth/active_job_subscriber"
42
+ LogNorth::ActiveJobSubscriber.attach
43
+ end
44
+ end
45
+
46
+ class << self
47
+ # Default off in development and test only. Production, staging, preview,
48
+ # qa, and any other custom env opt in automatically. Set
49
+ # config.lognorth.enabled = true/false to override.
50
+ def lognorth_enabled?(app)
51
+ explicit = app.config.lognorth.enabled
52
+ return explicit unless explicit.nil?
53
+
54
+ !(Rails.env.development? || Rails.env.test?)
55
+ end
56
+
57
+ def dig_credential(app, key)
58
+ app.credentials.dig(:lognorth, key)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/lognorth.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lognorth/client"
4
+ require_relative "lognorth/middleware"
5
+ require_relative "lognorth/error_subscriber"
6
+ require_relative "lognorth/railtie" if defined?(Rails::Railtie)
7
+
8
+ module LogNorth
9
+ class << self
10
+ def config(url, key, environment: nil)
11
+ Client.config(url, key, environment: environment)
12
+ end
13
+
14
+ def log(message, context = {})
15
+ Client.log(message, context)
16
+ end
17
+
18
+ def error(message, exception, context = {})
19
+ Client.error(message, exception, context)
20
+ end
21
+
22
+ def flush
23
+ Client.flush
24
+ end
25
+
26
+ # The HTTP status Rails answers this exception with, from
27
+ # config.action_dispatch.rescue_responses. RecordNotFound is a 404,
28
+ # InvalidAuthenticityToken a 422, anything unmapped a 500. Nil outside Rails.
29
+ def response_status_for(exception)
30
+ return nil unless defined?(ActionDispatch::ExceptionWrapper)
31
+
32
+ ActionDispatch::ExceptionWrapper.status_code_for_exception(exception.class.name)
33
+ end
34
+
35
+ # A client error is an exception Rails answers with a 4xx: the request
36
+ # was wrong, the app is fine. It is not reported as an error.
37
+ def client_error?(exception)
38
+ status = response_status_for(exception)
39
+ !status.nil? && status < 500
40
+ end
41
+ end
42
+ end
43
+
44
+ # Ruby runs at_exit on SIGTERM and SIGINT too, unless the app traps them.
45
+ at_exit { LogNorth::Client.shutdown }
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lognorth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - LogNorth
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Send errors and logs from Rails to LogNorth for monitoring and alerting
13
+ email:
14
+ - hello@lognorth.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/lognorth.rb
22
+ - lib/lognorth/active_job_subscriber.rb
23
+ - lib/lognorth/client.rb
24
+ - lib/lognorth/error_subscriber.rb
25
+ - lib/lognorth/middleware.rb
26
+ - lib/lognorth/railtie.rb
27
+ homepage: https://lognorth.com
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://lognorth.com
32
+ source_code_uri: https://github.com/karloscodes/lognorth-sdk-rails
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 4.0.20
48
+ specification_version: 4
49
+ summary: LogNorth SDK for Rails
50
+ test_files: []