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 +7 -0
- data/LICENSE +21 -0
- data/README.md +115 -0
- data/lib/lognorth/active_job_subscriber.rb +56 -0
- data/lib/lognorth/client.rb +468 -0
- data/lib/lognorth/error_subscriber.rb +18 -0
- data/lib/lognorth/middleware.rb +99 -0
- data/lib/lognorth/railtie.rb +64 -0
- data/lib/lognorth.rb +45 -0
- metadata +50 -0
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: []
|