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.
@@ -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
 
@@ -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 = env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
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
- user && "user:#{user}"[0, KEY_LIMIT]
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
- unless destination_allowed?
73
- return Result.new(ok: false, error: "plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true")
74
- end
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.3.0"
4
+ VERSION = "1.5.0"
5
5
  end
@@ -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 app/frontend/lib.
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
- client = Rails.root.join("app/frontend/lib/railwatch.ts")
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("app/frontend/entrypoints/*")].select { |f| File.file?(f) }
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 app/frontend/entrypoints calls startRailwatch()"
160
+ "no entrypoint in #{frontend}/entrypoints calls startRailwatch()"
160
161
  else
161
- "no app/frontend/entrypoints (only needed for Inertia visit timing)"
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.3.0
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