railwatch 1.3.0 → 1.5.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/AGENTS.md +7 -0
- data/CHANGELOG.md +54 -0
- data/app/controllers/railwatch/beacon_controller.rb +19 -12
- data/docs/ai-and-mcp.md +44 -6
- data/docs/configuration.md +34 -6
- data/docs/getting-started.md +19 -3
- data/docs/records.md +28 -10
- data/docs/troubleshooting.md +1 -1
- data/lib/generators/railwatch/agents/agents_generator.rb +151 -0
- data/lib/generators/railwatch/agents/templates/SKILL.md.tt +32 -0
- data/lib/generators/railwatch/agents/templates/agents_block.md.tt +26 -0
- data/lib/generators/railwatch/install/install_generator.rb +18 -10
- data/lib/generators/railwatch/install/templates/post-deploy +6 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +245 -35
- data/lib/railwatch/configuration.rb +34 -1
- data/lib/railwatch/engine.rb +4 -0
- data/lib/railwatch/middleware/beacon_limit.rb +53 -0
- data/lib/railwatch/middleware/request.rb +50 -4
- data/lib/railwatch/sessions.rb +63 -3
- data/lib/railwatch/transport/http.rb +3 -3
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +7 -1
- data/lib/railwatch.rb +16 -1
- data/lib/tasks/railwatch_tasks.rake +5 -4
- metadata +5 -1
|
@@ -20,6 +20,10 @@ module Railwatch
|
|
|
20
20
|
# CONTENT_TYPE test Subscribers::Requests uses before its params walk.
|
|
21
21
|
MULTIPART = "multipart/form-data"
|
|
22
22
|
NO_FILES = [].freeze
|
|
23
|
+
# Response headers written with config.timing_headers. Lower case, as
|
|
24
|
+
# Rack 3 requires; Rack::Headers matches any case.
|
|
25
|
+
SERVER_TIMING = "server-timing"
|
|
26
|
+
EXECUTION_ID = "x-railwatch-execution-id"
|
|
23
27
|
|
|
24
28
|
# Returns [trace_id, parent_id, sampled] from an inbound traceparent,
|
|
25
29
|
# or nil when the header is absent or malformed.
|
|
@@ -55,21 +59,59 @@ module Railwatch
|
|
|
55
59
|
exe.keep! if upstream_sampled
|
|
56
60
|
exe.enter_stage(:middleware_before)
|
|
57
61
|
env["railwatch.execution"] = exe
|
|
58
|
-
status = headers = body = nil
|
|
62
|
+
status = headers = body = parent = nil
|
|
63
|
+
timing = Railwatch.config.timing_headers?
|
|
59
64
|
begin
|
|
60
65
|
status, headers, body = @app.call(env)
|
|
66
|
+
server_timing(env, exe, headers) if timing && headers
|
|
67
|
+
Sessions.mint(env, status, headers) if headers
|
|
61
68
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
62
69
|
Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
|
|
63
70
|
raise
|
|
64
71
|
ensure
|
|
65
72
|
exe.enter_stage(:middleware_after) unless exe.stage == :middleware_after
|
|
66
|
-
finish(env, exe, status, headers)
|
|
73
|
+
parent = finish(env, exe, status, headers)
|
|
67
74
|
end
|
|
75
|
+
execution_id(exe, headers) if timing && headers && parent
|
|
68
76
|
[ status, headers, body ]
|
|
69
77
|
end
|
|
70
78
|
|
|
71
79
|
private
|
|
72
80
|
|
|
81
|
+
# config.timing_headers (development by default): what this request
|
|
82
|
+
# cost, on the response, so `curl -i` or the browser's network panel
|
|
83
|
+
# shows it without opening the dashboard. Everything here is already
|
|
84
|
+
# known: the counters, and the runtimes the process_action subscriber
|
|
85
|
+
# put in env. Appended to a Server-Timing the app (Rails' own, with
|
|
86
|
+
# config.server_timing) already set, whether one string or several.
|
|
87
|
+
def server_timing(env, exe, headers)
|
|
88
|
+
total = exe.duration / 1000.0
|
|
89
|
+
db = env["railwatch.db_runtime"]
|
|
90
|
+
view = env["railwatch.view_runtime"]
|
|
91
|
+
queries = exe.counters[:queries]
|
|
92
|
+
cached = exe.counters[:cached_queries]
|
|
93
|
+
desc = cached.positive? ? "#{queries} queries, #{cached} cached" : "#{queries} #{queries == 1 ? "query" : "queries"}"
|
|
94
|
+
metrics = [ format("total;dur=%.1f", total), db ? format("db;dur=%.1f;desc=\"%s\"", db, desc) : "db;desc=\"#{desc}\"" ]
|
|
95
|
+
metrics << format("view;dur=%.1f", view) if view
|
|
96
|
+
metrics << format("other;dur=%.1f;desc=\"not db or view\"", [ total - db.to_f - view.to_f, 0 ].max) if db || view
|
|
97
|
+
# A plain Hash may hold it title-cased; Rack::Headers finds either.
|
|
98
|
+
existing = Array(headers[SERVER_TIMING] || headers.delete("Server-Timing")).join(", ")
|
|
99
|
+
headers[SERVER_TIMING] = existing.empty? ? metrics.join(", ") : "#{existing}, #{metrics.join(", ")}"
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
# A frozen header hash (a Rack app that returns a constant): the
|
|
102
|
+
# response is the app's, unchanged.
|
|
103
|
+
Railwatch.debug { "timing headers skipped: #{e.class}: #{e.message}" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The execution an agent passes to get_execution. Only once the
|
|
107
|
+
# request record has shipped (head-sampled in, kept, or rescued by the
|
|
108
|
+
# tail decision): any other id is one the server never has.
|
|
109
|
+
def execution_id(exe, headers)
|
|
110
|
+
headers[EXECUTION_ID] = exe.id
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
Railwatch.debug { "execution id header skipped: #{e.class}: #{e.message}" }
|
|
113
|
+
end
|
|
114
|
+
|
|
73
115
|
def ignored_request?(env)
|
|
74
116
|
path = env["PATH_INFO"].to_s
|
|
75
117
|
Railwatch.config.ignored_request_paths.any? do |pattern|
|
|
@@ -111,13 +153,16 @@ module Railwatch
|
|
|
111
153
|
# The block is only called when the request record is going to ship;
|
|
112
154
|
# a head-sampled-out request nothing rescued skips the
|
|
113
155
|
# ActionDispatch::Request and the header walk entirely.
|
|
114
|
-
Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
156
|
+
parent = Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
115
157
|
# A request with no user and no session cookie has no session, and
|
|
116
158
|
# Sessions.touch returns without writing anything.
|
|
117
159
|
Sessions.touch(exe, env, status) if Railwatch.config.track_sessions
|
|
160
|
+
# The request record, when one shipped.
|
|
161
|
+
parent
|
|
118
162
|
rescue StandardError => e
|
|
119
163
|
Railwatch.debug { "request finish failed: #{e.class}: #{e.message}" }
|
|
120
164
|
Railwatch.finish_execution
|
|
165
|
+
nil
|
|
121
166
|
end
|
|
122
167
|
|
|
123
168
|
def parent_fields(env, exe, status, headers)
|
|
@@ -158,7 +203,8 @@ module Railwatch
|
|
|
158
203
|
payload: payload,
|
|
159
204
|
queue_time: queue_time(env, exe),
|
|
160
205
|
user_agent: req.user_agent.to_s[0, 256],
|
|
161
|
-
files: env["railwatch.files"] || uploaded_files_fallback(req, env)
|
|
206
|
+
files: env["railwatch.files"] || uploaded_files_fallback(req, env),
|
|
207
|
+
**Sessions.browser_fields(env)
|
|
162
208
|
}
|
|
163
209
|
end
|
|
164
210
|
|
data/lib/railwatch/sessions.rb
CHANGED
|
@@ -26,6 +26,18 @@ module Railwatch
|
|
|
26
26
|
# a tab carries the id its beacons already use.
|
|
27
27
|
COOKIE = /(?:\A|;\s*)railwatch_session=([^;]+)/
|
|
28
28
|
KEY_LIMIT = 64
|
|
29
|
+
# What the browser client mints for a session or a tab id. Anything else
|
|
30
|
+
# in a header, cookie, or beacon is not one, and is not recorded as one.
|
|
31
|
+
BROWSER_ID = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
32
|
+
# The id Sessions.mint handed this request, read by key_for and
|
|
33
|
+
# browser_fields as if the browser had sent it.
|
|
34
|
+
MINTED = "railwatch.session_id"
|
|
35
|
+
# The client's idle timeout, which the cookie lapses with (railwatch.ts).
|
|
36
|
+
COOKIE_MAX_AGE = 30 * 60
|
|
37
|
+
HTML = "text/html"
|
|
38
|
+
# A response a shared cache may store and serve to anyone. One visitor's
|
|
39
|
+
# cookie on it would be every visitor's.
|
|
40
|
+
SHARED_CACHE = /\b(?:public|s-maxage)\b/i
|
|
29
41
|
|
|
30
42
|
@mutex = Mutex.new
|
|
31
43
|
@wakeup = ConditionVariable.new
|
|
@@ -78,13 +90,61 @@ module Railwatch
|
|
|
78
90
|
end
|
|
79
91
|
|
|
80
92
|
# The browser client's id when the request carries one -- so the two
|
|
81
|
-
# halves of the same session share a key -- else the resolved user.
|
|
93
|
+
# halves of the same session share a key -- else the resolved user. A
|
|
94
|
+
# first page load the middleware minted an id for keys on that id, but
|
|
95
|
+
# only where it would have keyed on the user: an anonymous request with
|
|
96
|
+
# no cookie (a crawler, curl) opened no session before and opens none
|
|
97
|
+
# now. Its browser, if it runs the client, opens it with the first beat.
|
|
82
98
|
def key_for(exe, env)
|
|
83
|
-
id =
|
|
99
|
+
id = sent_id(env)
|
|
84
100
|
return id[0, KEY_LIMIT] unless id.nil? || id.empty?
|
|
85
101
|
|
|
86
102
|
user = exe.user_id
|
|
87
|
-
|
|
103
|
+
return nil unless user
|
|
104
|
+
env[MINTED] || "user:#{user}"[0, KEY_LIMIT]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The browser session and tab a request came from, for the request
|
|
108
|
+
# record: only what the browser client sent, never the user fallback
|
|
109
|
+
# above, which names a person rather than a session.
|
|
110
|
+
def browser_fields(env)
|
|
111
|
+
session = browser_id(sent_id(env) || env[MINTED])
|
|
112
|
+
tab = browser_id(env["HTTP_X_RAILWATCH_TAB"])
|
|
113
|
+
{ session_id: session, tab_id: tab }.compact
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def sent_id(env)
|
|
117
|
+
env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A browser's first page load carries no session cookie: the client sets
|
|
121
|
+
# it once the page runs, after this request is over. Without one the
|
|
122
|
+
# request would key on the user, a session no browser ever beats, and
|
|
123
|
+
# the journey would start a page late. So a page the client will run on
|
|
124
|
+
# -- a successful GET of an HTML document, not an Inertia visit, from an
|
|
125
|
+
# app with the client installed -- that arrives with no id gets one here,
|
|
126
|
+
# in the cookie the client reads and adopts. Never on a response a
|
|
127
|
+
# shared cache may keep (Cache-Control public or s-maxage).
|
|
128
|
+
def mint(env, status, headers)
|
|
129
|
+
return unless Railwatch.config.browser_client?
|
|
130
|
+
return unless env["REQUEST_METHOD"] == "GET" && status.to_i == 200
|
|
131
|
+
return if env["HTTP_X_INERTIA"] || sent_id(env)
|
|
132
|
+
return unless headers[Rack::CONTENT_TYPE].to_s.start_with?(HTML)
|
|
133
|
+
return if SHARED_CACHE.match?(headers[Rack::CACHE_CONTROL].to_s)
|
|
134
|
+
|
|
135
|
+
id = env[MINTED] = SecureRandom.hex(8)
|
|
136
|
+
# Secure over HTTPS (behind a TLS proxy too: ssl? reads
|
|
137
|
+
# X-Forwarded-Proto), so the id never rides a plain-HTTP request.
|
|
138
|
+
Rack::Utils.set_cookie_header!(headers, "railwatch_session",
|
|
139
|
+
{ value: id, path: "/", max_age: COOKIE_MAX_AGE, same_site: :lax, secure: Rack::Request.new(env).ssl? })
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
# A frozen header hash: the client mints the id once the page runs.
|
|
142
|
+
env.delete(MINTED)
|
|
143
|
+
Railwatch.debug { "session cookie skipped: #{e.class}: #{e.message}" }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def browser_id(value)
|
|
147
|
+
value if value.is_a?(String) && BROWSER_ID.match?(value)
|
|
88
148
|
end
|
|
89
149
|
|
|
90
150
|
def emit(key, entry, ended)
|
|
@@ -69,9 +69,9 @@ module Railwatch
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: SecureRandom.uuid)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
# Permanent: it cannot change between attempts, and with no status
|
|
73
|
+
# the result would read as retryable and walk the backoff ladder.
|
|
74
|
+
return permanent("plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true") unless destination_allowed?
|
|
75
75
|
return Result.new(ok: false, status: UNAUTHORIZED_STATUS, error: "unauthorized, flushing stopped") if @unauthorized
|
|
76
76
|
|
|
77
77
|
encoded = @encoder.encode(records)
|
data/lib/railwatch/version.rb
CHANGED
|
@@ -57,12 +57,16 @@
|
|
|
57
57
|
"headers": {
|
|
58
58
|
"Host": "www.example.com",
|
|
59
59
|
"Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
|
|
60
|
+
"X-Railwatch-Session": "3f2a9c1e7b4d6a08",
|
|
61
|
+
"X-Railwatch-Tab": "9e8d7c6b5a4f3e21",
|
|
60
62
|
"Cookie": "[FILTERED]"
|
|
61
63
|
},
|
|
62
64
|
"payload": null,
|
|
63
65
|
"queue_time": null,
|
|
64
66
|
"user_agent": "",
|
|
65
|
-
"files": []
|
|
67
|
+
"files": [],
|
|
68
|
+
"session_id": "3f2a9c1e7b4d6a08",
|
|
69
|
+
"tab_id": "9e8d7c6b5a4f3e21"
|
|
66
70
|
},
|
|
67
71
|
"job_attempt": {
|
|
68
72
|
"v": 1,
|
|
@@ -609,6 +613,8 @@
|
|
|
609
613
|
"redirect_count": null,
|
|
610
614
|
"connect_ms": null,
|
|
611
615
|
"server_ms": null,
|
|
616
|
+
"session_id": "3f2a9c1e7b4d6a08",
|
|
617
|
+
"tab_id": "9e8d7c6b5a4f3e21",
|
|
612
618
|
"user": null,
|
|
613
619
|
"tenant": null,
|
|
614
620
|
"user_agent": ""
|
data/lib/railwatch.rb
CHANGED
|
@@ -607,13 +607,28 @@ require "railwatch/subscribers"
|
|
|
607
607
|
|
|
608
608
|
module Railwatch
|
|
609
609
|
# The Inertia browser client, relative to the gem root. The install
|
|
610
|
-
# generator copies it into a host's
|
|
610
|
+
# generator copies it into a host's frontend source directory, at lib/.
|
|
611
611
|
BROWSER_CLIENT = "lib/generators/railwatch/install/templates/railwatch.ts"
|
|
612
|
+
# Where a Rails app keeps its Vite sources when ViteRuby does not say:
|
|
613
|
+
# vite_ruby's default, then the jsbundling/rails_vite one.
|
|
614
|
+
FRONTEND_DIRS = %w[app/frontend app/javascript].freeze
|
|
612
615
|
|
|
613
616
|
def self.browser_client_path
|
|
614
617
|
File.expand_path("../#{BROWSER_CLIENT}", __dir__)
|
|
615
618
|
end
|
|
616
619
|
|
|
620
|
+
# The app's frontend source directory, relative to `root`: ViteRuby's
|
|
621
|
+
# configured sourceCodeDir when ViteRuby is loaded, else the first of
|
|
622
|
+
# FRONTEND_DIRS that exists, else nil. The install generator copies the
|
|
623
|
+
# client into it, and Configuration#browser_client? looks for it there.
|
|
624
|
+
def self.frontend_dir(root)
|
|
625
|
+
root = Pathname(root)
|
|
626
|
+
configured = defined?(ViteRuby) && (ViteRuby.config.source_code_dir rescue nil)
|
|
627
|
+
return configured.to_s if configured.present? && root.join(configured.to_s).directory?
|
|
628
|
+
|
|
629
|
+
FRONTEND_DIRS.find { |dir| root.join(dir).directory? }
|
|
630
|
+
end
|
|
631
|
+
|
|
617
632
|
# One realistic wire record per record type, exactly as the gem's own
|
|
618
633
|
# subscribers emit it, generated by spec/railwatch/wire_fixtures_spec.rb.
|
|
619
634
|
# A receiver tests its mapper against these instead of hand-writing what
|
|
@@ -144,21 +144,22 @@ namespace :railwatch do
|
|
|
144
144
|
check.call(hook.exist? && hook.read.include?("railwatch"), "kamal post-deploy hook",
|
|
145
145
|
hook.exist? ? hook.to_s : "not found (only needed when deploying with Kamal)")
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
frontend = Railwatch.frontend_dir(Rails.root) || "app/frontend"
|
|
148
|
+
client = Rails.root.join(frontend, "lib/railwatch.ts")
|
|
148
149
|
check.call(client.exist?, "browser client",
|
|
149
150
|
client.exist? ? "#{client} (call startRailwatch() from your Inertia entrypoint)" : "not found (only needed for Inertia visit timing)")
|
|
150
151
|
|
|
151
152
|
# The client file existing is not the same as it running: startRailwatch()
|
|
152
153
|
# has to be called from an entrypoint or no visit is ever timed.
|
|
153
|
-
entrypoints = Dir[Rails.root.join("
|
|
154
|
+
entrypoints = Dir[Rails.root.join(frontend, "entrypoints/*")].select { |f| File.file?(f) }
|
|
154
155
|
started = entrypoints.select { |f| File.read(f).include?("startRailwatch") }
|
|
155
156
|
check.call(started.any?, "browser client imported",
|
|
156
157
|
if started.any?
|
|
157
158
|
started.map { |f| Pathname.new(f).relative_path_from(Rails.root).to_s }.join(", ")
|
|
158
159
|
elsif entrypoints.any?
|
|
159
|
-
"no entrypoint in
|
|
160
|
+
"no entrypoint in #{frontend}/entrypoints calls startRailwatch()"
|
|
160
161
|
else
|
|
161
|
-
"no
|
|
162
|
+
"no #{frontend}/entrypoints (only needed for Inertia visit timing)"
|
|
162
163
|
end)
|
|
163
164
|
|
|
164
165
|
backend = Railwatch::Profiler.backend
|
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: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole Robertson
|
|
@@ -70,6 +70,9 @@ files:
|
|
|
70
70
|
- docs/source-maps.md
|
|
71
71
|
- docs/testing.md
|
|
72
72
|
- docs/troubleshooting.md
|
|
73
|
+
- lib/generators/railwatch/agents/agents_generator.rb
|
|
74
|
+
- lib/generators/railwatch/agents/templates/SKILL.md.tt
|
|
75
|
+
- lib/generators/railwatch/agents/templates/agents_block.md.tt
|
|
73
76
|
- lib/generators/railwatch/install/install_generator.rb
|
|
74
77
|
- lib/generators/railwatch/install/templates/initializer.rb.tt
|
|
75
78
|
- lib/generators/railwatch/install/templates/post-deploy
|
|
@@ -91,6 +94,7 @@ files:
|
|
|
91
94
|
- lib/railwatch/health.rb
|
|
92
95
|
- lib/railwatch/job_tracing.rb
|
|
93
96
|
- lib/railwatch/json_compat.rb
|
|
97
|
+
- lib/railwatch/middleware/beacon_limit.rb
|
|
94
98
|
- lib/railwatch/middleware/request.rb
|
|
95
99
|
- lib/railwatch/minitest.rb
|
|
96
100
|
- lib/railwatch/patches.rb
|