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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "railwatch/secret_safety"
5
+
6
+ module Railwatch
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates config/initializers/railwatch.rb, a Kamal post-deploy hook, the browser client, and wires the test helpers."
12
+
13
+ class_option :token, type: :string,
14
+ desc: "Deprecated: token in process arguments. Prefer --prompt-token, --token-stdin, or RAILWATCH_TOKEN."
15
+ class_option :prompt_token, type: :boolean, default: false,
16
+ desc: "Prompt for the ingest token without echoing it."
17
+ class_option :token_stdin, type: :boolean, default: false,
18
+ desc: "Read the ingest token from one line on standard input."
19
+ class_option :url, type: :string,
20
+ desc: "Ingest URL, for a self-hosted Railwatch Cloud. Defaults to https://railwatch.rebulk.com."
21
+ class_option :kamal_secrets, type: :boolean, default: false,
22
+ desc: "Wire RAILWATCH_TOKEN through .kamal/secrets and config/deploy.yml's `env: secret:` list."
23
+ class_option :doctor, type: :boolean, default: true,
24
+ desc: "Run railwatch:doctor when the install finishes."
25
+
26
+ # Whichever of these exists is where `startRailwatch()` is added.
27
+ INERTIA_ENTRYPOINTS = %w[
28
+ app/frontend/entrypoints/inertia.tsx
29
+ app/frontend/entrypoints/inertia.ts
30
+ app/frontend/entrypoints/inertia.jsx
31
+ ].freeze
32
+
33
+ IMPORT_LINE = 'import { startRailwatch } from "@/lib/railwatch"'
34
+ START_CALL = "startRailwatch()"
35
+
36
+ # The name is written in three places -- .env, .kamal/secrets, and
37
+ # config/deploy.yml -- and each of them has to be idempotent, so it is
38
+ # spelled once here.
39
+ TOKEN_VAR = "RAILWATCH_TOKEN"
40
+ URL_VAR = "RAILWATCH_INGEST_URL"
41
+
42
+ def create_initializer
43
+ template "initializer.rb", "config/initializers/railwatch.rb"
44
+ end
45
+
46
+ def create_kamal_hook
47
+ return unless File.exist?("config/deploy.yml")
48
+ template "post-deploy", ".kamal/hooks/post-deploy"
49
+ chmod ".kamal/hooks/post-deploy", 0o755
50
+ end
51
+
52
+ def create_browser_client
53
+ return unless File.directory?("app/frontend")
54
+ template "railwatch.ts", "app/frontend/lib/railwatch.ts"
55
+ end
56
+
57
+ # Adds the import and the call to the Inertia entrypoint, so page-visit
58
+ # timing and Core Web Vitals report with no further editing.
59
+ def start_browser_client
60
+ return unless File.directory?("app/frontend")
61
+
62
+ path = INERTIA_ENTRYPOINTS.find { |candidate| File.exist?(candidate) }
63
+ return say(browser_client_instructions(INERTIA_ENTRYPOINTS.first), :yellow) unless path
64
+
65
+ contents = File.read(path)
66
+ return say_status(:identical, path, :blue) if contents.include?(START_CALL)
67
+
68
+ imports = contents.lines.select { |line| line.match?(/\A\s*import\s/) }
69
+ return say(browser_client_instructions(path), :yellow) if imports.empty?
70
+
71
+ # Anchored at \A so the injection lands after the *last* import line
72
+ # and can only ever match once, however many imports repeat.
73
+ inject_into_file path, "#{IMPORT_LINE}\n\n#{START_CALL}\n",
74
+ after: /\A[\s\S]*#{Regexp.escape(imports.last)}/
75
+ end
76
+
77
+ # `require "railwatch/rspec"` / `"railwatch/minitest"` brings in the block
78
+ # matchers (have_railwatch_queries, have_railwatch_n_plus_one, ...) that turn
79
+ # a spec suite into a performance gate. See docs/testing.md.
80
+ def wire_test_helper
81
+ if File.exist?("spec/rails_helper.rb")
82
+ inject_require "spec/rails_helper.rb", 'require "railwatch/rspec"', %r{^require ["']rspec/rails["'].*\n}
83
+ elsif File.exist?("test/test_helper.rb")
84
+ inject_require "test/test_helper.rb", 'require "railwatch/minitest"', %r{^require ["']rails/test_help["'].*\n}
85
+ end
86
+ end
87
+
88
+ def mount_beacon
89
+ route 'mount Railwatch::Engine, at: "/railwatch"'
90
+ end
91
+
92
+ # A token lands in .env only when Git confirms the file is ignored.
93
+ # URLs are not secret and can still be written to a tracked dotenv file.
94
+ def write_env
95
+ token = resolved_token
96
+ if options[:token]
97
+ say("--token exposes #{Railwatch::SecretSafety.token_preview(options[:token])} in process arguments; " \
98
+ "use --prompt-token or --token-stdin next time.", :yellow)
99
+ end
100
+ vars = { TOKEN_VAR => token, URL_VAR => options[:url] }.compact
101
+ return if vars.empty?
102
+ return say(env_instructions(vars), :yellow) unless dotenv_app?
103
+
104
+ if token && !safe_dotenv_for_token?
105
+ say("Refusing to write #{Railwatch::SecretSafety.token_preview(token)} to .env because Git does not confirm that .env is ignored. Use Rails credentials, a secret manager, or add .env to .gitignore first.", :red)
106
+ vars.delete(TOKEN_VAR)
107
+ return if vars.empty?
108
+ end
109
+
110
+ existing = File.exist?(".env") ? File.read(".env") : ""
111
+ missing = vars.reject { |name, _| existing.match?(/^#{name}=/) }
112
+ return say_status(:identical, ".env", :blue) if missing.empty?
113
+
114
+ body = missing.map { |name, value| "#{name}=#{value}\n" }.join
115
+ if File.exist?(".env")
116
+ append_to_file ".env", (existing.end_with?("\n") || existing.empty? ? body : "\n#{body}")
117
+ else
118
+ create_file ".env", body
119
+ end
120
+ end
121
+
122
+ # Kamal reads .kamal/secrets with shell expansion and passes only the
123
+ # names listed under `env: secret:` into the container, so the token
124
+ # needs both halves to reach the app.
125
+ def configure_kamal_secrets
126
+ return unless options[:kamal_secrets]
127
+ return say("--kamal-secrets: no .kamal/secrets found; run `bin/kamal init` first.", :yellow) unless File.exist?(".kamal/secrets")
128
+
129
+ secrets = File.read(".kamal/secrets")
130
+ if secrets.match?(/^#{TOKEN_VAR}=/)
131
+ say_status(:identical, ".kamal/secrets", :blue)
132
+ else
133
+ append_to_file ".kamal/secrets", "#{secrets.end_with?("\n") ? "" : "\n"}#{TOKEN_VAR}=$#{TOKEN_VAR}\n"
134
+ end
135
+
136
+ return say("--kamal-secrets: no config/deploy.yml found; add #{TOKEN_VAR} under `env: secret:` yourself.", :yellow) unless File.exist?("config/deploy.yml")
137
+
138
+ deploy = File.read("config/deploy.yml")
139
+ updated = self.class.deploy_yml_with_secret(deploy)
140
+ return say_status(:identical, "config/deploy.yml", :blue) if updated == deploy
141
+
142
+ create_file "config/deploy.yml", updated, force: true
143
+ end
144
+
145
+ def show_next_steps
146
+ say <<~STEPS, :green
147
+
148
+ Next steps
149
+ 1. Set #{TOKEN_VAR}. With Kamal, add it to .kamal/secrets:
150
+ #{TOKEN_VAR}=$#{TOKEN_VAR}
151
+ and list it under `env: secret:` in config/deploy.yml (or re-run
152
+ this generator with --kamal-secrets). Otherwise put it in
153
+ credentials and read it in the initializer:
154
+ c.token = Rails.application.credentials.dig(:railwatch, :token)
155
+ Self-hosting? Set #{URL_VAR} too.
156
+ No token yet? bin/rails railwatch:token
157
+ 2. Verify the install: bin/rails railwatch:doctor
158
+ 3. Connect your AI assistant: bin/rails railwatch:mcp
159
+ 4. Gate performance in CI: see docs/testing.md.
160
+ STEPS
161
+ end
162
+
163
+ # Runs the same checklist the developer would run next, in this
164
+ # process. Railwatch's configuration was read at boot, so a token this
165
+ # run just wrote to .env is not visible until the app restarts -- the
166
+ # note below says so rather than letting a ✗ look like a broken install.
167
+ def run_doctor
168
+ return unless options[:doctor]
169
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
170
+
171
+ say "\nbin/rails railwatch:doctor", :green
172
+ require "rake"
173
+ Rails.application.load_tasks unless Rake::Task.task_defined?("railwatch:doctor")
174
+ Rake::Task["railwatch:doctor"].reenable
175
+ Rake::Task["railwatch:doctor"].invoke
176
+ rescue SystemExit
177
+ say "\nFix the ✗ lines above and re-run `bin/rails railwatch:doctor`. " \
178
+ "Values added to .env or credentials just now are only picked up after a restart.", :yellow
179
+ rescue StandardError => e
180
+ say "\nCould not run railwatch:doctor here (#{e.class}: #{e.message}). Run `bin/rails railwatch:doctor` yourself.", :yellow
181
+ end
182
+
183
+ # Adds RAILWATCH_TOKEN to config/deploy.yml's `env: secret:` list as a
184
+ # targeted text insertion. A YAML round-trip would be shorter and would
185
+ # throw away every comment in the file, which is most of what a Kamal
186
+ # deploy.yml is. Returns the contents unchanged when it is already
187
+ # listed.
188
+ def self.deploy_yml_with_secret(contents, name = TOKEN_VAR)
189
+ lines = contents.lines
190
+ env_start = lines.index { |line| line.match?(/\Aenv:\s*(#.*)?$/) }
191
+ return "#{contents.sub(/\n*\z/, "\n")}\nenv:\n secret:\n - #{name}\n" unless env_start
192
+
193
+ env_end = block_end(lines, env_start)
194
+ return contents if lines[env_start...env_end].any? { |line| line.match?(/\A\s*-\s*#{name}\s*\z/) }
195
+
196
+ secret_start = (env_start + 1...env_end).find { |i| lines[i].match?(/\A\s+secret:\s*(#.*)?$/) }
197
+ return insert_lines(lines, env_start + 1, " secret:\n - #{name}\n") unless secret_start
198
+
199
+ insert_lines(lines, block_end(lines, secret_start), " - #{name}\n")
200
+ end
201
+
202
+ # Index of the first line after the block opened at `start`: the next
203
+ # line indented no more deeply than it, ignoring blanks and comments,
204
+ # then backed up over any trailing blank lines so an insertion lands
205
+ # inside the block rather than after the gap below it.
206
+ def self.block_end(lines, start)
207
+ indent = lines[start][/\A */].length
208
+ stop = ((start + 1)...lines.length).find { |i|
209
+ line = lines[i]
210
+ next false if line.strip.empty? || line.strip.start_with?("#")
211
+ line[/\A */].length <= indent
212
+ } || lines.length
213
+ stop -= 1 while stop > start + 1 && lines[stop - 1].strip.empty?
214
+ stop
215
+ end
216
+
217
+ def self.insert_lines(lines, index, text)
218
+ (lines[0...index] + [ text ] + lines[index..]).join
219
+ end
220
+
221
+ private_class_method :block_end, :insert_lines
222
+
223
+ private
224
+
225
+ def resolved_token
226
+ @resolved_token ||= begin
227
+ value = if options[:prompt_token]
228
+ ask("Railwatch ingest token (input hidden):", echo: false)
229
+ elsif options[:token_stdin]
230
+ $stdin.gets
231
+ elsif options[:token]
232
+ options[:token]
233
+ elsif !ENV[TOKEN_VAR].to_s.empty?
234
+ ENV[TOKEN_VAR]
235
+ end
236
+ value = value.to_s.strip
237
+ value unless value.empty?
238
+ end
239
+ end
240
+
241
+ def safe_dotenv_for_token?
242
+ !Railwatch::SecretSafety.git_tracked?(".env") && Railwatch::SecretSafety.git_ignored?(".env")
243
+ end
244
+
245
+ # dotenv is the only place the generator will write a token: an app
246
+ # that keeps its environment anywhere else gets told what to paste.
247
+ def dotenv_app?
248
+ File.exist?(".env") || (File.exist?("Gemfile") && File.read("Gemfile").match?(/^\s*gem ["']dotenv/))
249
+ end
250
+
251
+ def env_instructions(vars)
252
+ lines = vars.map do |name, value|
253
+ if name == TOKEN_VAR
254
+ " #{name}=#{Railwatch::SecretSafety.token_preview(value)} (value hidden)"
255
+ else
256
+ " #{name}=#{value}"
257
+ end
258
+ end
259
+ "Set these where this app reads its environment (.kamal/secrets, credentials, or your PaaS config). " \
260
+ "The token value is never printed:\n#{lines.join("\n")}"
261
+ end
262
+
263
+ def browser_client_instructions(path)
264
+ "Add these two lines to #{path} to report Inertia visits and Core Web Vitals:\n" \
265
+ " #{IMPORT_LINE}\n #{START_CALL}"
266
+ end
267
+
268
+ # inject_into_file on its own would append a second copy on a re-run --
269
+ # Thor only skips when the replacement is already byte-identical in
270
+ # place, and the anchor is not.
271
+ def inject_require(path, line, anchor)
272
+ contents = File.read(path)
273
+ return say_status(:identical, path, :blue) if contents.include?(line)
274
+ return say("Add `#{line}` to #{path} to get the Railwatch test matchers.", :yellow) unless contents.match?(anchor)
275
+
276
+ inject_into_file path, "#{line}\n", after: anchor
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Railwatch: first-class monitoring for Rails. Every option here can also be
4
+ # set by the RAILWATCH_* env var named in the comment.
5
+ Railwatch.configure do |c|
6
+ # c.token = ENV["RAILWATCH_TOKEN"] # RAILWATCH_TOKEN (required)
7
+ # c.ingest_url = "https://railwatch.rebulk.com" # RAILWATCH_INGEST_URL
8
+ # c.deploy = "release-name" # RAILWATCH_DEPLOY; platform/Git auto-detected
9
+ # c.detect_deploy = false # RAILWATCH_DETECT_DEPLOY; default true
10
+
11
+ # Sampling is decided once per execution; a sampled-in request ships its
12
+ # whole tree of queries, cache events, jobs, mail, and logs.
13
+ # c.sample = { requests: 1.0, jobs: 1.0, commands: 1.0, scheduled_tasks: 1.0, channels: 1.0, exceptions: 1.0 }
14
+
15
+ # Drop whole record types: :queries, :cache_events, :mail, :broadcasts,
16
+ # :notifications, :outgoing_requests, :storage_ops, :view_renders, :logs, :transactions
17
+ # c.ignore = []
18
+
19
+ # Default vendor rake tasks (db:migrate, assets:precompile, ...) and default
20
+ # vendor cache-key prefixes (rack::attack, flipper, ...) are excluded unless
21
+ # you opt back in.
22
+ # c.capture_default_vendor_commands = false # RAILWATCH_CAPTURE_DEFAULT_VENDOR_COMMANDS
23
+ # c.capture_default_vendor_cache_keys = false # RAILWATCH_CAPTURE_DEFAULT_VENDOR_CACHE_KEYS
24
+
25
+ # c.log_level = :info
26
+ # c.buffer_bytes = 16 * 1024 * 1024 # reporter queue memory ceiling
27
+ # c.execution_buffer_bytes = 8 * 1024 * 1024
28
+ # c.batch_bytes = 8 * 1024 * 1024 # uncompressed NDJSON per request
29
+ # c.backpressure = true # adapt sampling under buffer/ingest pressure
30
+ # c.backpressure_high_water = 0.8 # fraction of either buffer ceiling
31
+ # c.capture_request_payload = false # only captured for requests that raised, always redacted
32
+ # Retried job errors are usually expected, so they are not captured by
33
+ # default; enabling this can flood the issues list when retries are common.
34
+ # c.capture_job_retry_errors = false # RAILWATCH_CAPTURE_JOB_RETRY_ERRORS
35
+ # c.redact_headers += %w[X-Api-Key]
36
+ # c.redact_params += %w[ssn] # merged with Rails.application.config.filter_parameters
37
+ # c.ignored_request_paths += ["/internal/health"] # /up and /railwatch/beacon are ignored by default
38
+
39
+ # How the current user is described. Default reads Current.user then Warden.
40
+ # c.user { |user| { id: user.id, name: user.name, email: user.email } }
41
+ end
42
+
43
+ # A trailing "*" matches as a prefix; a string starting with "^" (or another
44
+ # regex metacharacter) is compiled as a Regexp; anything else must match the
45
+ # cache key exactly.
46
+ # Railwatch.reject_cache_keys %w[session: rack::attack* ^feature_flag_\d+$]
47
+ # Railwatch.reject_outgoing_requests { |r| r[:host] == "127.0.0.1" }
48
+ # Railwatch.redact_queries { |q| q[:sql] = q[:sql].gsub(/email = '[^']+'/, "email = '?'") }
49
+ # Railwatch.before_ingest { |batch| batch.size < 10_000 } # return false to drop a batch
50
+
51
+ # Called whenever Railwatch rescues one of its own internal errors (a
52
+ # subscriber raising, or delivery failing after its retry), instead of only
53
+ # logging to Railwatch.debug.
54
+ # Railwatch.on_unrecoverable { |error| Rails.error.report(error, handled: true) }
@@ -0,0 +1,98 @@
1
+ #!/bin/sh
2
+ # Kamal post-deploy hook: records the deploy in Railwatch so charts and issues
3
+ # get a deploy marker, regressions are attributed to a version, and the
4
+ # platform can show a diff of the commits that shipped.
5
+ #
6
+ # This runs on the deployer machine, which -- unlike the app containers -- has
7
+ # both the git history and the KAMAL_* environment
8
+ # (https://kamal-deploy.org/docs/hooks/overview/), so it POSTs straight to the
9
+ # ingest host instead of shelling into a container. Never fails a deploy.
10
+ set -e
11
+ [ -z "$RAILWATCH_TOKEN" ] && exit 0
12
+
13
+ # Optional: .kamal/hooks/post-deploy --sourcemaps, or set
14
+ # RAILWATCH_SOURCEMAPS=true. Requires the build artifacts on the deployer.
15
+ # Prefer uploading in CI before publishing the image. Deletion is opt-in
16
+ # with RAILWATCH_SOURCEMAPS_DELETE=true and happens only after acknowledgment.
17
+ if [ "${1:-}" = "--sourcemaps" ] || [ "${RAILWATCH_SOURCEMAPS:-}" = "true" ]; then
18
+ RAILWATCH_DEPLOY="$KAMAL_VERSION" bin/rails railwatch:sourcemaps || echo "Railwatch source map upload failed" >&2
19
+ fi
20
+
21
+ if ! command -v curl >/dev/null 2>&1 || ! command -v ruby >/dev/null 2>&1 || [ -z "$RAILWATCH_INGEST_URL" ]; then
22
+ # Fall back to the rake task inside the deployed container. It records the
23
+ # same deploy minus the commit list, which a container has no git history
24
+ # to build.
25
+ bin/kamal app exec --primary --reuse "bin/rails railwatch:deploy[$KAMAL_VERSION]" || true
26
+ exit 0
27
+ fi
28
+
29
+ INGEST="${RAILWATCH_INGEST_URL%/}"
30
+
31
+ case "$INGEST" in
32
+ https://*|http://localhost|http://localhost:*|http://127.0.0.1|http://127.0.0.1:*|http://\[::1\]|http://\[::1\]:*) ;;
33
+ http://*)
34
+ if [ "${RAILWATCH_ALLOW_HTTP:-}" != "true" ]; then
35
+ echo "Railwatch deploy marker skipped: plain HTTP ingest requires RAILWATCH_ALLOW_HTTP=true" >&2
36
+ exit 0
37
+ fi
38
+ ;;
39
+ esac
40
+
41
+ post() {
42
+ curl -sS -m 10 -X POST "$1" \
43
+ -H "Authorization: Bearer $RAILWATCH_TOKEN" \
44
+ -H "Content-Type: application/json" \
45
+ -d "$2" >/dev/null 2>&1 || true
46
+ }
47
+
48
+ git_log() {
49
+ if [ -d .git ] && command -v git >/dev/null 2>&1; then
50
+ git log -n 50 --format='%H%x1f%an%x1f%s%x1f%cI' 2>/dev/null || true
51
+ fi
52
+ }
53
+
54
+ # Set RAILWATCH_DEPLOY_URL to link the marker at a CI run or a release page.
55
+ DEPLOY_BODY=$(git_log | ruby -rjson -e '
56
+ commits = $stdin.read.split("\n").reject(&:empty?).map do |line|
57
+ sha, author, message, at = line.split("\x1f")
58
+ { sha: sha, author: author, message: message, at: at }
59
+ end
60
+ puts JSON.generate(
61
+ deploy: ENV["KAMAL_VERSION"],
62
+ ref: ENV["KAMAL_VERSION"],
63
+ name: ENV["KAMAL_SERVICE_VERSION"],
64
+ url: ENV["RAILWATCH_DEPLOY_URL"],
65
+ server: ENV["KAMAL_HOSTS"].to_s.split(",").first,
66
+ timestamp: ENV["KAMAL_RECORDED_AT"],
67
+ performer: ENV["KAMAL_PERFORMER"],
68
+ destination: ENV["KAMAL_DESTINATION"],
69
+ service: ENV["KAMAL_SERVICE"],
70
+ commits: commits)
71
+ ') || DEPLOY_BODY=""
72
+
73
+ if [ -n "$DEPLOY_BODY" ]; then
74
+ post "$INGEST/ingest/deploys" "$DEPLOY_BODY"
75
+ fi
76
+
77
+ # Which servers this deploy targeted, so the platform knows who should be
78
+ # reporting. The docs name the role variable KAMAL_ROLE; KAMAL_ROLES is read
79
+ # first because some Kamal versions set the plural.
80
+ KAMAL_BODY=$(ruby -rjson -e '
81
+ list = ->(value) { value.to_s.split(",").map(&:strip).reject(&:empty?) }
82
+ puts JSON.generate(
83
+ version: ENV["KAMAL_VERSION"],
84
+ hosts: list.call(ENV["KAMAL_HOSTS"]),
85
+ roles: list.call(ENV["KAMAL_ROLES"] || ENV["KAMAL_ROLE"]),
86
+ performer: ENV["KAMAL_PERFORMER"],
87
+ destination: ENV["KAMAL_DESTINATION"],
88
+ service: ENV["KAMAL_SERVICE"],
89
+ recorded_at: ENV["KAMAL_RECORDED_AT"],
90
+ command: ENV["KAMAL_COMMAND"],
91
+ subcommand: ENV["KAMAL_SUBCOMMAND"])
92
+ ') || KAMAL_BODY=""
93
+
94
+ if [ -n "$KAMAL_BODY" ]; then
95
+ post "$INGEST/ingest/kamal" "$KAMAL_BODY"
96
+ fi
97
+
98
+ exit 0