railwatch 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/app/models/railwatch/ingest/batch.rb +50 -1
- data/app/models/railwatch/telemetry/export_delivery.rb +32 -0
- data/app/models/railwatch/telemetry/export_destination.rb +66 -0
- data/db/railwatch_telemetry_migrate/20260919000000_create_export_queue.rb +108 -0
- data/docs/embedded.md +30 -0
- data/lib/railwatch/configuration.rb +61 -1
- data/lib/railwatch/engine.rb +11 -0
- data/lib/railwatch/export/client.rb +117 -0
- data/lib/railwatch/export/lease.rb +56 -0
- data/lib/railwatch/export/outbox.rb +361 -0
- data/lib/railwatch/export/policy.rb +79 -0
- data/lib/railwatch/export/sender.rb +130 -0
- data/lib/railwatch/maintenance.rb +11 -0
- data/lib/railwatch/reporter.rb +4 -1
- data/lib/railwatch/transport/http.rb +127 -53
- data/lib/railwatch/transport/wire_encoder.rb +60 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +7 -0
- data/lib/tasks/railwatch_tasks.rake +36 -0
- metadata +10 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "time"
|
|
4
5
|
require "openssl"
|
|
5
6
|
require "zlib"
|
|
6
7
|
require "json"
|
|
@@ -15,20 +16,34 @@ module Railwatch
|
|
|
15
16
|
RETRYABLE_STATUSES = [ 402, 408, 429 ].freeze
|
|
16
17
|
UNAUTHORIZED_STATUS = 401
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
# `reason` and `retry_after_at` are what the receiver said about this
|
|
20
|
+
# delivery beyond its counts: why it was not stored, and when to come
|
|
21
|
+
# back. They are carried rather than discarded so a caller with durable
|
|
22
|
+
# storage can wait instead of guessing.
|
|
23
|
+
Result = Struct.new(:ok, :status, :accepted, :rejected, :rejections, :error, :retryable_error,
|
|
24
|
+
:reason, :retry_after_at, :disposition, :ack_disposition, keyword_init: true) do
|
|
19
25
|
def retryable?
|
|
26
|
+
# A permanent failure says so outright: without this, its absent
|
|
27
|
+
# status would read as "no response yet", which is retryable.
|
|
28
|
+
return false if disposition == :permanent
|
|
29
|
+
|
|
20
30
|
!ok && (retryable_error || Http.retryable_status?(status))
|
|
21
31
|
end
|
|
32
|
+
|
|
33
|
+
# The receiver took the batch off our hands without storing it: a
|
|
34
|
+
# paused or over-quota environment. Not a failure, and not storage.
|
|
35
|
+
def deferred? = disposition == :deferred
|
|
22
36
|
end
|
|
23
37
|
|
|
24
38
|
def self.retryable_status?(status)
|
|
25
39
|
status.nil? || RETRYABLE_STATUSES.include?(status) || (500..599).cover?(status)
|
|
26
40
|
end
|
|
27
41
|
|
|
28
|
-
def initialize(config)
|
|
42
|
+
def initialize(config, endpoint: nil)
|
|
29
43
|
@config = config
|
|
30
|
-
@uri = URI.join(config.ingest_url, "/ingest")
|
|
44
|
+
@uri = endpoint ? URI.parse(endpoint) : URI.join(config.ingest_url, "/ingest")
|
|
31
45
|
@unauthorized = false
|
|
46
|
+
@encoder = WireEncoder.new(batch_bytes: config.batch_bytes)
|
|
32
47
|
end
|
|
33
48
|
|
|
34
49
|
def unauthorized?
|
|
@@ -43,12 +58,16 @@ module Railwatch
|
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: SecureRandom.uuid)
|
|
46
|
-
unless
|
|
61
|
+
unless destination_allowed?
|
|
47
62
|
return Result.new(ok: false, error: "plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true")
|
|
48
63
|
end
|
|
49
64
|
return Result.new(ok: false, status: UNAUTHORIZED_STATUS, error: "unauthorized, flushing stopped") if @unauthorized
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
encoded = @encoder.encode(records)
|
|
67
|
+
body = encoded.body
|
|
68
|
+
sent = encoded.sent
|
|
69
|
+
over_cap = encoded.over_cap
|
|
70
|
+
over_cap_bytes = encoded.over_cap_bytes
|
|
52
71
|
if over_cap.positive?
|
|
53
72
|
# Not a delivery failure: a batch this large will be exactly as
|
|
54
73
|
# large on every retry, so raising a retryable error here would burn
|
|
@@ -73,8 +92,33 @@ module Railwatch
|
|
|
73
92
|
end
|
|
74
93
|
end
|
|
75
94
|
|
|
95
|
+
# Sends bytes that were encoded earlier and stored. Exactly one attempt:
|
|
96
|
+
# the caller owns a durable queue and its own retry schedule, and
|
|
97
|
+
# multiplying two ladders together would turn one backoff into sixty-four.
|
|
98
|
+
def deliver_encoded(body:, expected_count:, batch_id:, headers: {}, dropped: 0, dropped_bytes: 0,
|
|
99
|
+
backpressure_factor: 1.0, gem_version: Railwatch::VERSION)
|
|
100
|
+
unless destination_allowed?
|
|
101
|
+
return permanent("plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true")
|
|
102
|
+
end
|
|
103
|
+
# The same latch deliver honours. A caller with its own queue would
|
|
104
|
+
# otherwise keep presenting a token the receiver has already refused.
|
|
105
|
+
return permanent("unauthorized, flushing stopped", status: UNAUTHORIZED_STATUS) if @unauthorized
|
|
106
|
+
|
|
107
|
+
response = post(body, dropped, dropped_bytes, backpressure_factor, batch_id,
|
|
108
|
+
headers: headers, gem_version: gem_version)
|
|
109
|
+
result = parse(response, expected_count: expected_count)
|
|
110
|
+
apply_status_policy(result)
|
|
111
|
+
result
|
|
112
|
+
rescue ArgumentError, URI::Error, TypeError, NoMethodError => e
|
|
113
|
+
# Bad input or bad configuration, not a bad network. Retrying the same
|
|
114
|
+
# stored bytes cannot fix it, and a durable queue would retry forever.
|
|
115
|
+
permanent("#{e.class}: #{e.message}")
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
Result.new(ok: false, error: "#{e.class}: #{e.message}", retryable_error: true)
|
|
118
|
+
end
|
|
119
|
+
|
|
76
120
|
def ping
|
|
77
|
-
return false unless
|
|
121
|
+
return false unless destination_allowed?
|
|
78
122
|
|
|
79
123
|
response = request(Net::HTTP::Get.new(URI.join(@config.ingest_url, "/ingest/ping")))
|
|
80
124
|
response.is_a?(Net::HTTPSuccess)
|
|
@@ -84,37 +128,19 @@ module Railwatch
|
|
|
84
128
|
|
|
85
129
|
private
|
|
86
130
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
def
|
|
93
|
-
|
|
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 ]
|
|
131
|
+
def destination_allowed? = @config.url_allowed?(@uri)
|
|
132
|
+
|
|
133
|
+
# A failure the caller must not retry: nothing about repeating it can
|
|
134
|
+
# change the outcome. `retryable?` treats a nil status as transient, so
|
|
135
|
+
# these say so explicitly.
|
|
136
|
+
def permanent(error, status: nil)
|
|
137
|
+
Result.new(ok: false, status: status, error: error, retryable_error: false, disposition: :permanent)
|
|
114
138
|
end
|
|
115
139
|
|
|
116
|
-
def post(body, dropped, dropped_bytes, backpressure_factor, batch_id
|
|
140
|
+
def post(body, dropped, dropped_bytes, backpressure_factor, batch_id, headers: {},
|
|
141
|
+
gem_version: Railwatch::VERSION)
|
|
117
142
|
req = Net::HTTP::Post.new(@uri)
|
|
143
|
+
headers.each { |name, value| req[name] = value.to_s }
|
|
118
144
|
req["Content-Type"] = "application/x-ndjson"
|
|
119
145
|
req["Content-Encoding"] = "gzip"
|
|
120
146
|
req["X-Railwatch-Dropped"] = dropped.to_s if dropped.positive?
|
|
@@ -122,7 +148,11 @@ module Railwatch
|
|
|
122
148
|
if backpressure_factor > 1.0
|
|
123
149
|
req["X-Railwatch-Backpressure-Factor"] = backpressure_factor.to_s
|
|
124
150
|
end
|
|
125
|
-
|
|
151
|
+
# The version this payload was built by, which for a stored delivery is
|
|
152
|
+
# not the version running now. The receiver digests this header, so
|
|
153
|
+
# sending today's value would turn an upgrade into a conflict and
|
|
154
|
+
# destroy a delivery it had already accepted.
|
|
155
|
+
req["X-Railwatch-Version"] = gem_version
|
|
126
156
|
req["X-Railwatch-Batch-Id"] = batch_id
|
|
127
157
|
req.body = body
|
|
128
158
|
request(req)
|
|
@@ -135,7 +165,10 @@ module Railwatch
|
|
|
135
165
|
use_ssl: @uri.scheme == "https",
|
|
136
166
|
open_timeout: @config.connect_timeout,
|
|
137
167
|
read_timeout: @config.timeout,
|
|
138
|
-
write_timeout: @config.timeout
|
|
168
|
+
write_timeout: @config.timeout,
|
|
169
|
+
# POST is not in Net::HTTP's idempotent retry set, but say so:
|
|
170
|
+
# a caller with its own queue must be able to trust "one attempt".
|
|
171
|
+
max_retries: 0
|
|
139
172
|
}
|
|
140
173
|
# Net::HTTP currently defaults HTTPS clients to VERIFY_PEER. Set it
|
|
141
174
|
# explicitly so a Ruby default change cannot silently weaken ingest.
|
|
@@ -145,16 +178,59 @@ module Railwatch
|
|
|
145
178
|
end
|
|
146
179
|
end
|
|
147
180
|
|
|
181
|
+
# A receiver in trouble can answer with something enormous -- a proxy
|
|
182
|
+
# error page, a stack trace. Only ever look at the first slice of it.
|
|
183
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
184
|
+
# The longest delay we will take from a receiver, in either spelling.
|
|
185
|
+
MAX_RETRY_AFTER = 86_400
|
|
186
|
+
|
|
148
187
|
def parse(response, expected_count:)
|
|
149
188
|
if response.is_a?(Net::HTTPSuccess)
|
|
150
189
|
parse_acknowledgement(response, expected_count)
|
|
151
190
|
else
|
|
152
|
-
Result.new(ok: false, status: response.code.to_i, error: response.body
|
|
191
|
+
Result.new(ok: false, status: response.code.to_i, error: summarize(response.body),
|
|
192
|
+
retry_after_at: retry_after_at(response))
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Bounds what we keep and log, not what Net::HTTP already read off the
|
|
197
|
+
# socket -- it buffers the whole response before we ever see it.
|
|
198
|
+
def summarize(body) = body.to_s.byteslice(0, 200).to_s.scrub
|
|
199
|
+
|
|
200
|
+
# Seconds, or an HTTP date. Anything else is not a delay we can trust,
|
|
201
|
+
# so the caller falls back to its own backoff rather than a guess.
|
|
202
|
+
def retry_after_at(response)
|
|
203
|
+
raw = response["Retry-After"].to_s.strip
|
|
204
|
+
return nil if raw.empty?
|
|
205
|
+
|
|
206
|
+
now = Time.now
|
|
207
|
+
if raw.match?(/\A\d{1,7}\z/)
|
|
208
|
+
seconds = raw.to_i
|
|
209
|
+
seconds <= MAX_RETRY_AFTER ? now + seconds : nil
|
|
210
|
+
else
|
|
211
|
+
parsed = begin
|
|
212
|
+
Time.httpdate(raw)
|
|
213
|
+
rescue ArgumentError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
return nil unless parsed
|
|
217
|
+
# One second of slack for whole-second HTTP-date precision, and the
|
|
218
|
+
# same ceiling the numeric form gets: a far-future date must not
|
|
219
|
+
# park a delivery for years.
|
|
220
|
+
parsed.between?(now - 1, now + MAX_RETRY_AFTER) ? parsed : nil
|
|
153
221
|
end
|
|
154
222
|
end
|
|
155
223
|
|
|
156
224
|
def parse_acknowledgement(response, expected_count)
|
|
157
|
-
|
|
225
|
+
body = response.body.to_s
|
|
226
|
+
# Refused whole rather than parsed in part: truncating first would let
|
|
227
|
+
# a padded prefix parse as a complete document, and would reject a
|
|
228
|
+
# large but valid acknowledgement as malformed JSON.
|
|
229
|
+
if body.bytesize > MAX_RESPONSE_BYTES
|
|
230
|
+
return invalid_acknowledgement(response, "acknowledgement larger than #{MAX_RESPONSE_BYTES} bytes")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
data = JSON.parse(body)
|
|
158
234
|
return invalid_acknowledgement(response, "response must be a JSON object") unless data.is_a?(Hash)
|
|
159
235
|
|
|
160
236
|
accepted = data["accepted"]
|
|
@@ -162,7 +238,8 @@ module Railwatch
|
|
|
162
238
|
unless accepted.is_a?(Integer) && accepted >= 0 && rejected.is_a?(Integer) && rejected >= 0
|
|
163
239
|
return invalid_acknowledgement(response, "accepted and rejected must be non-negative integers")
|
|
164
240
|
end
|
|
165
|
-
|
|
241
|
+
reason = data["reason"].is_a?(String) ? data["reason"][0, 64] : nil
|
|
242
|
+
if reason.nil? && accepted + rejected != expected_count
|
|
166
243
|
return invalid_acknowledgement(response,
|
|
167
244
|
"accepted + rejected was #{accepted + rejected}, expected #{expected_count}")
|
|
168
245
|
end
|
|
@@ -173,27 +250,24 @@ module Railwatch
|
|
|
173
250
|
end
|
|
174
251
|
|
|
175
252
|
Result.new(ok: true, status: response.code.to_i, accepted: accepted, rejected: rejected,
|
|
176
|
-
rejections: Array(rejections).first(10)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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?)
|
|
253
|
+
rejections: Array(rejections).first(10), reason: reason,
|
|
254
|
+
retry_after_at: retry_after_at(response),
|
|
255
|
+
disposition: reason ? :deferred : :stored,
|
|
256
|
+
ack_disposition: data["disposition"].is_a?(String) ? data["disposition"][0, 32] : nil)
|
|
257
|
+
rescue JSON::ParserError
|
|
258
|
+
# The parser's message quotes the document, which may be a proxy page
|
|
259
|
+
# echoing the request. Say what happened, not what it contained.
|
|
260
|
+
invalid_acknowledgement(response, "invalid JSON")
|
|
189
261
|
end
|
|
190
262
|
|
|
191
263
|
# A proxy-generated 2xx page or a contract mismatch cannot acknowledge
|
|
192
264
|
# the submitted records. Keep the batch for Reporter retry instead of
|
|
193
265
|
# silently treating it as delivered.
|
|
194
266
|
def invalid_acknowledgement(response, detail)
|
|
267
|
+
# Keep the delay even though we could not read the rest: a receiver
|
|
268
|
+
# asking for room still means it, whatever state its body was in.
|
|
195
269
|
Result.new(ok: false, status: response.code.to_i, error: "invalid ingest acknowledgement: #{detail}",
|
|
196
|
-
retryable_error: true)
|
|
270
|
+
retryable_error: true, retry_after_at: retry_after_at(response))
|
|
197
271
|
end
|
|
198
272
|
|
|
199
273
|
def apply_status_policy(result)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "json"
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module Railwatch
|
|
9
|
+
module Transport
|
|
10
|
+
# Turns records into the wire body: gzipped NDJSON, one record per line.
|
|
11
|
+
#
|
|
12
|
+
# Lifted out of Transport::Http so a durable queue can hold the encoded
|
|
13
|
+
# bytes and send them later without re-encoding. A retry has to be the
|
|
14
|
+
# same delivery, which means the same bytes, which means encoding is
|
|
15
|
+
# something you do once and keep -- not something you redo per attempt.
|
|
16
|
+
class WireEncoder
|
|
17
|
+
# What one encode produced. `over_cap` records did not fit and were
|
|
18
|
+
# left out; the caller counts them as dropped rather than growing the
|
|
19
|
+
# request without limit, since they would not fit on a retry either.
|
|
20
|
+
Encoded = Struct.new(:body, :sha256, :sent, :over_cap, :over_cap_bytes, :uncompressed_bytes,
|
|
21
|
+
keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def initialize(batch_bytes:)
|
|
24
|
+
@batch_bytes = batch_bytes
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def encode(records)
|
|
28
|
+
io = StringIO.new
|
|
29
|
+
# mtime 0 so encoding the same records twice on this runtime gives the
|
|
30
|
+
# same bytes, rather than differing by the second they were encoded.
|
|
31
|
+
# It is not a portability guarantee -- zlib builds may deflate
|
|
32
|
+
# identical input differently -- which is why a stored delivery keeps
|
|
33
|
+
# its bytes and its digest rather than re-deriving them later.
|
|
34
|
+
gz = Zlib::GzipWriter.new(io, Zlib::DEFAULT_COMPRESSION, Zlib::DEFAULT_STRATEGY)
|
|
35
|
+
gz.mtime = 0
|
|
36
|
+
bytes = 0
|
|
37
|
+
sent = 0
|
|
38
|
+
over_cap = 0
|
|
39
|
+
over_cap_bytes = 0
|
|
40
|
+
records.each do |record|
|
|
41
|
+
json = JSON.generate(record)
|
|
42
|
+
size = json.bytesize + 1
|
|
43
|
+
if bytes + size > @batch_bytes
|
|
44
|
+
over_cap += 1
|
|
45
|
+
over_cap_bytes += size
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
gz.write(json)
|
|
49
|
+
gz.write("\n")
|
|
50
|
+
bytes += size
|
|
51
|
+
sent += 1
|
|
52
|
+
end
|
|
53
|
+
gz.close
|
|
54
|
+
body = io.string.b
|
|
55
|
+
Encoded.new(body: body, sha256: Digest::SHA256.hexdigest(body), sent: sent, over_cap: over_cap,
|
|
56
|
+
over_cap_bytes: over_cap_bytes, uncompressed_bytes: bytes)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/railwatch/version.rb
CHANGED
data/lib/railwatch.rb
CHANGED
|
@@ -20,6 +20,12 @@ require "railwatch/execution"
|
|
|
20
20
|
require "railwatch/current"
|
|
21
21
|
require "railwatch/record"
|
|
22
22
|
require "railwatch/buffer"
|
|
23
|
+
require "railwatch/transport/wire_encoder"
|
|
24
|
+
require "railwatch/export/policy"
|
|
25
|
+
require "railwatch/export/lease"
|
|
26
|
+
require "railwatch/export/outbox"
|
|
27
|
+
require "railwatch/export/client"
|
|
28
|
+
require "railwatch/export/sender"
|
|
23
29
|
require "railwatch/transport/http"
|
|
24
30
|
require "railwatch/transport/local"
|
|
25
31
|
require "railwatch/transport/socket"
|
|
@@ -103,6 +109,7 @@ module Railwatch
|
|
|
103
109
|
Subscribers::Users.restart_after_fork!
|
|
104
110
|
Subscribers::ProcessInfo.restart_after_fork!
|
|
105
111
|
Health.restart_after_fork!
|
|
112
|
+
Export::Sender.restart_after_fork!
|
|
106
113
|
Sessions.restart_after_fork!
|
|
107
114
|
Maintenance.restart_after_fork!
|
|
108
115
|
end
|
|
@@ -95,6 +95,19 @@ namespace :railwatch do
|
|
|
95
95
|
# the two databases the engine writes to and the jobs that derive
|
|
96
96
|
# rollups and issues from them.
|
|
97
97
|
check.call(true, "transport", "local (telemetry stays in this app; dashboard at the engine mount)")
|
|
98
|
+
# Mirroring is opt-in, so silence means "not asked for". Asked for and
|
|
99
|
+
# not working is the case worth failing on: the operator believes their
|
|
100
|
+
# telemetry is leaving the box and it is not.
|
|
101
|
+
if config.export_enabled
|
|
102
|
+
problem = config.export_problem
|
|
103
|
+
check.call(problem.nil?, "export", problem || "mirroring to #{config.resolved_export_url}", fatal: true)
|
|
104
|
+
if problem.nil?
|
|
105
|
+
status = Railwatch::Export::Outbox.new(config, Railwatch::Environment.current)
|
|
106
|
+
.then { |outbox| Railwatch::Environment.current.with_telemetry { outbox.status } }
|
|
107
|
+
check.call(status[:state] == "ready", "export destination",
|
|
108
|
+
status[:state] == "ready" ? "#{status[:queued_deliveries]} queued (#{status[:queued_bytes]} bytes), producer #{status[:producer_id]}" : "#{status[:state]}: #{status[:reason]} (bin/rails railwatch:export:rebind to clear)")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
98
111
|
%w[railwatch railwatch_telemetry].each do |name|
|
|
99
112
|
configured = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: name)
|
|
100
113
|
check.call(!configured.nil?, "#{name} database",
|
|
@@ -284,6 +297,29 @@ namespace :railwatch do
|
|
|
284
297
|
puts "\nRailwatch is wired up."
|
|
285
298
|
end
|
|
286
299
|
|
|
300
|
+
namespace :export do
|
|
301
|
+
desc "Show what the export queue is holding and whether it can send"
|
|
302
|
+
task status: :environment do
|
|
303
|
+
env = Railwatch::Environment.current
|
|
304
|
+
status = env.with_telemetry { Railwatch::Export::Outbox.new(Railwatch.config, env).status }
|
|
305
|
+
status.each { |key, value| puts "#{key}: #{value}" }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
desc "Clear a credential block, abandoning work admitted under the old token"
|
|
309
|
+
task rebind: :environment do
|
|
310
|
+
env = Railwatch::Environment.current
|
|
311
|
+
discarded = env.with_telemetry { Railwatch::Export::Outbox.new(Railwatch.config, env).rebind! }
|
|
312
|
+
puts discarded ? "rebound; #{discarded} queued deliveries abandoned" : "export is not configured"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
desc "Abandon everything queued for export without contacting the receiver"
|
|
316
|
+
task discard: :environment do
|
|
317
|
+
env = Railwatch::Environment.current
|
|
318
|
+
count = env.with_telemetry { Railwatch::Export::Outbox.new(Railwatch.config, env).discard_all! }
|
|
319
|
+
puts "#{count} queued deliveries abandoned"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
287
323
|
desc "Print where to create an ingest token for this app's Railwatch platform"
|
|
288
324
|
task token: :environment do
|
|
289
325
|
base = Railwatch::Endpoints.new(Railwatch.config)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: railwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole Robertson
|
|
@@ -173,6 +173,8 @@ files:
|
|
|
173
173
|
- app/models/railwatch/telemetry/enqueued_job.rb
|
|
174
174
|
- app/models/railwatch/telemetry/exception.rb
|
|
175
175
|
- app/models/railwatch/telemetry/execution.rb
|
|
176
|
+
- app/models/railwatch/telemetry/export_delivery.rb
|
|
177
|
+
- app/models/railwatch/telemetry/export_destination.rb
|
|
176
178
|
- app/models/railwatch/telemetry/health_sample.rb
|
|
177
179
|
- app/models/railwatch/telemetry/ingest_batch.rb
|
|
178
180
|
- app/models/railwatch/telemetry/llm_call.rb
|
|
@@ -230,6 +232,7 @@ files:
|
|
|
230
232
|
- db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb
|
|
231
233
|
- db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb
|
|
232
234
|
- db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb
|
|
235
|
+
- db/railwatch_telemetry_migrate/20260919000000_create_export_queue.rb
|
|
233
236
|
- docs/ai-and-mcp.md
|
|
234
237
|
- docs/configuration.md
|
|
235
238
|
- docs/embedded.md
|
|
@@ -263,6 +266,11 @@ files:
|
|
|
263
266
|
- lib/railwatch/embedded.rb
|
|
264
267
|
- lib/railwatch/engine.rb
|
|
265
268
|
- lib/railwatch/execution.rb
|
|
269
|
+
- lib/railwatch/export/client.rb
|
|
270
|
+
- lib/railwatch/export/lease.rb
|
|
271
|
+
- lib/railwatch/export/outbox.rb
|
|
272
|
+
- lib/railwatch/export/policy.rb
|
|
273
|
+
- lib/railwatch/export/sender.rb
|
|
266
274
|
- lib/railwatch/faraday.rb
|
|
267
275
|
- lib/railwatch/health.rb
|
|
268
276
|
- lib/railwatch/ingest_request_body_limit.rb
|
|
@@ -308,6 +316,7 @@ files:
|
|
|
308
316
|
- lib/railwatch/transport/http.rb
|
|
309
317
|
- lib/railwatch/transport/local.rb
|
|
310
318
|
- lib/railwatch/transport/socket.rb
|
|
319
|
+
- lib/railwatch/transport/wire_encoder.rb
|
|
311
320
|
- lib/railwatch/version.rb
|
|
312
321
|
- lib/railwatch/writer.rb
|
|
313
322
|
- lib/tasks/railwatch_tasks.rake
|