railwatch 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/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- metadata +157 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
module Railwatch
|
|
8
|
+
# Railwatch.attach: ship an arbitrary blob -- the JSON payload that failed to
|
|
9
|
+
# parse, a rendered PDF, the webhook body a customer swears they sent -- as
|
|
10
|
+
# its own `attachment` record. Sentry's `Sentry.add_attachment` equivalent,
|
|
11
|
+
# except an attachment is a first-class record linked to the execution it
|
|
12
|
+
# was made in, and optionally to the exception it explains.
|
|
13
|
+
#
|
|
14
|
+
# Railwatch.attach("payload.json", request.raw_post)
|
|
15
|
+
# Railwatch.attach("invoice.pdf", Rails.root.join("tmp/invoice.pdf"))
|
|
16
|
+
# Railwatch.attach("payload.json", body, exception: error)
|
|
17
|
+
#
|
|
18
|
+
# The wire field `data` is base64 of gzip, so a text payload costs a
|
|
19
|
+
# fraction of its size in the batch.
|
|
20
|
+
module Attachments
|
|
21
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
22
|
+
MAX_NAME = 255
|
|
23
|
+
MAX_CONTENT_TYPE = 128
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def attach(name, data, content_type: nil, exception: nil)
|
|
28
|
+
return nil unless Railwatch.enabled?
|
|
29
|
+
|
|
30
|
+
# cap + 1 so "was it truncated?" is still answerable without ever
|
|
31
|
+
# holding more than the cap in memory. A 2GB log file used to be read
|
|
32
|
+
# whole and then sliced.
|
|
33
|
+
cap = Railwatch.config.max_attachment_bytes
|
|
34
|
+
bytes = read(data, cap + 1)
|
|
35
|
+
return nil if bytes.nil? || bytes.empty?
|
|
36
|
+
|
|
37
|
+
name = name.to_s[0, MAX_NAME]
|
|
38
|
+
truncated = bytes.bytesize > cap
|
|
39
|
+
bytes = bytes.byteslice(0, cap) if truncated
|
|
40
|
+
|
|
41
|
+
fields = {
|
|
42
|
+
name: name,
|
|
43
|
+
content_type: (content_type || content_type_for(name)).to_s[0, MAX_CONTENT_TYPE],
|
|
44
|
+
bytes: bytes.bytesize,
|
|
45
|
+
data: Base64.strict_encode64(Zlib.gzip(bytes)),
|
|
46
|
+
exception_group_hash: exception && Subscribers::Exceptions.group_for(exception)
|
|
47
|
+
}
|
|
48
|
+
fields[:truncated] = true if truncated
|
|
49
|
+
# `attachment` is in Railwatch::STANDALONE_TYPES, so this buffers as a
|
|
50
|
+
# child of the current execution when one is recording and ships on its
|
|
51
|
+
# own (from a boot hook, a console, a rescue with nothing executing)
|
|
52
|
+
# when there isn't one.
|
|
53
|
+
Railwatch.record(:attachment, group: Record.group_hash(name), **fields)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A String is the data itself; a Pathname is a file to read; anything else
|
|
57
|
+
# that responds to #read (File, StringIO, an uploaded file) is read.
|
|
58
|
+
def read(data, limit)
|
|
59
|
+
case data
|
|
60
|
+
when nil then nil
|
|
61
|
+
when String then data.byteslice(0, limit)
|
|
62
|
+
when Pathname then File.binread(data, limit)
|
|
63
|
+
else
|
|
64
|
+
value = data.respond_to?(:read) ? data.read(limit) : data.to_s
|
|
65
|
+
value.to_s.byteslice(0, limit)
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Railwatch.debug { "attachment read failed: #{e.class}: #{e.message}" }
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Marcel comes with Rails (Active Storage depends on it), but Railwatch's
|
|
73
|
+
# only declared dependency is rails itself, so an app that has somehow
|
|
74
|
+
# dropped it still attaches -- just with the generic type.
|
|
75
|
+
def content_type_for(name)
|
|
76
|
+
return DEFAULT_CONTENT_TYPE unless defined?(::Marcel::MimeType)
|
|
77
|
+
|
|
78
|
+
::Marcel::MimeType.for(name: name) || DEFAULT_CONTENT_TYPE
|
|
79
|
+
rescue StandardError
|
|
80
|
+
DEFAULT_CONTENT_TYPE
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Locates the app frame that caused a query or outgoing request, and
|
|
5
|
+
# serialises exception frames with source snippets for app code.
|
|
6
|
+
module Backtrace
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def app_root
|
|
10
|
+
@app_root ||= (defined?(Rails) && Rails.root ? Rails.root.to_s + "/" : Dir.pwd + "/")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def clean(frames)
|
|
14
|
+
cleaner = defined?(Rails) && Rails.respond_to?(:backtrace_cleaner) ? Rails.backtrace_cleaner : nil
|
|
15
|
+
cleaner ? cleaner.clean(frames) : frames
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# First application frame as "app/models/user.rb:12", or nil. 100 frames
|
|
19
|
+
# clears framework internals in practice (Rails 8.1's instrumentation and
|
|
20
|
+
# query-cache wrapping alone run 40+ frames deep before reaching app code,
|
|
21
|
+
# more once a view template is on the stack) and keeps the walk (and the
|
|
22
|
+
# array it allocates) cheap on the hot query path.
|
|
23
|
+
# The gem's own lib/ directory, so frames inside Railwatch are skipped by
|
|
24
|
+
# prefix rather than by a substring that would also match any app whose
|
|
25
|
+
# checkout happens to live under a folder named "railwatch" (including
|
|
26
|
+
# this repo's own spec/dummy, which lives under the repo root but not
|
|
27
|
+
# under its lib/).
|
|
28
|
+
GEM_ROOT = File.expand_path("..", __dir__) + "/"
|
|
29
|
+
|
|
30
|
+
def gem_root
|
|
31
|
+
GEM_ROOT
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def caller_location(skip: 2)
|
|
35
|
+
locations = caller_locations(skip, 100) or return nil
|
|
36
|
+
locations.each do |loc|
|
|
37
|
+
path = loc.path
|
|
38
|
+
next if path.start_with?(gem_root)
|
|
39
|
+
next if installed_gem_path?(path)
|
|
40
|
+
return "#{path.delete_prefix(app_root)}:#{loc.lineno}"
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# True for frames inside an installed gem (net-http, webmock, rspec-core,
|
|
46
|
+
# etc). Railwatch itself is excluded separately via gem_root because in
|
|
47
|
+
# this repo's own test suite (and in a `path:`/`git:` Gemfile checkout)
|
|
48
|
+
# it is loaded straight from a working tree, not from a Gem.path install.
|
|
49
|
+
def installed_gem_path?(path)
|
|
50
|
+
Gem.path.any? { |p| path.start_with?("#{p}/") }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One line of a String backtrace: "path:line:in 'label'" (Ruby 3.4),
|
|
54
|
+
# "path:line:in `label'" (earlier), or a bare "path:line".
|
|
55
|
+
BACKTRACE_LINE = /\A(.+?):(\d+)(?::in [`'](.*)')?\z/
|
|
56
|
+
|
|
57
|
+
def frames(exception, with_source: true, limit: 50)
|
|
58
|
+
raw_frames(exception, limit).map do |path, lineno, label|
|
|
59
|
+
in_app = path.to_s.start_with?(app_root)
|
|
60
|
+
frame = {
|
|
61
|
+
file: in_app ? path.delete_prefix(app_root) : path,
|
|
62
|
+
line: lineno,
|
|
63
|
+
function: label,
|
|
64
|
+
in_app: in_app
|
|
65
|
+
}
|
|
66
|
+
frame[:code] = source_snippet(path, lineno) if with_source && in_app
|
|
67
|
+
frame
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# [path, line, label] per frame. backtrace_locations is nil for any
|
|
72
|
+
# exception whose backtrace was assigned rather than raised into it --
|
|
73
|
+
# ActiveRecord::StatementInvalid (set_backtrace from the driver error),
|
|
74
|
+
# Faraday::Error (delegates #backtrace to the wrapped exception) -- which
|
|
75
|
+
# are the most common production exceptions, so fall back to parsing the
|
|
76
|
+
# strings rather than shipping them with no frames, no culprit, and a
|
|
77
|
+
# fingerprint of nothing but class and message.
|
|
78
|
+
def raw_frames(exception, limit)
|
|
79
|
+
locations = exception.backtrace_locations
|
|
80
|
+
if locations
|
|
81
|
+
locations.first(limit).map { |loc| [ loc.absolute_path || loc.path, loc.lineno, loc.label ] }
|
|
82
|
+
else
|
|
83
|
+
Array(exception.backtrace).first(limit).filter_map do |line|
|
|
84
|
+
match = BACKTRACE_LINE.match(line.to_s) or next
|
|
85
|
+
[ match[1], match[2].to_i, match[3] ]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- Browser stacks ---------------------------------------------------
|
|
91
|
+
|
|
92
|
+
# One frame of a JavaScript stack, in either of the two shapes engines
|
|
93
|
+
# write: V8's "at fn (https://host/assets/app-abc.js:1:2)" (and the same
|
|
94
|
+
# line without the function name), or SpiderMonkey and JavaScriptCore's
|
|
95
|
+
# "fn@https://host/assets/app-abc.js:1:2". A line with no file:line on it
|
|
96
|
+
# -- V8's leading "TypeError: ..." header, "at new Promise (<anonymous>)"
|
|
97
|
+
# -- matches neither and is dropped.
|
|
98
|
+
JS_FRAME = /
|
|
99
|
+
\A
|
|
100
|
+
(?:at\s+)? # V8 indents every frame with "at "
|
|
101
|
+
(?:(?<function>[^@]*?)\s*[@(])? # "fn@" (Firefox, Safari) or "fn (" (V8)
|
|
102
|
+
(?<file>\S+?)
|
|
103
|
+
:(?<line>\d+)(?::(?<column>\d+))? # line and column (both one-based)
|
|
104
|
+
\)?
|
|
105
|
+
\z
|
|
106
|
+
/x
|
|
107
|
+
|
|
108
|
+
# Frames from the app's own origin that are still not the app's code.
|
|
109
|
+
VENDOR_PATH = %r{(?:\A|/)(?:node_modules|vendor)\b}
|
|
110
|
+
|
|
111
|
+
MAX_JS_FRAMES = 50
|
|
112
|
+
|
|
113
|
+
# A browser stack, exactly as the engine wrote it, in the same frame
|
|
114
|
+
# shape as a Ruby backtrace. `origin` is the app's own scheme and host: a
|
|
115
|
+
# script served from it is the app's own, so its file is stored relative
|
|
116
|
+
# to that origin the way a Ruby frame is stored relative to Rails.root,
|
|
117
|
+
# and anything from a CDN, an extension, or a third-party tag keeps its
|
|
118
|
+
# whole URL and is not in_app.
|
|
119
|
+
def js_frames(stack, origin: nil, limit: MAX_JS_FRAMES)
|
|
120
|
+
frames = []
|
|
121
|
+
stack.to_s.each_line do |raw|
|
|
122
|
+
break if frames.size >= limit
|
|
123
|
+
match = JS_FRAME.match(raw.strip) or next
|
|
124
|
+
url = match[:file].split("?", 2).first.to_s
|
|
125
|
+
own = js_own_origin?(url, origin)
|
|
126
|
+
file = own ? url.delete_prefix(origin.to_s).delete_prefix("/") : url
|
|
127
|
+
function = match[:function].to_s.strip
|
|
128
|
+
frame = {
|
|
129
|
+
file: file[0, 255],
|
|
130
|
+
line: match[:line].to_i,
|
|
131
|
+
function: function.empty? ? "(anonymous)" : function[0, 255],
|
|
132
|
+
in_app: own && !VENDOR_PATH.match?(file)
|
|
133
|
+
}
|
|
134
|
+
frame[:column] = match[:column].to_i if match[:column]
|
|
135
|
+
frames << frame
|
|
136
|
+
end
|
|
137
|
+
frames
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A bare path ("/assets/app.js") can only be the app's own; an absolute
|
|
141
|
+
# URL is only the app's own when it is on the app's origin.
|
|
142
|
+
def js_own_origin?(url, origin)
|
|
143
|
+
return true if url.start_with?("/")
|
|
144
|
+
return false if origin.nil? || origin.empty?
|
|
145
|
+
url.start_with?("#{origin}/")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def source_snippet(path, line, context: 5)
|
|
149
|
+
return nil unless path && File.readable?(path)
|
|
150
|
+
lines = File.readlines(path, chomp: true)
|
|
151
|
+
from = [ line - context - 1, 0 ].max
|
|
152
|
+
to = [ line + context - 1, lines.size - 1 ].min
|
|
153
|
+
(from..to).to_h { |i| [ i + 1, lines[i].to_s[0, 200] ] }
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Bounded, thread-safe queue of records. When full, the oldest record is
|
|
5
|
+
# dropped and counted; the drop count is reported with the next batch so
|
|
6
|
+
# loss is visible on the platform instead of silent.
|
|
7
|
+
#
|
|
8
|
+
# "Full" is two limits, not one. A record count alone does not bound memory:
|
|
9
|
+
# 10,000 records is a few megabytes of ordinary telemetry and a gigabyte of
|
|
10
|
+
# captured attachments or a query record carrying a multi-megabyte SQL
|
|
11
|
+
# string. Each record's weight is measured once, when it is pushed, and
|
|
12
|
+
# carried alongside it so shifting one out is arithmetic rather than a
|
|
13
|
+
# re-measure.
|
|
14
|
+
class Buffer
|
|
15
|
+
def initialize(capacity, byte_capacity: Float::INFINITY)
|
|
16
|
+
@capacity = capacity
|
|
17
|
+
@byte_capacity = byte_capacity
|
|
18
|
+
@records = []
|
|
19
|
+
@record_bytes = []
|
|
20
|
+
@bytes = 0
|
|
21
|
+
@dropped = 0
|
|
22
|
+
@dropped_bytes = 0
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `bytes` is the caller's already-measured weight (Execution weighed the
|
|
27
|
+
# record when it buffered it); anything else is weighed here. Weighing
|
|
28
|
+
# stops at the ceiling, so the dropped-byte counter is a floor for an
|
|
29
|
+
# over-ceiling record; the dropped-record counter is always exact.
|
|
30
|
+
def push(record, bytes = nil)
|
|
31
|
+
bytes ||= Record.buffered_bytes(record, limit: @byte_capacity)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
# A single record heavier than the whole queue can only be dropped:
|
|
34
|
+
# making room for it would mean emptying the queue and still not
|
|
35
|
+
# fitting.
|
|
36
|
+
if bytes > @byte_capacity
|
|
37
|
+
drop(bytes)
|
|
38
|
+
next @records.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
drop_oldest while @records.any? && (@records.size >= @capacity || @bytes + bytes > @byte_capacity)
|
|
42
|
+
@records << record
|
|
43
|
+
@record_bytes << bytes
|
|
44
|
+
@bytes += bytes
|
|
45
|
+
@records.size
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def size
|
|
50
|
+
@mutex.synchronize { @records.size }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def dropped
|
|
54
|
+
@mutex.synchronize { @dropped }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def bytes
|
|
58
|
+
@mutex.synchronize { @bytes }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stats
|
|
62
|
+
@mutex.synchronize { [ @records.size, @dropped, @bytes, @dropped_bytes ] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def account_dropped(count, bytes: 0)
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@dropped += count
|
|
68
|
+
@dropped_bytes += bytes
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def full?(threshold)
|
|
73
|
+
size >= threshold
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Atomically take everything, resetting the drop counters. Returns the
|
|
77
|
+
# records with the weights measured when they were pushed, so the reporter
|
|
78
|
+
# can split a batch by bytes without weighing anything twice.
|
|
79
|
+
def drain
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
drained = [ @records, @dropped, @dropped_bytes, @record_bytes ]
|
|
82
|
+
@records = []
|
|
83
|
+
@record_bytes = []
|
|
84
|
+
@bytes = 0
|
|
85
|
+
@dropped = 0
|
|
86
|
+
@dropped_bytes = 0
|
|
87
|
+
drained
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Put an unsuccessfully delivered batch back ahead of records written
|
|
92
|
+
# while it was in flight. The queue stays bounded: if both generations no
|
|
93
|
+
# longer fit, the oldest restored records are discarded first so fresh
|
|
94
|
+
# application telemetry wins under sustained ingest failure.
|
|
95
|
+
def restore(records, sizes = nil, dropped: 0, dropped_bytes: 0)
|
|
96
|
+
sizes ||= records.map { |record| Record.buffered_bytes(record, limit: @byte_capacity) }
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@records = records + @records
|
|
99
|
+
@record_bytes = sizes + @record_bytes
|
|
100
|
+
@bytes += sizes.sum
|
|
101
|
+
@dropped += dropped
|
|
102
|
+
@dropped_bytes += dropped_bytes
|
|
103
|
+
drop_oldest while @records.any? && (@records.size > @capacity || @bytes > @byte_capacity)
|
|
104
|
+
@records.size
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def drop(bytes)
|
|
111
|
+
@dropped += 1
|
|
112
|
+
@dropped_bytes += bytes
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def drop_oldest
|
|
116
|
+
@records.shift
|
|
117
|
+
bytes = @record_bytes.shift
|
|
118
|
+
@bytes -= bytes
|
|
119
|
+
drop(bytes)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Wall time for timestamps, monotonic time for durations. Durations are
|
|
5
|
+
# integers in microseconds everywhere, matching Nightwatch.
|
|
6
|
+
module Clock
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def now
|
|
10
|
+
Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def monotonic
|
|
14
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def micros_since(monotonic_start)
|
|
18
|
+
((monotonic - monotonic_start) * 1_000_000).round
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ms_to_micros(ms)
|
|
22
|
+
(ms.to_f * 1_000).round
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|