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.
@@ -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
- Result = Struct.new(:ok, :status, :accepted, :rejected, :rejections, :error, :retryable_error, keyword_init: true) do
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 @config.ingest_url_allowed?
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
- body, sent, over_cap, over_cap_bytes = encode(records)
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 @config.ingest_url_allowed?
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
- # 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 ]
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
- req["X-Railwatch-Version"] = Railwatch::VERSION
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.to_s[0, 200])
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
- data = JSON.parse(response.body)
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
- unless drained?(data, accepted, rejected) || accepted + rejected == expected_count
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
- 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?)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.2
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