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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ VERSION = "0.1.0"
5
+ end