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,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Resolves the current user. Default order: the app's Railwatch.user block,
|
|
6
|
+
# then Current.user (authentication-zero, Rails 8 auth generator), then
|
|
7
|
+
# Warden (Devise). Emits a `user` record once per user per process hour so
|
|
8
|
+
# the platform can show names without every record carrying them.
|
|
9
|
+
module Users
|
|
10
|
+
extend Base
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def install!(_app)
|
|
15
|
+
subscribe("start_processing.action_controller") do |event|
|
|
16
|
+
exe = execution or next
|
|
17
|
+
exe.user_id = resolve_id(event.payload[:request]&.env)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_id(env = nil)
|
|
22
|
+
user = resolve_object(env) or return nil
|
|
23
|
+
details = describe(user) or return nil
|
|
24
|
+
remember(details)
|
|
25
|
+
details[:id]
|
|
26
|
+
rescue StandardError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resolve_from_current
|
|
31
|
+
resolve_id(nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The beacon's resolution: the app's beacon_user block first, then the
|
|
35
|
+
# same Current.user / Warden lookup a request gets.
|
|
36
|
+
def resolve_beacon_id(request)
|
|
37
|
+
if (resolver = Railwatch.config.beacon_user_resolver)
|
|
38
|
+
user = resolver.call(request)
|
|
39
|
+
if user
|
|
40
|
+
details = describe(user) or return nil
|
|
41
|
+
remember(details)
|
|
42
|
+
return details[:id]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
resolve_id(request.env)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_object(env)
|
|
51
|
+
if defined?(::Current) && ::Current.respond_to?(:user) && ::Current.user
|
|
52
|
+
::Current.user
|
|
53
|
+
elsif env && env["warden"].respond_to?(:user) && env["warden"].user
|
|
54
|
+
env["warden"].user
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def describe(user)
|
|
59
|
+
details = if (resolver = Railwatch.config.user_resolver)
|
|
60
|
+
resolver.call(user)
|
|
61
|
+
else
|
|
62
|
+
{
|
|
63
|
+
id: user.respond_to?(:id) ? user.id : user.to_s,
|
|
64
|
+
name: user.respond_to?(:name) ? user.name : nil,
|
|
65
|
+
email: user.respond_to?(:email) ? user.email : nil
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
return nil unless details.is_a?(Hash) && details[:id]
|
|
69
|
+
details = details.transform_values { |v| v&.to_s&.[](0, 255) }
|
|
70
|
+
# Binding a known tenant onto the execution now (rather than leaving
|
|
71
|
+
# it to Execution#envelope's lazy bind) is what makes the reference
|
|
72
|
+
# below final, which is what lets `remember` trust its cache.
|
|
73
|
+
if (tenant = Context.current_tenant)
|
|
74
|
+
exe = execution
|
|
75
|
+
exe.tenant = tenant if exe && exe.tenant.nil?
|
|
76
|
+
details[:id] = Execution.qualified_user(details[:id], tenant)
|
|
77
|
+
end
|
|
78
|
+
details
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One `user` entity per id per process-hour. The cache entry is written
|
|
82
|
+
# only once the entity has actually shipped, which is why the record is
|
|
83
|
+
# parked on the execution (Execution#pending_users) and committed from
|
|
84
|
+
# finish_execution instead of here: a first sighting inside a
|
|
85
|
+
# sampled-out or paused execution writes no record, and must not
|
|
86
|
+
# suppress the next sighting that would.
|
|
87
|
+
#
|
|
88
|
+
# @seen is a plain unsynchronized Hash, as it was before: in CRuby a
|
|
89
|
+
# Hash store runs to completion under the GVL, so concurrent web
|
|
90
|
+
# threads cannot corrupt it, and the worst a lost race costs is one
|
|
91
|
+
# duplicate `user` record -- which the platform upserts by id.
|
|
92
|
+
def remember(details)
|
|
93
|
+
@seen ||= {}
|
|
94
|
+
key = details[:id]
|
|
95
|
+
now = Clock.now
|
|
96
|
+
return if recently_seen?(key, now)
|
|
97
|
+
|
|
98
|
+
exe = execution
|
|
99
|
+
pending = exe&.pending_users
|
|
100
|
+
return if pending&.key?(key)
|
|
101
|
+
|
|
102
|
+
record = Railwatch.record(:user, id: key, name: details[:name], email: details[:email],
|
|
103
|
+
tenant: Context.current_tenant)
|
|
104
|
+
return unless record
|
|
105
|
+
|
|
106
|
+
exe ? (exe.pending_users ||= {})[key] = record : mark_seen(key, now)
|
|
107
|
+
record
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Called from Railwatch.finish_execution, for a tree that is being handed
|
|
111
|
+
# to the reporter, just before its records are written.
|
|
112
|
+
def commit_execution!(exe)
|
|
113
|
+
pending = exe.pending_users
|
|
114
|
+
exe.pending_users = nil
|
|
115
|
+
now = Clock.now
|
|
116
|
+
pending.each do |key, record|
|
|
117
|
+
# An over-full execution buffer drops the record it was handed, and
|
|
118
|
+
# a failure-context ring can later shift it back out; either way the
|
|
119
|
+
# entity never shipped. Identity, not `==`: two `user` records for
|
|
120
|
+
# the same person are equal hashes.
|
|
121
|
+
index = exe.records.index { |buffered| buffered.equal?(record) } or next
|
|
122
|
+
|
|
123
|
+
# A tenant bound after the entity was resolved changes its
|
|
124
|
+
# reference ("1" becomes "acme:1"), so this -- not the provisional
|
|
125
|
+
# key `remember` checked -- is what the cache is keyed on. Such an
|
|
126
|
+
# app therefore rebuilds the entity each request and discards the
|
|
127
|
+
# duplicate here; that is one small hash, and the alternative
|
|
128
|
+
# (trusting the provisional key) is what suppressed a second
|
|
129
|
+
# tenant's user 1 entirely.
|
|
130
|
+
reference = Execution.qualified_user(key, exe.tenant)
|
|
131
|
+
if recently_seen?(reference, now)
|
|
132
|
+
exe.records.delete_at(index)
|
|
133
|
+
else
|
|
134
|
+
record[:id] = reference
|
|
135
|
+
record[:tenant] = exe.tenant if record[:tenant].nil?
|
|
136
|
+
mark_seen(reference, now)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A fork inherits this cache but not the reporter buffer the cached
|
|
142
|
+
# entities were written to, so the child has to emit its own.
|
|
143
|
+
def restart_after_fork!
|
|
144
|
+
@seen = {}
|
|
145
|
+
execution&.pending_users = nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def recently_seen?(key, now)
|
|
149
|
+
last = @seen[key]
|
|
150
|
+
last && now - last < 3600
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def mark_seen(key, now)
|
|
154
|
+
@seen[key] = now
|
|
155
|
+
@seen.delete(@seen.keys.first) if @seen.size > 10_000
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Template, partial, and collection renders. Only the first N per
|
|
6
|
+
# execution are stored (config.max_view_renders_per_execution); all are counted.
|
|
7
|
+
module Views
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
# A process renders a small, fixed set of templates, so the identifier
|
|
11
|
+
# -> group hash is computed once per template rather than per render.
|
|
12
|
+
# Frozen because the same string goes out on every record as _group.
|
|
13
|
+
GROUP_CACHE_LIMIT = 2_048
|
|
14
|
+
@group_cache = {}
|
|
15
|
+
@group_mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def group_for(identifier)
|
|
20
|
+
cached = @group_cache[identifier]
|
|
21
|
+
return cached if cached
|
|
22
|
+
|
|
23
|
+
group = Record.group_hash(identifier).freeze
|
|
24
|
+
@group_mutex.synchronize do
|
|
25
|
+
@group_cache.clear if @group_cache.size >= GROUP_CACHE_LIMIT
|
|
26
|
+
@group_cache[identifier] = group
|
|
27
|
+
end
|
|
28
|
+
group
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def install!(_app)
|
|
32
|
+
%w[render_template render_partial render_collection render_layout].each do |kind|
|
|
33
|
+
subscribe("#{kind}.action_view") do |event|
|
|
34
|
+
exe = execution
|
|
35
|
+
exe&.count(:view_renders)
|
|
36
|
+
next unless recording?
|
|
37
|
+
next if exe && exe.counters[:view_renders] > Railwatch.config.max_view_renders_per_execution
|
|
38
|
+
p = event.payload
|
|
39
|
+
identifier = p[:identifier].to_s.delete_prefix(Backtrace.app_root)
|
|
40
|
+
Railwatch.record(:view_render,
|
|
41
|
+
group: group_for(identifier),
|
|
42
|
+
timestamp: started_at(event),
|
|
43
|
+
identifier: identifier[0, 255],
|
|
44
|
+
kind: kind.delete_prefix("render_"),
|
|
45
|
+
layout: p[:layout]&.to_s,
|
|
46
|
+
count: p[:count],
|
|
47
|
+
cache_hits: p[:cache_hits],
|
|
48
|
+
duration: micros(event))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "railwatch/subscribers/base"
|
|
4
|
+
require "railwatch/subscribers/requests"
|
|
5
|
+
require "railwatch/subscribers/queries"
|
|
6
|
+
require "railwatch/subscribers/exceptions"
|
|
7
|
+
require "railwatch/subscribers/cache"
|
|
8
|
+
require "railwatch/subscribers/mail"
|
|
9
|
+
require "railwatch/subscribers/broadcasts"
|
|
10
|
+
require "railwatch/subscribers/notifications"
|
|
11
|
+
require "railwatch/subscribers/storage"
|
|
12
|
+
require "railwatch/subscribers/views"
|
|
13
|
+
require "railwatch/subscribers/logs"
|
|
14
|
+
require "railwatch/subscribers/jobs"
|
|
15
|
+
require "railwatch/subscribers/deprecations"
|
|
16
|
+
require "railwatch/subscribers/users"
|
|
17
|
+
require "railwatch/subscribers/process_info"
|
|
18
|
+
|
|
19
|
+
module Railwatch
|
|
20
|
+
module Subscribers
|
|
21
|
+
ALL = [ Requests, Queries, Exceptions, Cache, Mail, Broadcasts, Notifications,
|
|
22
|
+
Storage, Views, Logs, Jobs, Deprecations, Users, ProcessInfo ].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def install!(app = nil)
|
|
27
|
+
ALL.each do |subscriber|
|
|
28
|
+
subscriber.install!(app)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Railwatch.debug { "failed to install #{subscriber.name}: #{e.class}: #{e.message}" }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Railwatch
|
|
9
|
+
module Transport
|
|
10
|
+
# POSTs gzip NDJSON batches to the platform. Each call retries one raised
|
|
11
|
+
# error or 5xx response, then returns a classified, non-raising result;
|
|
12
|
+
# Reporter owns retention and backoff between calls. A 401 marks the
|
|
13
|
+
# transport unauthorized so no further requests are made.
|
|
14
|
+
class Http
|
|
15
|
+
RETRYABLE_STATUSES = [ 402, 408, 429 ].freeze
|
|
16
|
+
UNAUTHORIZED_STATUS = 401
|
|
17
|
+
|
|
18
|
+
Result = Struct.new(:ok, :status, :accepted, :rejected, :rejections, :error, :retryable_error, keyword_init: true) do
|
|
19
|
+
def retryable?
|
|
20
|
+
!ok && (retryable_error || Http.retryable_status?(status))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.retryable_status?(status)
|
|
25
|
+
status.nil? || RETRYABLE_STATUSES.include?(status) || (500..599).cover?(status)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(config)
|
|
29
|
+
@config = config
|
|
30
|
+
@uri = URI.join(config.ingest_url, "/ingest")
|
|
31
|
+
@unauthorized = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unauthorized?
|
|
35
|
+
@unauthorized
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The object itself is copied into a forked process, but its policy
|
|
39
|
+
# state belongs to the parent that observed those responses.
|
|
40
|
+
def reset_after_fork!
|
|
41
|
+
@unauthorized = false
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: SecureRandom.uuid)
|
|
46
|
+
unless @config.ingest_url_allowed?
|
|
47
|
+
return Result.new(ok: false, error: "plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true")
|
|
48
|
+
end
|
|
49
|
+
return Result.new(ok: false, status: UNAUTHORIZED_STATUS, error: "unauthorized, flushing stopped") if @unauthorized
|
|
50
|
+
|
|
51
|
+
body, sent, over_cap, over_cap_bytes = encode(records)
|
|
52
|
+
if over_cap.positive?
|
|
53
|
+
# Not a delivery failure: a batch this large will be exactly as
|
|
54
|
+
# large on every retry, so raising a retryable error here would burn
|
|
55
|
+
# the whole ladder and drop the records at the end anyway. Drop them
|
|
56
|
+
# now, and count them onto this batch so the loss is visible.
|
|
57
|
+
Railwatch.debug { "dropped #{over_cap} records that did not fit in batch_bytes (#{@config.batch_bytes})" }
|
|
58
|
+
dropped += over_cap
|
|
59
|
+
dropped_bytes += over_cap_bytes
|
|
60
|
+
end
|
|
61
|
+
attempt = 0
|
|
62
|
+
begin
|
|
63
|
+
attempt += 1
|
|
64
|
+
result = parse(post(body, dropped, dropped_bytes, backpressure_factor, batch_id), expected_count: sent)
|
|
65
|
+
if attempt < 2 && (500..599).cover?(result.status)
|
|
66
|
+
result = parse(post(body, dropped, dropped_bytes, backpressure_factor, batch_id), expected_count: sent)
|
|
67
|
+
end
|
|
68
|
+
apply_status_policy(result)
|
|
69
|
+
result
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
retry if attempt < 2
|
|
72
|
+
Result.new(ok: false, error: "#{e.class}: #{e.message}")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ping
|
|
77
|
+
return false unless @config.ingest_url_allowed?
|
|
78
|
+
|
|
79
|
+
response = request(Net::HTTP::Get.new(URI.join(@config.ingest_url, "/ingest/ping")))
|
|
80
|
+
response.is_a?(Net::HTTPSuccess)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# The one serialization of the batch, so it is also where its exact
|
|
88
|
+
# uncompressed size is known. Records past config.batch_bytes are left
|
|
89
|
+
# out and reported back to the caller rather than growing the request
|
|
90
|
+
# without limit. Returns [body, records written, records left out,
|
|
91
|
+
# bytes left out].
|
|
92
|
+
def encode(records)
|
|
93
|
+
io = StringIO.new
|
|
94
|
+
gz = Zlib::GzipWriter.new(io)
|
|
95
|
+
bytes = 0
|
|
96
|
+
sent = 0
|
|
97
|
+
over_cap = 0
|
|
98
|
+
over_cap_bytes = 0
|
|
99
|
+
records.each do |record|
|
|
100
|
+
json = JSON.generate(record)
|
|
101
|
+
size = json.bytesize + 1
|
|
102
|
+
if bytes + size > @config.batch_bytes
|
|
103
|
+
over_cap += 1
|
|
104
|
+
over_cap_bytes += size
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
gz.write(json)
|
|
108
|
+
gz.write("\n")
|
|
109
|
+
bytes += size
|
|
110
|
+
sent += 1
|
|
111
|
+
end
|
|
112
|
+
gz.close
|
|
113
|
+
[ io.string, sent, over_cap, over_cap_bytes ]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def post(body, dropped, dropped_bytes, backpressure_factor, batch_id)
|
|
117
|
+
req = Net::HTTP::Post.new(@uri)
|
|
118
|
+
req["Content-Type"] = "application/x-ndjson"
|
|
119
|
+
req["Content-Encoding"] = "gzip"
|
|
120
|
+
req["X-Railwatch-Dropped"] = dropped.to_s if dropped.positive?
|
|
121
|
+
req["X-Railwatch-Dropped-Bytes"] = dropped_bytes.to_s if dropped_bytes.positive?
|
|
122
|
+
if backpressure_factor > 1.0
|
|
123
|
+
req["X-Railwatch-Backpressure-Factor"] = backpressure_factor.to_s
|
|
124
|
+
end
|
|
125
|
+
req["X-Railwatch-Version"] = Railwatch::VERSION
|
|
126
|
+
req["X-Railwatch-Batch-Id"] = batch_id
|
|
127
|
+
req.body = body
|
|
128
|
+
request(req)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def request(req)
|
|
132
|
+
req["Authorization"] = "Bearer #{@config.token}"
|
|
133
|
+
req["User-Agent"] = "railwatch-ruby/#{Railwatch::VERSION}"
|
|
134
|
+
options = {
|
|
135
|
+
use_ssl: @uri.scheme == "https",
|
|
136
|
+
open_timeout: @config.connect_timeout,
|
|
137
|
+
read_timeout: @config.timeout,
|
|
138
|
+
write_timeout: @config.timeout
|
|
139
|
+
}
|
|
140
|
+
# Net::HTTP currently defaults HTTPS clients to VERIFY_PEER. Set it
|
|
141
|
+
# explicitly so a Ruby default change cannot silently weaken ingest.
|
|
142
|
+
options[:verify_mode] = OpenSSL::SSL::VERIFY_PEER if options[:use_ssl]
|
|
143
|
+
Net::HTTP.start(@uri.host, @uri.port, **options) do |http|
|
|
144
|
+
http.request(req)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse(response, expected_count:)
|
|
149
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
150
|
+
parse_acknowledgement(response, expected_count)
|
|
151
|
+
else
|
|
152
|
+
Result.new(ok: false, status: response.code.to_i, error: response.body.to_s[0, 200])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parse_acknowledgement(response, expected_count)
|
|
157
|
+
data = JSON.parse(response.body)
|
|
158
|
+
return invalid_acknowledgement(response, "response must be a JSON object") unless data.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
accepted = data["accepted"]
|
|
161
|
+
rejected = data["rejected"]
|
|
162
|
+
unless accepted.is_a?(Integer) && accepted >= 0 && rejected.is_a?(Integer) && rejected >= 0
|
|
163
|
+
return invalid_acknowledgement(response, "accepted and rejected must be non-negative integers")
|
|
164
|
+
end
|
|
165
|
+
unless drained?(data, accepted, rejected) || accepted + rejected == expected_count
|
|
166
|
+
return invalid_acknowledgement(response,
|
|
167
|
+
"accepted + rejected was #{accepted + rejected}, expected #{expected_count}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
rejections = data["rejections"]
|
|
171
|
+
unless rejections.nil? || rejections.is_a?(Array)
|
|
172
|
+
return invalid_acknowledgement(response, "rejections must be an array when present")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
Result.new(ok: true, status: response.code.to_i, accepted: accepted, rejected: rejected,
|
|
176
|
+
rejections: Array(rejections).first(10))
|
|
177
|
+
rescue JSON::ParserError => error
|
|
178
|
+
invalid_acknowledgement(response, "invalid JSON (#{error.message})")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Ingest can take a whole batch off our hands without storing any of it:
|
|
182
|
+
# a paused or over-quota environment answers 200 with
|
|
183
|
+
# {"accepted":0,"rejected":0,"reason":"paused"}. That batch IS delivered
|
|
184
|
+
# -- the platform decided its fate -- so retrying it would burn eight
|
|
185
|
+
# attempts and drop the records anyway. Any acknowledgement carrying a
|
|
186
|
+
# `reason`, and any all-zero acknowledgement, drains the batch.
|
|
187
|
+
def drained?(data, accepted, rejected)
|
|
188
|
+
data.key?("reason") || (accepted.zero? && rejected.zero?)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A proxy-generated 2xx page or a contract mismatch cannot acknowledge
|
|
192
|
+
# the submitted records. Keep the batch for Reporter retry instead of
|
|
193
|
+
# silently treating it as delivered.
|
|
194
|
+
def invalid_acknowledgement(response, detail)
|
|
195
|
+
Result.new(ok: false, status: response.code.to_i, error: "invalid ingest acknowledgement: #{detail}",
|
|
196
|
+
retryable_error: true)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def apply_status_policy(result)
|
|
200
|
+
case result.status
|
|
201
|
+
when UNAUTHORIZED_STATUS
|
|
202
|
+
@unauthorized = true
|
|
203
|
+
Railwatch.debug { "ingest returned 401 -- marking transport unauthorized, no further flushes will be attempted" }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|