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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ace6fc321d145d2e579b808a8ce2813b23bc85060d320f39dde2586da8c7db72
|
|
4
|
+
data.tar.gz: 545154019cd93d2d3e605da2b5edf7318265d5c9b43c6e086b514da06a0fe43d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20299db14a1865dcff87eddb613254b2fe83fc7ffc67f55afff8dd4b08e5c4593ee11b6082a4d57a9a7777045c4d5e3fa5df34c5c103bca008fde2bbe2663475
|
|
7
|
+
data.tar.gz: f1105cf854e7b671aeb72b268959e49207101d09afb97f40ac90715d61739562fb2d494f2c21c443f09a86c49f5e2019af998848394c9d368b2ad0c2e602e0aa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
- An embedded install can now also mirror its telemetry to Railwatch Cloud,
|
|
6
|
+
or to any receiver speaking the same protocol. It is off unless you ask
|
|
7
|
+
for it: `c.export_enabled = true` (or `RAILWATCH_EXPORT_ENABLED=true`),
|
|
8
|
+
reusing the token and ingest URL you already have. A token being present
|
|
9
|
+
is not consent -- an embedded install that has one configured still sends
|
|
10
|
+
nothing.
|
|
11
|
+
|
|
12
|
+
Records are held in a durable queue in your own telemetry database,
|
|
13
|
+
admitted in the same transaction as the rows they mirror, and sent by one
|
|
14
|
+
leased thread. A delivery keeps the exact bytes it will send until the
|
|
15
|
+
receiver acknowledges it, so a retry is the same delivery rather than a
|
|
16
|
+
second one; the receiver recognises repeats and answers with the original
|
|
17
|
+
counts. Local capture never waits on the network, and a queue that cannot
|
|
18
|
+
drain sheds rather than growing without limit. `railwatch:export:status`
|
|
19
|
+
shows what is queued; `railwatch:doctor` reports export health, and fails
|
|
20
|
+
if you asked for mirroring and it cannot work.
|
|
21
|
+
|
|
22
|
+
Embedded and cloud installs are otherwise unchanged: same install, same
|
|
23
|
+
records, same dashboard. See docs/embedded.md.
|
|
24
|
+
|
|
25
|
+
- Ingest acknowledgements are read more carefully. An all-zero
|
|
26
|
+
acknowledgement with no reason no longer counts as a successful delivery
|
|
27
|
+
for a non-empty batch -- nothing legitimate answers a 500-record batch
|
|
28
|
+
that way, but a proxy error page does, and those were being treated as
|
|
29
|
+
stored. A paused or over-quota environment still takes the batch rather
|
|
30
|
+
than burning the retry ladder, and now says so rather than looking like
|
|
31
|
+
storage.
|
|
32
|
+
|
|
3
33
|
## 0.2.2 (2026-09-18)
|
|
4
34
|
|
|
5
35
|
- README: describe embedded mode. The gem has had two destinations since
|
|
@@ -38,6 +38,7 @@ module Railwatch
|
|
|
38
38
|
@session_buckets = Set.new
|
|
39
39
|
@bucket_cache = {}
|
|
40
40
|
@exception_ids = []
|
|
41
|
+
@export = nil
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# The ledger row for a batch id that has already been written, or nil.
|
|
@@ -48,6 +49,10 @@ module Railwatch
|
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def write!
|
|
52
|
+
# Prepared before mapping and outside the transaction: mirroring sends
|
|
53
|
+
# what we were given, not what we kept, and encoding is not something
|
|
54
|
+
# to do while holding the write lock.
|
|
55
|
+
prepare_export
|
|
51
56
|
map_all
|
|
52
57
|
log_truncations
|
|
53
58
|
accepted = 0
|
|
@@ -65,7 +70,8 @@ module Railwatch
|
|
|
65
70
|
dropped_by_client: @dropped_by_client, backpressure_factor: @backpressure_factor,
|
|
66
71
|
bytes: @bytes, gem_version: @gem_version,
|
|
67
72
|
counts_by_type: @counts, rejections: @rejections.first(20),
|
|
68
|
-
batch_id: @batch_id, followups: followups
|
|
73
|
+
batch_id: @batch_id, followups: followups,
|
|
74
|
+
**export_columns)
|
|
69
75
|
end
|
|
70
76
|
end
|
|
71
77
|
end
|
|
@@ -76,6 +82,9 @@ module Railwatch
|
|
|
76
82
|
@environment.count_events!(accepted)
|
|
77
83
|
end
|
|
78
84
|
enqueue_followups
|
|
85
|
+
# After the transaction: the sender must never find a delivery that
|
|
86
|
+
# has not committed yet.
|
|
87
|
+
Export::Sender.wake! if @queued
|
|
79
88
|
broadcast_live
|
|
80
89
|
Result.new(accepted: accepted, rejected: @rejections.size, rejections: @rejections)
|
|
81
90
|
end
|
|
@@ -94,6 +103,46 @@ module Railwatch
|
|
|
94
103
|
|
|
95
104
|
private
|
|
96
105
|
|
|
106
|
+
# Mirroring, when this install has been told to. Off is the whole of the
|
|
107
|
+
# cost: no encode, no query, no row, one boolean.
|
|
108
|
+
def prepare_export
|
|
109
|
+
return unless @embedded && Railwatch.config.export?
|
|
110
|
+
|
|
111
|
+
@export = Export::Outbox.new(Railwatch.config, @environment)
|
|
112
|
+
@selections = Export::Policy.fetch(Railwatch.config.export_policy).prepare(
|
|
113
|
+
records: @records, encoder: Transport::WireEncoder.new(batch_bytes: Railwatch.config.batch_bytes),
|
|
114
|
+
source_batch_id: @batch_id || SecureRandom.uuid,
|
|
115
|
+
metadata: { "dropped" => @dropped_by_client, "backpressure_factor" => @backpressure_factor.to_s }
|
|
116
|
+
)
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
# A batch must still be stored when mirroring cannot be prepared.
|
|
119
|
+
Railwatch.debug { "export preparation failed: #{e.class}: #{e.message}" }
|
|
120
|
+
@export = nil
|
|
121
|
+
@export_error = "shed_encoding"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Runs inside the batch's own transaction, so the rows and the intent to
|
|
125
|
+
# mirror them commit together or not at all.
|
|
126
|
+
# In a savepoint, so a failure here sheds the mirroring and nothing
|
|
127
|
+
# else. Without it an export table that is missing -- a gem upgraded
|
|
128
|
+
# without db:prepare, with export already on -- would roll back the
|
|
129
|
+
# batch that carried it, and every batch after, until nothing was being
|
|
130
|
+
# recorded at all. Monitoring must degrade to local-only rather than
|
|
131
|
+
# stop.
|
|
132
|
+
def export_columns
|
|
133
|
+
return { export_disposition: @export_error } if @export_error
|
|
134
|
+
return {} unless @export
|
|
135
|
+
|
|
136
|
+
admission = TelemetryRecord.transaction(requires_new: true) do
|
|
137
|
+
@export.enqueue!(@selections, now: @received_at)
|
|
138
|
+
end
|
|
139
|
+
@queued = admission.disposition == "queued"
|
|
140
|
+
{ export_disposition: admission.disposition, export_record_count: admission.record_count }
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
Railwatch.debug { "export admission failed: #{e.class}: #{e.message}" }
|
|
143
|
+
{ export_disposition: "shed_error" }
|
|
144
|
+
end
|
|
145
|
+
|
|
97
146
|
ROLLED_UP = %w[request job_attempt scheduled_task command channel_action query outgoing_request cache_event mail visit notification span llm_call].freeze
|
|
98
147
|
MAX_PAST_AGE = 30.days
|
|
99
148
|
MAX_FUTURE_AGE = 1.hour
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# One immutable delivery waiting to be sent, or the record of one that no
|
|
6
|
+
# longer is. The body is the exact bytes that will go on the wire, kept
|
|
7
|
+
# until the delivery reaches a terminal state and then freed: a retry has
|
|
8
|
+
# to be the same delivery, and the receiver recognises it by those bytes.
|
|
9
|
+
class ExportDelivery < TelemetryRecord
|
|
10
|
+
DISPOSITIONS = %w[acked rejected expired discarded].freeze
|
|
11
|
+
|
|
12
|
+
belongs_to :export_destination
|
|
13
|
+
|
|
14
|
+
scope :live, -> { where.not(state: "done") }
|
|
15
|
+
scope :due, ->(now = Time.current) { live.where(state: "pending").where(next_attempt_at: ..now) }
|
|
16
|
+
scope :overdue, ->(now = Time.current) { live.where(expires_at: ...now) }
|
|
17
|
+
scope :oldest_first, -> { order(:id) }
|
|
18
|
+
|
|
19
|
+
def live? = state != "done"
|
|
20
|
+
|
|
21
|
+
def sending? = state == "sending"
|
|
22
|
+
|
|
23
|
+
# True when this claim is still the one allowed to finish the delivery.
|
|
24
|
+
# A sender that stalled past its lease may wake and complete its request
|
|
25
|
+
# anyway; the receiver's receipt makes that harmless, but it must not be
|
|
26
|
+
# able to overwrite what the new owner has since recorded.
|
|
27
|
+
def held_by?(token, generation)
|
|
28
|
+
sending? && claim_token == token && claim_generation == generation
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Where this database mirrors its telemetry, and everything durable about
|
|
6
|
+
# that relationship: who we are to the receiver, which token we were bound
|
|
7
|
+
# with, whether it is currently taking deliveries, and who holds the lease
|
|
8
|
+
# to send them.
|
|
9
|
+
class ExportDestination < TelemetryRecord
|
|
10
|
+
STATES = %w[ready deferred unauthorized inactive].freeze
|
|
11
|
+
COUNTERS = %w[acked rejected expired discarded shed].freeze
|
|
12
|
+
|
|
13
|
+
has_many :export_deliveries, dependent: :delete_all
|
|
14
|
+
|
|
15
|
+
validates :state, inclusion: { in: STATES }
|
|
16
|
+
|
|
17
|
+
def self.digest(value) = Digest::SHA256.hexdigest(value.to_s)
|
|
18
|
+
|
|
19
|
+
# The binding for this url and token. A different token for the same url
|
|
20
|
+
# is a different binding decision, not a silent rebind: queued bytes
|
|
21
|
+
# were admitted under the old credential and must not follow the new one
|
|
22
|
+
# to whatever tenant it belongs to.
|
|
23
|
+
def self.bind!(url:, token:, now: Time.current, retried: false)
|
|
24
|
+
row = find_or_initialize_by(url_sha256: digest(url))
|
|
25
|
+
row.url = url
|
|
26
|
+
row.producer_id ||= SecureRandom.uuid
|
|
27
|
+
fingerprint = digest(token)
|
|
28
|
+
if row.persisted? && row.credential_sha256 != fingerprint
|
|
29
|
+
row.update!(credential_sha256: fingerprint, state: "unauthorized", reason: "credential_changed",
|
|
30
|
+
retry_at: nil)
|
|
31
|
+
else
|
|
32
|
+
row.credential_sha256 = fingerprint
|
|
33
|
+
row.created_at ||= now
|
|
34
|
+
row.save!
|
|
35
|
+
end
|
|
36
|
+
row
|
|
37
|
+
rescue ActiveRecord::RecordNotUnique
|
|
38
|
+
# Two processes binding for the first time at once. One row wins; the
|
|
39
|
+
# loser wants that row, not an error.
|
|
40
|
+
raise if retried
|
|
41
|
+
|
|
42
|
+
bind!(url: url, token: token, now: now, retried: true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A pause the receiver asked for ends when it said it would. Only a
|
|
46
|
+
# credential problem needs a person: everything else is a delay, and a
|
|
47
|
+
# delay that never ends is an outage we caused ourselves.
|
|
48
|
+
# Needs a person before anything can move again: a credential that was
|
|
49
|
+
# changed, or a destination taken out of service. Queueing into one
|
|
50
|
+
# just fills it with work that rebind! will throw away.
|
|
51
|
+
def blocked? = %w[unauthorized inactive].include?(state)
|
|
52
|
+
|
|
53
|
+
def sendable?(now: Time.current)
|
|
54
|
+
return false unless %w[ready deferred].include?(state)
|
|
55
|
+
|
|
56
|
+
retry_at.nil? || retry_at <= now
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bump!(counter, by = 1)
|
|
60
|
+
return unless COUNTERS.include?(counter.to_s)
|
|
61
|
+
|
|
62
|
+
self.counters = counters.merge(counter.to_s => counters.fetch(counter.to_s, 0) + by)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The durable queue an embedded install uses to mirror its telemetry to a
|
|
4
|
+
# remote receiver.
|
|
5
|
+
#
|
|
6
|
+
# Why the bodies are stored rather than re-derived from the rows next door:
|
|
7
|
+
# the rows are not a reversible copy of what was sent. Mapping renames and
|
|
8
|
+
# drops fields, caps strings, swaps a query's SQL for a shape reference and
|
|
9
|
+
# coalesces people; and ingest rejects records the receiver may well accept,
|
|
10
|
+
# so sending only what we kept would not be the same telemetry. A delivery
|
|
11
|
+
# therefore keeps the exact bytes it will send, and frees them the moment it
|
|
12
|
+
# reaches a terminal state -- the cost tracks what is unacknowledged, not what
|
|
13
|
+
# is stored.
|
|
14
|
+
#
|
|
15
|
+
# Nothing here is written unless export is explicitly enabled.
|
|
16
|
+
class CreateExportQueue < ActiveRecord::Migration[8.1]
|
|
17
|
+
def change
|
|
18
|
+
# One row per destination this database has ever been pointed at.
|
|
19
|
+
create_table :export_destinations do |t|
|
|
20
|
+
t.string :url, null: false
|
|
21
|
+
t.string :url_sha256, limit: 64, null: false
|
|
22
|
+
# Permanent for this database's lineage: it is how the receiver tells
|
|
23
|
+
# our deliveries from another installation's.
|
|
24
|
+
t.string :producer_id, limit: 36, null: false
|
|
25
|
+
# A fingerprint, never the token. If the token changes, queued bytes
|
|
26
|
+
# must not follow it to whatever tenant the new one belongs to.
|
|
27
|
+
t.string :credential_sha256, limit: 64, null: false
|
|
28
|
+
|
|
29
|
+
t.string :state, limit: 16, null: false, default: "ready"
|
|
30
|
+
t.datetime :retry_at, precision: 6
|
|
31
|
+
t.string :reason, limit: 64
|
|
32
|
+
|
|
33
|
+
t.string :lease_owner, limit: 36
|
|
34
|
+
t.bigint :lease_generation, null: false, default: 0
|
|
35
|
+
t.datetime :lease_expires_at, precision: 6
|
|
36
|
+
|
|
37
|
+
# Live totals, maintained in the same transaction as the rows they
|
|
38
|
+
# describe, so admission can be decided without counting the table.
|
|
39
|
+
t.bigint :queued_bytes, null: false, default: 0
|
|
40
|
+
t.bigint :queued_deliveries, null: false, default: 0
|
|
41
|
+
# Lifetime accounting: acked, rejected, expired, discarded, shed.
|
|
42
|
+
t.json :counters, null: false, default: {}
|
|
43
|
+
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
add_index :export_destinations, :url_sha256, unique: true
|
|
48
|
+
add_index :export_destinations, :producer_id, unique: true
|
|
49
|
+
|
|
50
|
+
create_table :export_deliveries do |t|
|
|
51
|
+
t.references :export_destination, null: false, foreign_key: true
|
|
52
|
+
# A UUIDv7: its embedded time is how the receiver ages it out, so a
|
|
53
|
+
# delivery cannot be made young again by relabelling it.
|
|
54
|
+
t.string :delivery_id, limit: 36, null: false
|
|
55
|
+
# What this delivery is a delivery OF. One per source batch today; a
|
|
56
|
+
# future policy that selects across batches supplies its own key.
|
|
57
|
+
t.string :selection_key, limit: 160, null: false
|
|
58
|
+
|
|
59
|
+
t.binary :body
|
|
60
|
+
t.string :body_sha256, limit: 64, null: false
|
|
61
|
+
t.string :metadata_sha256, limit: 64, null: false
|
|
62
|
+
t.bigint :body_bytes, null: false
|
|
63
|
+
t.bigint :ndjson_bytes, null: false
|
|
64
|
+
t.integer :record_count, null: false
|
|
65
|
+
# Everything about the delivery that is not its body: version, drop
|
|
66
|
+
# counts, backpressure. Digested into metadata_sha256 so the same id
|
|
67
|
+
# arriving with different counts is a conflict, not an update.
|
|
68
|
+
t.json :wire_metadata, null: false, default: {}
|
|
69
|
+
|
|
70
|
+
t.string :state, limit: 8, null: false, default: "pending"
|
|
71
|
+
# Set only when done: acked, rejected, expired, discarded.
|
|
72
|
+
t.string :disposition, limit: 16
|
|
73
|
+
t.datetime :enqueued_at, null: false, precision: 6
|
|
74
|
+
t.datetime :expires_at, null: false, precision: 6
|
|
75
|
+
t.datetime :next_attempt_at, null: false, precision: 6
|
|
76
|
+
t.bigint :attempts, null: false, default: 0
|
|
77
|
+
|
|
78
|
+
t.string :claim_token, limit: 36
|
|
79
|
+
t.bigint :claim_generation
|
|
80
|
+
t.datetime :claim_expires_at, precision: 6
|
|
81
|
+
|
|
82
|
+
t.integer :last_status
|
|
83
|
+
t.string :last_reason, limit: 64
|
|
84
|
+
t.json :ack
|
|
85
|
+
t.datetime :finished_at, precision: 6
|
|
86
|
+
|
|
87
|
+
t.timestamps
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
add_index :export_deliveries, %i[export_destination_id delivery_id], unique: true,
|
|
91
|
+
name: "index_export_deliveries_on_destination_and_delivery"
|
|
92
|
+
# One delivery per selection: a batch replayed into the same transaction
|
|
93
|
+
# cannot enqueue itself twice.
|
|
94
|
+
add_index :export_deliveries, %i[export_destination_id selection_key], unique: true,
|
|
95
|
+
name: "index_export_deliveries_on_destination_and_selection"
|
|
96
|
+
add_index :export_deliveries, %i[export_destination_id id], where: "state <> 'done'",
|
|
97
|
+
name: "index_export_deliveries_live"
|
|
98
|
+
add_index :export_deliveries, :expires_at, where: "body IS NOT NULL",
|
|
99
|
+
name: "index_export_deliveries_expiring"
|
|
100
|
+
add_index :export_deliveries, :finished_at, where: "state = 'done'",
|
|
101
|
+
name: "index_export_deliveries_finished"
|
|
102
|
+
|
|
103
|
+
# Why a batch did or did not enqueue anything. Null on every row an
|
|
104
|
+
# install without export ever writes.
|
|
105
|
+
add_column :ingest_batches, :export_disposition, :string, limit: 24
|
|
106
|
+
add_column :ingest_batches, :export_record_count, :bigint
|
|
107
|
+
end
|
|
108
|
+
end
|
data/docs/embedded.md
CHANGED
|
@@ -14,6 +14,36 @@ place for many apps, point the gem at Railwatch Cloud instead
|
|
|
14
14
|
([Getting started](getting-started.md)); the two are switchable with
|
|
15
15
|
one setting.
|
|
16
16
|
|
|
17
|
+
## Three ways to run it
|
|
18
|
+
|
|
19
|
+
Same gem, same install, same records. The only question is where they end
|
|
20
|
+
up:
|
|
21
|
+
|
|
22
|
+
| | Records live | Dashboard |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| **Embedded** | your app's SQLite files | `/railwatch` in your app |
|
|
25
|
+
| **Cloud** | Railwatch Cloud | the hosted one |
|
|
26
|
+
| **Both** | your app's files, *and* Railwatch Cloud | either |
|
|
27
|
+
|
|
28
|
+
Embedded is `c.transport = :local`, which is what `--local` writes.
|
|
29
|
+
Cloud is the default. "Both" is embedded plus one more line:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
c.export_enabled = true # or RAILWATCH_EXPORT_ENABLED=true
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It reuses the token and ingest URL you already have, so an install that
|
|
36
|
+
was pointed at the cloud and moved to embedded needs nothing else to send
|
|
37
|
+
to both. Everything captured locally is mirrored — the same records the
|
|
38
|
+
same install would have sent had you chosen the cloud — so the hosted
|
|
39
|
+
dashboard is as complete as it would be either way.
|
|
40
|
+
|
|
41
|
+
It is off unless you set that flag. A token being present is not consent:
|
|
42
|
+
an embedded install that has one configured still sends nothing.
|
|
43
|
+
`railwatch:doctor` says nothing about export until you ask for it, and
|
|
44
|
+
fails loudly if you ask for it and it cannot work. `railwatch:export:status`
|
|
45
|
+
shows what is queued.
|
|
46
|
+
|
|
17
47
|
## Install
|
|
18
48
|
|
|
19
49
|
```sh
|
|
@@ -65,6 +65,8 @@ module Railwatch
|
|
|
65
65
|
# "they left the default".
|
|
66
66
|
DEFAULT_BASE_CONTROLLER = "ActionController::Base"
|
|
67
67
|
|
|
68
|
+
attr_accessor :export_enabled, :export_policy, :export_url, :export_token, :export_max_bytes,
|
|
69
|
+
:export_max_deliveries, :export_max_age
|
|
68
70
|
attr_accessor :enabled, :token, :ingest_url, :allow_http, :server, :environment, :transport,
|
|
69
71
|
:issue_prefix, :repository_url, :retention_days, :dashboard_user, :writer_socket,
|
|
70
72
|
:http_basic_auth_enabled, :http_basic_auth_user, :http_basic_auth_password, :base_controller_class,
|
|
@@ -103,6 +105,16 @@ module Railwatch
|
|
|
103
105
|
@repository_url = ENV["RAILWATCH_REPOSITORY_URL"]
|
|
104
106
|
@retention_days = env_int("RAILWATCH_RETENTION_DAYS", 7)
|
|
105
107
|
@dashboard_user = nil
|
|
108
|
+
# Mirroring an embedded install's telemetry to a remote receiver. Off
|
|
109
|
+
# unless asked for: an embedded install's promise is that nothing leaves
|
|
110
|
+
# the machine, and a token being present is not consent.
|
|
111
|
+
@export_enabled = env_bool("RAILWATCH_EXPORT_ENABLED", false)
|
|
112
|
+
@export_policy = ENV.fetch("RAILWATCH_EXPORT_POLICY", "everything").to_sym
|
|
113
|
+
@export_url = ENV["RAILWATCH_EXPORT_URL"]
|
|
114
|
+
@export_token = ENV["RAILWATCH_EXPORT_TOKEN"]
|
|
115
|
+
@export_max_bytes = env_int("RAILWATCH_EXPORT_MAX_BYTES", 256 * 1024 * 1024)
|
|
116
|
+
@export_max_deliveries = env_int("RAILWATCH_EXPORT_MAX_DELIVERIES", 100_000)
|
|
117
|
+
@export_max_age = env_int("RAILWATCH_EXPORT_MAX_AGE_SECONDS", 86_400)
|
|
106
118
|
# Dashboard access, the way Mission Control Jobs does it: HTTP Basic
|
|
107
119
|
# authentication is on and CLOSED by default. With no user and password
|
|
108
120
|
# configured every dashboard request is 401, so an install that forgot
|
|
@@ -300,6 +312,46 @@ module Railwatch
|
|
|
300
312
|
# anything else ships it to ingest_url over HTTPS.
|
|
301
313
|
def local? = transport.to_s == "local"
|
|
302
314
|
|
|
315
|
+
# The receiver admits an unseen delivery for seven days; queueing one for
|
|
316
|
+
# longer cannot help.
|
|
317
|
+
MAX_EXPORT_AGE = 7 * 24 * 60 * 60
|
|
318
|
+
|
|
319
|
+
# Mirroring is a thing an embedded install opts into; it is meaningless
|
|
320
|
+
# for an install that is already sending everything over HTTP.
|
|
321
|
+
def export? = export_enabled && local? && export_problem.nil?
|
|
322
|
+
|
|
323
|
+
# Why export is configured but unusable, or nil when it is fine. The
|
|
324
|
+
# doctor reports this; nothing silently half-enables.
|
|
325
|
+
def export_problem
|
|
326
|
+
return nil unless export_enabled
|
|
327
|
+
return "export needs transport :local; an :http install already sends everything" unless local?
|
|
328
|
+
return "RAILWATCH_EXPORT_POLICY #{export_policy} is not implemented" unless export_policy.to_s == "everything"
|
|
329
|
+
return "no export token: set RAILWATCH_EXPORT_TOKEN or RAILWATCH_TOKEN" if resolved_export_token.to_s.empty?
|
|
330
|
+
return "no export url: set RAILWATCH_EXPORT_URL or RAILWATCH_INGEST_URL" if resolved_export_url.to_s.empty?
|
|
331
|
+
return "export url must be HTTPS (or set RAILWATCH_ALLOW_HTTP=true)" unless url_allowed?(resolved_export_url)
|
|
332
|
+
# Past this a receiver stops recognising a delivery's id, so holding one
|
|
333
|
+
# any longer just means discovering later that it can never be sent.
|
|
334
|
+
if export_max_age > MAX_EXPORT_AGE
|
|
335
|
+
return "RAILWATCH_EXPORT_MAX_AGE_SECONDS cannot exceed #{MAX_EXPORT_AGE} (the receiver stops recognising a delivery past that)"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The receiver, and the credential we are bound to it with. Both fall back
|
|
342
|
+
# to the ordinary ingest settings so switching an install from embedded to
|
|
343
|
+
# cloud needs no second set of values.
|
|
344
|
+
def resolved_export_url
|
|
345
|
+
url = export_url.presence || (ingest_url.presence && URI.join(ingest_url, "/ingest").to_s)
|
|
346
|
+
url&.sub(%r{/\z}, "")
|
|
347
|
+
rescue URI::Error
|
|
348
|
+
# BadURIError (a relative ingest_url) is not an InvalidURIError, and
|
|
349
|
+
# letting it out of here takes the doctor down with it.
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def resolved_export_token = export_token.presence || token
|
|
354
|
+
|
|
303
355
|
# Absolute path of the writer socket, or nil when the writer is off.
|
|
304
356
|
def writer_socket_path
|
|
305
357
|
path = writer_socket.to_s
|
|
@@ -373,7 +425,15 @@ module Railwatch
|
|
|
373
425
|
end
|
|
374
426
|
|
|
375
427
|
def ingest_url_allowed?
|
|
376
|
-
|
|
428
|
+
url_allowed?(ingest_url)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# The same policy, applied to whatever URL is actually about to be
|
|
432
|
+
# requested. A transport pointed at an explicit endpoint must be judged on
|
|
433
|
+
# that endpoint: approving it because some other configured URL happens to
|
|
434
|
+
# be HTTPS would put the token on the wire in plaintext.
|
|
435
|
+
def url_allowed?(url)
|
|
436
|
+
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
377
437
|
return true if uri.scheme == "https"
|
|
378
438
|
return false unless uri.scheme == "http"
|
|
379
439
|
|
data/lib/railwatch/engine.rb
CHANGED
|
@@ -216,6 +216,17 @@ module Railwatch
|
|
|
216
216
|
at_exit { Railwatch::Maintenance.stop! }
|
|
217
217
|
end
|
|
218
218
|
|
|
219
|
+
# Draining the export queue. Same shape and ordering as the others:
|
|
220
|
+
# stopped before the reporter's final flush, so its last claim is
|
|
221
|
+
# released rather than left to expire. Sender.start! is a no-op unless
|
|
222
|
+
# export is configured and usable.
|
|
223
|
+
initializer "railwatch.export" do
|
|
224
|
+
next unless Railwatch.enabled?
|
|
225
|
+
|
|
226
|
+
Railwatch::Export::Sender.start!
|
|
227
|
+
at_exit { Railwatch::Export::Sender.stop! }
|
|
228
|
+
end
|
|
229
|
+
|
|
219
230
|
# lib/tasks/railwatch_tasks.rake is picked up by Rails::Engine's default
|
|
220
231
|
# lib/tasks convention; the rake_tasks block above only installs the
|
|
221
232
|
# Rake::Task patch.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Export
|
|
5
|
+
# Speaks the receipt protocol: names the delivery so the receiver can
|
|
6
|
+
# recognise a repeat of it, and turns the answer into something the queue
|
|
7
|
+
# can act on.
|
|
8
|
+
#
|
|
9
|
+
# It makes one attempt and has no opinion about when to try again. That
|
|
10
|
+
# belongs to the queue, which is the thing with durable storage.
|
|
11
|
+
class Client
|
|
12
|
+
Outcome = Struct.new(:disposition, :status, :reason, :retry_after_at, :ack, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# The queue is bound to a fingerprint of the export credential, so the
|
|
15
|
+
# request has to be made with that credential: authenticating as someone
|
|
16
|
+
# else would deliver these bytes to a tenant that never admitted them.
|
|
17
|
+
def initialize(config)
|
|
18
|
+
@config = config
|
|
19
|
+
@transports = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Not `send`: shadowing Object#send on a class makes it impossible to
|
|
23
|
+
# reach the real one, and reads as a coincidence rather than a verb.
|
|
24
|
+
def deliver(claim, producer_id:)
|
|
25
|
+
result = transport_for(claim).deliver_encoded(
|
|
26
|
+
body: claim.body, expected_count: claim.record_count, batch_id: claim.delivery_id,
|
|
27
|
+
dropped: claim.metadata.fetch("dropped", 0).to_i,
|
|
28
|
+
dropped_bytes: claim.metadata.fetch("dropped_bytes", 0).to_i,
|
|
29
|
+
# Stored with the delivery, not read from the reporter now: this is
|
|
30
|
+
# what the batch was carrying when it was queued.
|
|
31
|
+
backpressure_factor: claim.metadata.fetch("backpressure_factor", 1.0).to_f,
|
|
32
|
+
gem_version: claim.metadata.fetch("version", Railwatch::VERSION),
|
|
33
|
+
headers: headers(claim, producer_id)
|
|
34
|
+
)
|
|
35
|
+
interpret(result)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset_after_fork!
|
|
39
|
+
@transports = {}
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Drops any latch the transports have picked up. After a credential
|
|
44
|
+
# problem is fixed, the next send must be an actual send.
|
|
45
|
+
def reset!
|
|
46
|
+
@transports = {}
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Keyed on the destination this delivery is FOR, not on whatever the
|
|
53
|
+
# configuration says right now. A process reconfigured mid-flight would
|
|
54
|
+
# otherwise post bytes admitted for one receiver to another one -- and,
|
|
55
|
+
# if they belong to different tenants, hand a customer's telemetry to
|
|
56
|
+
# somebody else while marking it delivered.
|
|
57
|
+
def transport_for(claim)
|
|
58
|
+
key = [ claim.url, claim.token_digest ]
|
|
59
|
+
@transports[key] ||= begin
|
|
60
|
+
credentialed = @config.dup
|
|
61
|
+
credentialed.token = @config.resolved_export_token
|
|
62
|
+
Transport::Http.new(credentialed, endpoint: claim.url)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def headers(claim, producer_id)
|
|
67
|
+
{
|
|
68
|
+
"X-Railwatch-Producer-Id" => producer_id,
|
|
69
|
+
"X-Railwatch-Body-SHA256" => Digest::SHA256.hexdigest(claim.body),
|
|
70
|
+
"X-Railwatch-Policy" => claim.metadata.fetch("policy", Policy::Everything::VERSION)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The receiver's answer, reduced to what the queue needs to decide.
|
|
75
|
+
# Anything it cannot read is treated as "not stored": keeping bytes we
|
|
76
|
+
# might not need costs a retry, discarding bytes that never arrived
|
|
77
|
+
# costs the telemetry.
|
|
78
|
+
STORED = %w[committed already_committed].freeze
|
|
79
|
+
REJECTING_STATUSES = [ 400, 409, 410, 413, 422 ].freeze
|
|
80
|
+
|
|
81
|
+
def interpret(result)
|
|
82
|
+
return Outcome.new(disposition: :stored, status: result.status, ack: result.to_h) if stored?(result)
|
|
83
|
+
return Outcome.new(disposition: :rejected, status: result.status, reason: reason_for(result)) if rejected?(result)
|
|
84
|
+
|
|
85
|
+
Outcome.new(disposition: :deferred, status: result.status, reason: reason_for(result),
|
|
86
|
+
retry_after_at: result.retry_after_at)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A 200 that stored the records, or a duplicate the receiver already
|
|
90
|
+
# holds. When the receiver names what it did, we believe the name and
|
|
91
|
+
# not the arithmetic: counts that happen to add up are not a receipt.
|
|
92
|
+
def stored?(result)
|
|
93
|
+
return false unless result.ok && !result.deferred?
|
|
94
|
+
return STORED.include?(result.ack_disposition) if result.ack_disposition
|
|
95
|
+
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Permanently unacceptable TO THE RECEIVER: sending it again cannot
|
|
100
|
+
# change the answer. 409 is a delivery id reused for different bytes,
|
|
101
|
+
# 410 one too old to be recognised.
|
|
102
|
+
#
|
|
103
|
+
# Deliberately not our own refusals. `:permanent` from the transport
|
|
104
|
+
# means we declined to send -- a latched credential failure, a
|
|
105
|
+
# configuration we will not use -- and throwing the bytes away because
|
|
106
|
+
# of something on this side would destroy telemetry that was never
|
|
107
|
+
# offered to anyone.
|
|
108
|
+
def rejected?(result)
|
|
109
|
+
REJECTING_STATUSES.include?(result.status)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def reason_for(result)
|
|
113
|
+
(result.reason || result.error || result.status).to_s[0, 64]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Export
|
|
5
|
+
# Which process is allowed to send, right now.
|
|
6
|
+
#
|
|
7
|
+
# Every eligible process runs a sender; the lease decides which one does
|
|
8
|
+
# anything. It is held for a short time and renewed, so a process that
|
|
9
|
+
# dies holding it blocks the queue for seconds rather than forever.
|
|
10
|
+
#
|
|
11
|
+
# The generation is a fence. A holder that stalls past its expiry may wake
|
|
12
|
+
# and finish a request it had already started -- the receipt at the other
|
|
13
|
+
# end makes that harmless -- but it must not then be able to overwrite
|
|
14
|
+
# what the new holder has since recorded. Every write it attempts carries
|
|
15
|
+
# the generation it was granted, and a stale one matches nothing.
|
|
16
|
+
module Lease
|
|
17
|
+
TTL = 30
|
|
18
|
+
# Renewed well inside the TTL: a renewal that has to wait on the write
|
|
19
|
+
# lock still has room to land before the lease it is extending lapses.
|
|
20
|
+
RENEW_EVERY = 10
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Takes the lease, or extends it if we already hold it. Returns the
|
|
25
|
+
# generation we hold it under, or nil if someone else has it.
|
|
26
|
+
def acquire(destination_id, owner:, now: Time.current)
|
|
27
|
+
taken = Telemetry::ExportDestination
|
|
28
|
+
.where(id: destination_id)
|
|
29
|
+
.where("lease_owner IS NULL OR lease_owner = ? OR lease_expires_at < ?", owner, now)
|
|
30
|
+
.update_all([
|
|
31
|
+
"lease_owner = ?, lease_expires_at = ?, lease_generation = lease_generation + 1, updated_at = ?",
|
|
32
|
+
owner, now + TTL, now
|
|
33
|
+
])
|
|
34
|
+
return nil if taken.zero?
|
|
35
|
+
|
|
36
|
+
Telemetry::ExportDestination.where(id: destination_id).pick(:lease_generation)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Extends a lease we still hold. Cannot revive an expired one: taking it
|
|
40
|
+
# again is acquire's job, and that increments the generation so anything
|
|
41
|
+
# in flight under the old one is fenced out.
|
|
42
|
+
def renew(destination_id, owner:, generation:, now: Time.current)
|
|
43
|
+
Telemetry::ExportDestination
|
|
44
|
+
.where(id: destination_id, lease_owner: owner, lease_generation: generation)
|
|
45
|
+
.where(lease_expires_at: now..)
|
|
46
|
+
.update_all([ "lease_expires_at = ?, updated_at = ?", now + TTL, now ]) == 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def release(destination_id, owner:, generation:, now: Time.current)
|
|
50
|
+
Telemetry::ExportDestination
|
|
51
|
+
.where(id: destination_id, lease_owner: owner, lease_generation: generation)
|
|
52
|
+
.update_all([ "lease_owner = NULL, lease_expires_at = NULL, updated_at = ?", now ]) == 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|