onlylogs 0.5.3 → 0.8.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.
data/bin/super_ripgrep CHANGED
@@ -5,7 +5,6 @@ export LC_ALL=C
5
5
  max_matches=""
6
6
  start_position=""
7
7
  end_position=""
8
- block_size="${BLOCK_SIZE:-8M}"
9
8
 
10
9
  while [[ $# -gt 0 ]]; do
11
10
  case "$1" in
@@ -56,31 +55,32 @@ actual_color_regex='\x1b\[[0-9;]*m'
56
55
  query_regex="${query_regex//$placeholder/$actual_color_regex}"
57
56
 
58
57
  # Build ripgrep command
59
- rg_cmd="rg --color=never --no-filename"
58
+ # --no-mmap: read the file with read() instead of memory-mapping it. mmap'ing a
59
+ # multi-GB log file maps the whole file into the process and, in a container,
60
+ # charges those pages to the cgroup as active memory, which can trigger an OOM
61
+ # kill. Plain read() pages are ordinary, readily-reclaimable page cache.
62
+ # --byte-offset: include byte offset in output for the logs-around-line feature
63
+ rg_cmd="rg --color=never --no-filename --byte-offset --no-mmap"
60
64
  [ -n "$max_matches" ] && rg_cmd="$rg_cmd --max-count=$max_matches"
61
65
 
62
66
  # Handle byte range if specified
63
67
  if [ -n "$start_position" ] || [ -n "$end_position" ]; then
68
+ case "${start_position:-0}${end_position:-0}" in
69
+ *[!0-9]*) exit 1 ;;
70
+ esac
71
+
64
72
  file_size=$(wc -c < "$file")
65
73
  range_start=${start_position:-0}
66
74
  range_end=${end_position:-$file_size}
67
75
  range_size=$((range_end - range_start))
68
-
69
- # Validate range
70
- if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
76
+
77
+ if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
71
78
  exit 0
72
79
  fi
73
-
74
- # Adjust if exceeds file size
80
+
75
81
  [ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
76
-
77
- # Extract byte range using dd
78
- start_mb=$((range_start / 1048576))
79
- start_offset=$((range_start % 1048576))
80
- count_mb=$(((range_size + 1048576 - 1) / 1048576))
81
-
82
- dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
83
- dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
82
+
83
+ tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
84
84
  $rg_cmd -e "$query_regex"
85
85
  else
86
86
  # Search entire file
@@ -9,7 +9,7 @@ module Onlylogs
9
9
  isolate_namespace Onlylogs
10
10
 
11
11
  initializer "onlylogs.assets" do |app|
12
- %w[images stylesheets builds fonts].each do |subdir|
12
+ %w[images javascript stylesheets builds fonts].each do |subdir|
13
13
  path = root.join("app/assets", subdir)
14
14
  app.config.assets.paths << path if path.exist?
15
15
  end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require_relative "spool"
6
+
7
+ # A Logger log device that sends log lines to onlylogs.io (or any Vector-compatible sink) directly
8
+ # via HTTP.
9
+ #
10
+ # When the drain is unreachable or unresponsive, we do two things to protect the app:
11
+ # * an upper bound to the in-memory queue: log lines can never accumulate without limit and exhaust memory
12
+ # * cooldown: once the drain is known to be failing we stop attempting
13
+ # requests for a cooldown period instead of blocking on every send for the full
14
+ # read timeout (a down host accepts the TCP/TLS connection but never answers).
15
+ #
16
+ # By default an on-disk Spool buffers any batch we could not deliver and replays it once the
17
+ # drain recovers, so a transient outage or a restart does not lose logs. It is on by default
18
+ # (set ONLYLOGS_SPOOL_DIR empty to disable) and bounded by bytes; see Onlylogs::Spool.
19
+ module Onlylogs
20
+ class HttpDevice
21
+ DEFAULT_BATCH_SIZE = 100
22
+ DEFAULT_FLUSH_INTERVAL = 0.5
23
+ DEFAULT_MAX_QUEUE_SIZE = 10_000
24
+
25
+ # Keep timeouts short: a single slow/dead drain must never stall the app for long.
26
+ DEFAULT_OPEN_TIMEOUT = 0.5
27
+ DEFAULT_READ_TIMEOUT = 0.5
28
+
29
+ # How long Net::HTTP may keep an idle connection around for reuse. Comfortably longer than
30
+ # the default flush interval so normal traffic reuses one connection across many batches.
31
+ DEFAULT_KEEP_ALIVE_TIMEOUT = 30
32
+
33
+ # Open the circuit after this many consecutive failed sends
34
+ CIRCUIT_FAILURE_THRESHOLD = 3
35
+ # ...and keep it open for this long once it is open.
36
+ CIRCUIT_COOLDOWN = 30
37
+
38
+ def initialize(
39
+ drain_url: ENV["ONLYLOGS_DRAIN_URL"],
40
+ batch_size: ENV.fetch("ONLYLOGS_BATCH_SIZE", DEFAULT_BATCH_SIZE).to_i,
41
+ flush_interval: ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL).to_f,
42
+ max_queue_size: ENV.fetch("ONLYLOGS_MAX_QUEUE_SIZE", DEFAULT_MAX_QUEUE_SIZE).to_i,
43
+ open_timeout: ENV.fetch("ONLYLOGS_OPEN_TIMEOUT", DEFAULT_OPEN_TIMEOUT).to_f,
44
+ read_timeout: ENV.fetch("ONLYLOGS_READ_TIMEOUT", DEFAULT_READ_TIMEOUT).to_f,
45
+ circuit_cooldown: ENV.fetch("ONLYLOGS_CIRCUIT_COOLDOWN", CIRCUIT_COOLDOWN).to_f,
46
+ keep_alive_timeout: ENV.fetch("ONLYLOGS_KEEP_ALIVE_TIMEOUT", DEFAULT_KEEP_ALIVE_TIMEOUT).to_f,
47
+ spool_dir: ENV.fetch("ONLYLOGS_SPOOL_DIR", default_spool_dir),
48
+ spool_max_bytes: ENV.fetch("ONLYLOGS_SPOOL_MAX_BYTES", Spool::DEFAULT_MAX_BYTES).to_i
49
+ )
50
+ @drain_url = drain_url
51
+ @uri = URI.parse(drain_url) if drain_url
52
+ @batch_size = batch_size
53
+ @flush_interval = flush_interval
54
+ @max_queue_size = max_queue_size
55
+ @open_timeout = open_timeout
56
+ @read_timeout = read_timeout
57
+ @circuit_cooldown = circuit_cooldown
58
+ @keep_alive_timeout = keep_alive_timeout
59
+ @queue = Queue.new
60
+ @mutex = Mutex.new
61
+ @http_mutex = Mutex.new
62
+ @http = nil
63
+ @spool = nil
64
+
65
+ @consecutive_failures = 0
66
+ @circuit_open_until = nil
67
+ @dropped = 0
68
+
69
+ if @drain_url
70
+ @spool = build_spool(spool_dir, spool_max_bytes)
71
+ start_sender
72
+ else
73
+ $stderr.puts "Onlylogs::HttpDevice: ONLYLOGS_DRAIN_URL is not set; logging locally only." # rubocop:disable Style/StderrPuts
74
+ end
75
+ end
76
+
77
+ # Receives the already-formatted, already-level-filtered line from Logger#add.
78
+ def write(message)
79
+ return if message.nil? || message.empty?
80
+ # No drain configured: nothing to ship. The local fallback (see MultiDevice) still logs it.
81
+ return unless @drain_url
82
+
83
+ enqueue(message.chomp)
84
+ end
85
+
86
+ def close
87
+ flush
88
+ @running = false
89
+ @sender_thread&.join(2)
90
+ close_connection
91
+ end
92
+
93
+ def flush
94
+ send_batch(drain_queue)
95
+ end
96
+
97
+ private
98
+
99
+ # Push a line onto the queue unless it is full. Dropping is intentional: blocking the
100
+ # caller (a request thread) or growing without bound (OOM) are both worse than losing
101
+ # logs while the drain is unavailable.
102
+ def enqueue(line)
103
+ if @queue.size >= @max_queue_size
104
+ @mutex.synchronize { @dropped += 1 }
105
+ return
106
+ end
107
+
108
+ @queue << line
109
+ end
110
+
111
+ def start_sender
112
+ @running = true
113
+
114
+ @sender_thread = Thread.new do
115
+ # Replay anything left in the spool by a previous run or a crashed/redeployed sibling.
116
+ drain_spool
117
+
118
+ batch = []
119
+ last_flush = Time.now
120
+
121
+ while @running || !@queue.empty?
122
+ begin
123
+ line = @queue.pop(true)
124
+ batch << line if line
125
+ rescue ThreadError
126
+ # queue empty
127
+ end
128
+
129
+ if batch.any? && (batch.size >= @batch_size || (Time.now - last_flush) >= @flush_interval)
130
+ send_batch(batch)
131
+ batch = []
132
+ last_flush = Time.now
133
+ end
134
+
135
+ sleep 0.01 if batch.empty?
136
+ end
137
+
138
+ send_batch(batch) if batch.any?
139
+ end
140
+
141
+ at_exit { close }
142
+ end
143
+
144
+ def drain_queue
145
+ lines = []
146
+ lines << @queue.pop(true) until @queue.empty?
147
+ lines
148
+ rescue ThreadError
149
+ lines
150
+ end
151
+
152
+ def send_batch(lines)
153
+ return if lines.empty?
154
+
155
+ body = lines.join("\n")
156
+
157
+ # Drain is known to be down: skip the request entirely so we don't block for the full read
158
+ # timeout on every batch. Buffer the batch so the cooldown does not cost us data (without a
159
+ # spool configured, spool_write is a no-op and the batch is dropped — best-effort logging).
160
+ if circuit_open?
161
+ spool_write(body)
162
+ return
163
+ end
164
+
165
+ deliver(body)
166
+ record_success
167
+ # The drain just answered: replay anything we had buffered while it was unavailable.
168
+ drain_spool
169
+ rescue => e
170
+ record_failure
171
+ spool_write(body)
172
+ Kernel.warn "Onlylogs::HttpDevice error: #{e.class}: #{e.message}"
173
+ end
174
+
175
+ def spool_write(body)
176
+ @spool&.write(body)
177
+ end
178
+
179
+ # Replay buffered batches now that the drain is responding. Oldest first; stop at the first
180
+ # failure (record it and leave the rest on disk) so a drain that just went down again does not
181
+ # burn the whole backlog into the void.
182
+ def drain_spool
183
+ return unless @spool
184
+
185
+ @spool.replay do |body|
186
+ deliver(body)
187
+ record_success
188
+ true
189
+ rescue => e
190
+ record_failure
191
+ Kernel.warn "Onlylogs::HttpDevice replay error: #{e.class}: #{e.message}"
192
+ false
193
+ end
194
+ end
195
+
196
+ def build_spool(dir, max_bytes)
197
+ return if dir.nil? || dir.to_s.strip.empty?
198
+
199
+ Spool.new(dir: dir, max_bytes: max_bytes)
200
+ rescue => e
201
+ Kernel.warn "Onlylogs::HttpDevice: spool disabled (#{e.class}: #{e.message})"
202
+ nil
203
+ end
204
+
205
+ # The spool is on by default. It lives under the app's tmp dir, which survives a drain outage
206
+ # while the app keeps running; point ONLYLOGS_SPOOL_DIR at a persistent volume to also survive
207
+ # redeploys, or set it empty to disable.
208
+ def default_spool_dir
209
+ base = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
210
+ Rails.root.to_s
211
+ else
212
+ ::Dir.pwd
213
+ end
214
+
215
+ ::File.join(base, "tmp", "onlylogs", "spool")
216
+ end
217
+
218
+ # POST the body over a persistent (kept-alive) connection.
219
+ def deliver(body)
220
+ @http_mutex.synchronize do
221
+ attempts = 0
222
+ response = begin
223
+ attempts += 1
224
+ reused = !@http.nil?
225
+ connection.request(build_request(body))
226
+ rescue
227
+ close_connection
228
+ retry if reused && attempts < 2
229
+ raise
230
+ end
231
+
232
+ # Checked outside the rescue on purpose: a non-2xx is an application-level error on a
233
+ # healthy connection, so it must NOT trigger the reconnect-retry above (that would hammer
234
+ # an erroring drain on a perfectly good socket). Raising here records a failure instead.
235
+ ensure_success!(response)
236
+ end
237
+ end
238
+
239
+ # Net::HTTP does not raise on 4xx/5xx; it returns the response. Treat any non-2xx as a
240
+ # failed delivery so send_batch records it and the circuit can open. Without this a drain
241
+ # that is up but answering 500/413 would look like success and we'd silently drop every batch.
242
+ def ensure_success!(response)
243
+ return if response.is_a?(Net::HTTPSuccess)
244
+
245
+ raise "drain responded #{response.code} #{response.message}"
246
+ end
247
+
248
+ def build_request(body)
249
+ # request_uri (not path): it defaults to "/" when the drain URL has no path — Net::HTTP::Post.new("")
250
+ # raises "HTTP request path is empty" — and it carries any query string (e.g. ?token=...) along.
251
+ request = Net::HTTP::Post.new(@uri.request_uri)
252
+ request.body = body
253
+ request.content_type = "text/plain"
254
+ request
255
+ end
256
+
257
+ # Lazily opens and memoizes the connection. Only assigns @http once #start succeeds, so a
258
+ # failed connect leaves @http nil and the next send starts clean. Caller holds @http_mutex.
259
+ def connection
260
+ return @http if @http
261
+
262
+ http = Net::HTTP.new(@uri.host, @uri.port)
263
+ http.use_ssl = (@uri.scheme == "https")
264
+ http.read_timeout = @read_timeout
265
+ http.open_timeout = @open_timeout
266
+ http.keep_alive_timeout = @keep_alive_timeout
267
+ http.start
268
+ @http = http
269
+ end
270
+
271
+ # Caller holds @http_mutex, or no other thread can touch @http (shutdown after the sender
272
+ # thread has joined).
273
+ def close_connection
274
+ @http&.finish
275
+ rescue IOError
276
+ # already closed
277
+ ensure
278
+ @http = nil
279
+ end
280
+
281
+ def circuit_open?
282
+ @mutex.synchronize { !@circuit_open_until.nil? && Time.now < @circuit_open_until }
283
+ end
284
+
285
+ def record_success
286
+ @mutex.synchronize do
287
+ @consecutive_failures = 0
288
+ @circuit_open_until = nil
289
+ end
290
+ end
291
+
292
+ def record_failure
293
+ opened = false
294
+ dropped = 0
295
+
296
+ @mutex.synchronize do
297
+ @consecutive_failures += 1
298
+ next if @consecutive_failures < CIRCUIT_FAILURE_THRESHOLD
299
+
300
+ # (Re)open the circuit. record_failure only runs on a real send attempt — send_batch
301
+ # short-circuits while the circuit is open — so reaching here always means the drain
302
+ # is still down and we should pause again (this is how recovery retries every cooldown).
303
+ @circuit_open_until = Time.now + @circuit_cooldown
304
+ opened = true
305
+ dropped = @dropped
306
+ @dropped = 0
307
+ end
308
+
309
+ # Warn outside the mutex:
310
+ # doing it inside the lock would re-enter @mutex through record_failure and raise a recursive-lock error.
311
+ return unless opened
312
+
313
+ suffix = dropped.positive? ? " (#{dropped} log lines dropped)" : ""
314
+ Kernel.warn "Onlylogs::HttpDevice: drain unavailable, pausing for #{@circuit_cooldown}s#{suffix}"
315
+ end
316
+ end
317
+ end