railwatch-server 0.1.0-arm64-darwin
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 +7 -0
- data/LICENSE +661 -0
- data/NOTICE +24 -0
- data/README.md +368 -0
- data/app/controllers/railwatch/server/proxy_controller.rb +323 -0
- data/exe/arm64-darwin/railwatch +0 -0
- data/lib/puma/plugin/railwatch_server.rb +110 -0
- data/lib/railwatch/server/binary.rb +46 -0
- data/lib/railwatch/server/configuration.rb +128 -0
- data/lib/railwatch/server/data_dir.rb +155 -0
- data/lib/railwatch/server/engine.rb +58 -0
- data/lib/railwatch/server/process.rb +372 -0
- data/lib/railwatch/server/proxy_auth.rb +33 -0
- data/lib/railwatch/server/proxy_handoff.rb +55 -0
- data/lib/railwatch/server/relay.rb +286 -0
- data/lib/railwatch/server/settings.rb +175 -0
- data/lib/railwatch/server/version.rb +10 -0
- data/lib/railwatch/server.rb +157 -0
- data/lib/tasks/railwatch_server.rake +33 -0
- metadata +100 -0
data/NOTICE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
railwatch-server bundles the Railwatch server.
|
|
2
|
+
|
|
3
|
+
The platform gems of railwatch-server (x86_64-linux, aarch64-linux,
|
|
4
|
+
x86_64-darwin, arm64-darwin) contain exe/<platform>/railwatch, a compiled
|
|
5
|
+
build of Railwatch (railwatch-rs), https://github.com/Rebulk/railwatch-rs,
|
|
6
|
+
which is free software under the GNU Affero General Public License,
|
|
7
|
+
version 3 only (AGPL-3.0-only). This gem's own Ruby code is under the same
|
|
8
|
+
license; see LICENSE.
|
|
9
|
+
|
|
10
|
+
The Corresponding Source of the bundled binary is the railwatch-rs commit
|
|
11
|
+
named in lib/railwatch/server/version.rb (RAILWATCH_RS), unmodified. The
|
|
12
|
+
running server also links to its source on every response (the Link header and /source).
|
|
13
|
+
Third-party code compiled into the binary keeps its own licenses, listed in
|
|
14
|
+
railwatch-rs's THIRD-PARTY-LICENSES.
|
|
15
|
+
|
|
16
|
+
Running this gem in your application does not make your application a
|
|
17
|
+
derivative of Railwatch: the server is a separate program, started as its
|
|
18
|
+
own process and spoken to over HTTP. If you modify the server and offer it
|
|
19
|
+
to users over a network, AGPL-3.0 section 13 asks you to offer them the
|
|
20
|
+
source of your version.
|
|
21
|
+
|
|
22
|
+
The railwatch gem (https://github.com/Rebulk/railwatch), the agent that
|
|
23
|
+
reports your app's telemetry, is a separate gem under the MIT license. This
|
|
24
|
+
gem depends on it; it is not part of this gem.
|
data/README.md
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# railwatch-server
|
|
2
|
+
|
|
3
|
+
The Railwatch server, bundled as a gem. Add it to a Rails app and you get
|
|
4
|
+
the **full** Railwatch dashboard at `/railwatch` (issues, alerts, the API
|
|
5
|
+
and MCP included), behind your app's own login, with no server to install.
|
|
6
|
+
The server runs as **its own process** next to the app, started by Puma's
|
|
7
|
+
master. It never runs inside a Puma worker.
|
|
8
|
+
|
|
9
|
+
## How it relates to the other two
|
|
10
|
+
|
|
11
|
+
| | what it is | license |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| [`railwatch`](https://github.com/Rebulk/railwatch) | the agent in your app; on its own it reports to Railwatch Cloud or a self-hosted server | MIT |
|
|
14
|
+
| [`railwatch-rs`](https://github.com/Rebulk/railwatch-rs) | the server, one static binary; self-hosted on a VPS for many apps | AGPL-3.0-only |
|
|
15
|
+
| **`railwatch-server`** (this) | `railwatch-rs`, prebuilt per platform, run and wired for one app | AGPL-3.0-only |
|
|
16
|
+
|
|
17
|
+
This gem depends on `railwatch` and points the agent at the bundled server.
|
|
18
|
+
It is the same server you would self-host, at the same version, just started
|
|
19
|
+
for you. Moving later to a shared self-hosted install means changing
|
|
20
|
+
`RAILWATCH_INGEST_URL` and `RAILWATCH_TOKEN` (and copying `storage/railwatch`
|
|
21
|
+
if you want the history).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Gemfile
|
|
27
|
+
gem "railwatch"
|
|
28
|
+
gem "railwatch-server"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/puma.rb
|
|
33
|
+
plugin :railwatch_server
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/initializers/railwatch.rb: the dashboard's gate (the railwatch gem's settings)
|
|
38
|
+
Railwatch.configure do |c|
|
|
39
|
+
c.http_basic_auth_user = "admin" # or bin/rails railwatch:authentication:configure
|
|
40
|
+
c.http_basic_auth_password = ENV["RAILWATCH_DASHBOARD_PASSWORD"]
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`bin/rails s`, then open `/railwatch`. On first boot the gem runs
|
|
45
|
+
`railwatch setup` against `storage/railwatch/<Rails.env>`, so the account,
|
|
46
|
+
app, environment, ingest token and proxy secret are created once and kept
|
|
47
|
+
there. It then starts `railwatch serve` on `127.0.0.1:28790` and points the
|
|
48
|
+
agent at it.
|
|
49
|
+
|
|
50
|
+
Only a process that starts the server creates that directory: Puma with the
|
|
51
|
+
plugin (`bin/rails server` or `puma`) and `bin/rails railwatch_server:start`.
|
|
52
|
+
Anything else (`assets:precompile` in a Dockerfile, a console, a Solid Queue
|
|
53
|
+
worker) reads it and, until it exists, leaves the agent alone and says so.
|
|
54
|
+
So an image build ships no token, proxy secret or `secret_key_base`; each
|
|
55
|
+
volume gets its own on first start. A job worker started before the first
|
|
56
|
+
server reports from its next restart. A `railwatch setup` that hangs never
|
|
57
|
+
holds up the boot past `RAILWATCH_SERVER_SETUP_TIMEOUT` (below).
|
|
58
|
+
|
|
59
|
+
Bundler picks the platform gem with the binary for your machine:
|
|
60
|
+
x86_64-linux, aarch64-linux, x86_64-darwin or arm64-darwin. On anything
|
|
61
|
+
else the plain gem installs and tells you to set `RAILWATCH_SERVER_BINARY`
|
|
62
|
+
to a `railwatch` you installed yourself.
|
|
63
|
+
|
|
64
|
+
### Without Puma
|
|
65
|
+
|
|
66
|
+
Use `bin/rails railwatch_server:start` (foreground, TERM to stop), e.g. as a
|
|
67
|
+
Procfile line:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
railwatch: bin/rails railwatch_server:start
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`bin/rails railwatch_server:status` reports the binary, the data dir,
|
|
74
|
+
whether the server is up, and where the agent reports.
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
| env | default | |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `RAILWATCH_SERVER_ENABLED` | on, off under `Rails.env.test` | off: no server, no route, the agent untouched |
|
|
81
|
+
| `RAILWATCH_SERVER_PORT` | `28790` | loopback port. Fixed, not picked at boot, so every process of the app finds the same server |
|
|
82
|
+
| `RAILWATCH_SERVER_DATA_DIR` | `storage/railwatch/<Rails.env>` | put it on the volume that survives deploys |
|
|
83
|
+
| `RAILWATCH_SERVER_MOUNT_PATH` | `/railwatch` | |
|
|
84
|
+
| `RAILWATCH_SERVER_URL` | the loopback URL | public URL of the app, for links in alert emails and Secure cookies |
|
|
85
|
+
| `RAILWATCH_SERVER_BINARY` | the platform gem's | your own `railwatch` |
|
|
86
|
+
| `RAILWATCH_SERVER_STOP_TIMEOUT` | `15` | seconds Puma's exit waits for the server to drain |
|
|
87
|
+
| `RAILWATCH_SERVER_SETUP_TIMEOUT` | `30` | seconds the first start of a data dir waits for `railwatch setup` (about a second), before Puma listens. Past it the setup is KILLed, the app boots without the server (agent not wired, the dashboard's 503 says why) and the next start retries |
|
|
88
|
+
| `RAILWATCH_SERVER_WIRE_AGENT` | `true` | off: you configure the agent yourself |
|
|
89
|
+
| `RAILWATCH_SERVER_MAX_LIVE_CONNECTIONS` | `64` | open live-update connections per app process (one per dashboard tab); past it the dashboard works without live updates |
|
|
90
|
+
| `RAILWATCH_SERVER_MAX_PROXIED_REQUESTS` | `64` | dashboard requests in flight per app process (waiting on the server or streaming back); past it a dashboard request gets 503 with `Retry-After: 1` at once. The app's own requests are never affected |
|
|
91
|
+
| `RAILWATCH_SERVER_PROXY_CONNECT_TIMEOUT` | `2` | seconds to connect to the server on loopback; past it, 503 |
|
|
92
|
+
| `RAILWATCH_SERVER_PROXY_FIRST_BYTE_TIMEOUT` | `25` | seconds for the server to start answering a dashboard request; past it, 504 with a message. Under 30 s on purpose: Thruster (its write timeout) and kamal-proxy drop a response that hasn't started after 30 s, and the visitor gets an empty reply instead |
|
|
93
|
+
| `RAILWATCH_SERVER_PROXY_IDLE_TIMEOUT` | `60` | seconds a response that has started may go without a byte moving; past it the connection is closed |
|
|
94
|
+
| `RAILWATCH_SERVER_NICE` | `10` | how far below the app's CPU priority the server runs (0-19; 0: the same). A busy dashboard can keep every core busy; measured in a 2-CPU container under 30 dashboard clients at the app's priority, the app's `/up` p50 went from 2 to 15 ms. Not under a CPU quota (`docker --cpus`, Kubernetes CPU limits): the kernel throttles the container's processes together whatever their priority, and in that 2-CPU container under `--cpus 2` nice 10 measured no better than nice 0 (`/up` p99 190-580 ms at either); on 2 dedicated cores (`--cpuset-cpus`) it took `/up` p99 from 207-363 ms to 18-22 ms. In a quota-limited container give it real cores (a cpuset, or a VM) or keep dashboard use light |
|
|
95
|
+
|
|
96
|
+
**Environments.** One environment per data dir, named after `Rails.env`:
|
|
97
|
+
development, staging and production usually run on different machines,
|
|
98
|
+
and each keeps its own history in `storage/railwatch/<env>`. Under
|
|
99
|
+
`Rails.env.test` the server is off by default (a test run would otherwise
|
|
100
|
+
report into the development server). The data dir records the environment
|
|
101
|
+
it was set up for (`environment`), and an app booted in another
|
|
102
|
+
environment against it refuses to wire the agent and says so, rather than
|
|
103
|
+
filing staging's telemetry under production. To see several environments
|
|
104
|
+
in one dashboard, run one shared self-hosted `railwatch-rs` and point each
|
|
105
|
+
environment's agent at it (`RAILWATCH_INGEST_URL`, `RAILWATCH_TOKEN`).
|
|
106
|
+
|
|
107
|
+
**The server's own settings** (alert and digest email, backups, Google
|
|
108
|
+
sign-in, retention, whether alerts are sent) come from the app, written on
|
|
109
|
+
every server start to `<data dir>/server.env` (0600) and never logged
|
|
110
|
+
(the log names only what was set: "settings from the app: smtp, backup"):
|
|
111
|
+
|
|
112
|
+
| server setting | from |
|
|
113
|
+
|---|---|
|
|
114
|
+
| SMTP | `config.smtp`, else Action Mailer's `smtp_settings` when `delivery_method` is `:smtp` |
|
|
115
|
+
| From | `config.mail_from`, else `ActionMailer::Base.default[:from]` |
|
|
116
|
+
| S3 backups | `config.backup`, else `credentials.railwatch_server.backup` (`endpoint bucket access_key_id secret_access_key`, optional `region prefix interval_seconds`) |
|
|
117
|
+
| Google sign-in | `config.google`, else `credentials.railwatch_server.google` (`client_id client_secret`) |
|
|
118
|
+
| retention | `config.retention = { detail_days:, core_days_max: }` |
|
|
119
|
+
| alert delivery | `config.alert_delivery = false` records alerts without sending |
|
|
120
|
+
| anything else | `config.server_env = { "RAILWATCH_NEW_ISSUES_PER_HOUR" => 50 }` |
|
|
121
|
+
|
|
122
|
+
`false` or `{}` turns one off where the app would otherwise supply it. An
|
|
123
|
+
incomplete backup is refused whole (logged, no backups) rather than
|
|
124
|
+
half-applied. `railwatch doctor` with `RAILWATCH_ENV_FILE` pointed at the
|
|
125
|
+
file shows what the server reads.
|
|
126
|
+
|
|
127
|
+
The agent is wired only when the app has not configured a destination:
|
|
128
|
+
when `RAILWATCH_TOKEN` is set, or the agent uses `transport :local`, it is
|
|
129
|
+
left alone and the bundled server serves only the dashboard.
|
|
130
|
+
|
|
131
|
+
**Who the server sees.** The dashboard's gate is the `railwatch` gem's:
|
|
132
|
+
HTTP Basic, on and closed by default; or, with Basic off,
|
|
133
|
+
`base_controller_class` (the proxy controller inherits from it, so your
|
|
134
|
+
`before_action :require_admin` runs first). Past the gate, the visitor is
|
|
135
|
+
named to the server by `Railwatch::Server.config.user`:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Railwatch::Server.configure do |c|
|
|
139
|
+
c.user = ->(controller) { u = controller.current_user; { email: u.email, name: u.name, role: u.admin? ? "admin" : "member" } }
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Without it, the HTTP Basic user name (or "Operator") is an admin. Users are
|
|
144
|
+
created in the server on first sight. Nobody signs in to the server itself.
|
|
145
|
+
|
|
146
|
+
## How it works
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
browser ── /railwatch/* ──> Rails (gate) ──> ProxyController ── 127.0.0.1:28790 ──> railwatch serve
|
|
150
|
+
+ X-Railwatch-Proxy-Auth (signed, 60 s) (RAILWATCH_BASE_PATH=/railwatch)
|
|
151
|
+
Puma workers, jobs ── railwatch agent ── /ingest ─────────────────────────────────────┘
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **Process.** `plugin :railwatch_server` spawns `railwatch serve` from
|
|
155
|
+
Puma's master after boot, in its own process group: a Ctrl-C reaches
|
|
156
|
+
Puma, Puma drains, and only then is the server stopped. A supervisor
|
|
157
|
+
thread checks every second and restarts a dead server with backoff
|
|
158
|
+
(1, 2, 4, 8, 15, 30 s), logging the death and the last lines of
|
|
159
|
+
`storage/railwatch/server.log`. Each check also asks the server's `/up`
|
|
160
|
+
(2 s timeout): one that is alive but stops answering (stopped, wedged)
|
|
161
|
+
is logged once as NOT ANSWERING, and after 8 s more of silence is
|
|
162
|
+
KILLed and restarted the same way (measured: a SIGSTOPped server was
|
|
163
|
+
replaced and answering in 11 s). At boot Puma waits at most 5 s for a
|
|
164
|
+
new server's `/up`, then boots without it and says so; a server that
|
|
165
|
+
hasn't answered once within 60 s of starting is killed and restarted. On stop: the railwatch agent's samplers
|
|
166
|
+
stop, the reporter flushes into the still-running server, then the
|
|
167
|
+
server gets SIGTERM (finishes in-flight requests, commits queued writes)
|
|
168
|
+
and KILL past the deadline. `server.pid` records "server parent"; a
|
|
169
|
+
server orphaned by a SIGKILLed Puma is stopped by the next start.
|
|
170
|
+
- **Proxy.** `/railwatch/*` is a prepended route to
|
|
171
|
+
`Railwatch::Server::ProxyController`. It forwards the server's cookies
|
|
172
|
+
and Inertia headers, sets `Host`/`X-Forwarded-Proto`, sets
|
|
173
|
+
`X-Forwarded-For` to the visitor's `request.remote_ip` (replacing any the
|
|
174
|
+
browser sent; the server runs with `RAILWATCH_TRUST_PROXY`, so its per-IP
|
|
175
|
+
sign-in and MCP limits count visitors, not the app), adds the signed
|
|
176
|
+
header, and hands the request off: **no Puma thread waits for the
|
|
177
|
+
server.** Past the gate the controller connects to the server and leaves
|
|
178
|
+
the request to `ProxyHandoff`, a middleware outside every other one,
|
|
179
|
+
which takes the browser's socket with Rack hijack and gives both
|
|
180
|
+
sockets to the process's one `Relay` thread (the live channel's, below).
|
|
181
|
+
The Relay writes the request (Puma has already read the body; a large
|
|
182
|
+
one comes from Puma's tempfile) and passes the response back as it
|
|
183
|
+
arrives, byte for byte except the head: `Connection: close` (the browser,
|
|
184
|
+
or the proxy in front of the app, opens a new connection for the next
|
|
185
|
+
request) and the app's own `Set-Cookie` (a rolling session) added. A
|
|
186
|
+
response the server hasn't started within the first-byte timeout is a
|
|
187
|
+
504 and one it drops before answering a 502, both written by the Relay;
|
|
188
|
+
one that goes quiet mid-body is cut off after the idle timeout.
|
|
189
|
+
Measured (`test/e2e/slow_dashboard.rb`, Puma 1 worker x 3 threads, pages
|
|
190
|
+
taking 3 s): before, 6 slow loads made the app's `/up` wait 5.7 s;
|
|
191
|
+
after, 4 ms. With 30 slow loads at once the worker kept its 13 threads,
|
|
192
|
+
RSS rose 131 -> 137 MB and `/up` stayed under 12 ms. Encodings pass
|
|
193
|
+
through untouched. Without Rack hijack, and under Puma's own TLS (`ssl_bind`;
|
|
194
|
+
its socket wrapper holds decrypted bytes `IO.select` can't see), the
|
|
195
|
+
response is streamed from the Puma thread instead, as before: headers
|
|
196
|
+
as soon as the server sends them, the body as Puma writes it. Behind
|
|
197
|
+
Thruster, kamal-proxy or nginx the app sees plain HTTP/1.1 and the
|
|
198
|
+
handoff applies; HTTP/2 ends at that proxy. It doesn't rewrite paths:
|
|
199
|
+
the server itself serves under `/railwatch` (below). CSRF is the
|
|
200
|
+
server's own (Origin check plus its token), so Rails' check is off for
|
|
201
|
+
this controller, and so is inertia_rails' (its `XSRF-TOKEN` at
|
|
202
|
+
`Path=/` shadowed the server's, refusing every write). inertia_rails'
|
|
203
|
+
middleware would also answer every dashboard click with a 409 full
|
|
204
|
+
reload, since the dashboard's Inertia version is the server's, not the
|
|
205
|
+
app's; the proxy removes `X-Inertia` from the request once it has been
|
|
206
|
+
forwarded, so the middleware leaves the response alone.
|
|
207
|
+
Commenting, resolving, saving a view and changing a setting are tested
|
|
208
|
+
through the proxy (`test/e2e/browser_writes.rb`, and by hand in Chromium).
|
|
209
|
+
- **Live channel.** `/railwatch/cable` is a Rack hijack too; the browser's
|
|
210
|
+
and the server's sockets are spliced by the same `IO.select` thread per
|
|
211
|
+
app process (`Relay`), not two threads per socket: 30 open dashboards
|
|
212
|
+
cost one thread. At most `max_live_connections` per process, counted
|
|
213
|
+
apart from dashboard requests; past it the handshake gets 503 and the
|
|
214
|
+
dashboard falls back to reloading.
|
|
215
|
+
- **Trust.** `storage/railwatch/proxy_secret` (0600, generated) is the
|
|
216
|
+
HMAC key. The header is `v1.<claims>.<HMAC-SHA256>` with
|
|
217
|
+
`email, name, role, exp`; the server refuses a bad signature, an expired
|
|
218
|
+
one, one valid for more than 5 minutes, and any role but admin/member
|
|
219
|
+
(401, logged). It never falls back to "signed out". The server binds
|
|
220
|
+
loopback only, so nothing outside the host can reach it unproxied.
|
|
221
|
+
|
|
222
|
+
## The server side (Rebulk/railwatch-rs)
|
|
223
|
+
|
|
224
|
+
The server features this gem relies on are upstream, in Rebulk/railwatch-rs
|
|
225
|
+
`main` from `ef87f72` (PR #16), and the dashboard's mount handling is in the
|
|
226
|
+
railwatch gem from 0.9.3. Every setting is off by default, so a self-hosted
|
|
227
|
+
install is unchanged. Documented in railwatch-rs `docs/SELF-HOST.md`,
|
|
228
|
+
"Running behind an app".
|
|
229
|
+
|
|
230
|
+
- **`RAILWATCH_BASE_PATH`**: pages are served under a prefix (`/railwatch`)
|
|
231
|
+
while `/ingest` and `/up` stay at the root, so the agent's URL doesn't
|
|
232
|
+
change. Redirects, cookie paths, asset/icon/entry URLs, emails, MCP
|
|
233
|
+
discovery and the Google callback all carry it.
|
|
234
|
+
- **`RAILWATCH_TRUSTED_PROXY_SECRET_FILE`**: the server accepts an
|
|
235
|
+
HMAC-signed `X-Railwatch-Proxy-Auth` header (email, name, admin/member
|
|
236
|
+
role, `exp` at most 300 s ahead) from this gem's proxy as the signed-in
|
|
237
|
+
user. The header is stripped from every request before any route sees it,
|
|
238
|
+
even with the mode off; when on, an invalid one is a 401, never a
|
|
239
|
+
fall-back. The proxy can't create or demote an owner.
|
|
240
|
+
- **`RAILWATCH_STANDBY_RELAY`**: the server holding `serve.lock` also
|
|
241
|
+
listens on `<data_dir>/serve.sock` (0600, peer uid checked); a second
|
|
242
|
+
`serve` on the same data dir binds its own loopback port at once and
|
|
243
|
+
relays to that socket until it gets the lock. This is what makes a
|
|
244
|
+
two-container deploy overlap lose nothing (see "Deploys" below).
|
|
245
|
+
|
|
246
|
+
**Why a base path, not rewriting in the proxy:** URLs are made in four
|
|
247
|
+
places (Rust redirects, Inertia's 409 `X-Inertia-Location`, js-routes in
|
|
248
|
+
the bundle, CSS/JS asset URLs) plus cookies and the cable URL. Rewriting
|
|
249
|
+
means parsing HTML, JSON page props and JS chunks, and it breaks the first
|
|
250
|
+
time the server adds a URL the proxy doesn't know about. With a base path
|
|
251
|
+
the server says the right URL once and the proxy stays byte-transparent.
|
|
252
|
+
|
|
253
|
+
## Building
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
script/build-server /path/to/railwatch-rs # railwatch-rs at or after ef87f72, frontend/dist built (bin/build, SKIP_CARGO=1)
|
|
257
|
+
rake gem:all # pkg/: ruby + 4 platform gems (~10 MB each)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Builds use the pinned `cargo-zigbuild` image from `bin/release`: static musl
|
|
261
|
+
for Linux, and that image's MacOSX 11.3 SDK for both darwin targets. All
|
|
262
|
+
four built here (2-3 min each, 4 jobs).
|
|
263
|
+
|
|
264
|
+
**macOS: built natively, signed ad hoc, run in CI.** `.github/workflows/macos.yml`
|
|
265
|
+
builds the frontend once on Linux, then on `macos-14` (arm64) and
|
|
266
|
+
`macos-15-intel` (x86_64; GitHub retired the macos-13 runners) builds the
|
|
267
|
+
server from the pinned railwatch-rs commit (`MACOSX_DEPLOYMENT_TARGET=11.0`, checked
|
|
268
|
+
with `vtool`: minos 11.0), signs it `codesign -s -`, verifies the
|
|
269
|
+
signature and the arch, and runs it: `--version`, `setup` in a temp dir,
|
|
270
|
+
`serve`, `/up` and `/ingest/ping`, then a second `serve` on the same data
|
|
271
|
+
dir that comes up as a standby and takes over when the first gets TERM.
|
|
272
|
+
Then it builds the darwin platform gem, installs it, checks the installed
|
|
273
|
+
binary's signature and runs it. Artifacts: the binary and the `.gem` per
|
|
274
|
+
arch. The zigbuild cross-build in `script/build-server` still works for
|
|
275
|
+
Linux; for darwin, use the workflow's artifacts (they are signed).
|
|
276
|
+
|
|
277
|
+
## Deploys and two processes
|
|
278
|
+
|
|
279
|
+
- **Same host, same data dir** (two Pumas, or `phased-restart` overlap):
|
|
280
|
+
the second app process finds the port already answered, starts its own
|
|
281
|
+
server anyway (it waits on `serve.lock`) and logs "standby". Its agent
|
|
282
|
+
reports to the first server, which owns the data. When the first app
|
|
283
|
+
process stops, its server drains and exits, the standby takes the lock
|
|
284
|
+
and the port (the log says "took over"). Measured: 10 + 10 requests
|
|
285
|
+
during the overlap and 10 after, 30 of 30 stored; one server writes at
|
|
286
|
+
any time.
|
|
287
|
+
- **Two containers sharing a volume** (Kamal blue/green): each container
|
|
288
|
+
has its own loopback, so the new container's agent can't reach the old
|
|
289
|
+
container's server. The new container's server (`RAILWATCH_STANDBY_RELAY`,
|
|
290
|
+
railwatch-rs `RAILWATCH_STANDBY_RELAY`) binds its own `127.0.0.1:28790` at once and splices
|
|
291
|
+
every connection, byte for byte, to `storage/railwatch/serve.sock`, where
|
|
292
|
+
the server holding `serve.lock` also listens. A Unix socket on the shared
|
|
293
|
+
volume crosses network namespaces (same kernel, same inode). When the old
|
|
294
|
+
container stops, its server drains and exits, the standby takes the lock
|
|
295
|
+
and serves the same listener itself; connections in that gap retry the
|
|
296
|
+
socket for up to 30 s. The log says "as a standby" then "took over".
|
|
297
|
+
|
|
298
|
+
Why this mattered (measured, `test/overlap/`, 3 runs before the fix): the
|
|
299
|
+
new container's first delivery was refused, the agent retained it and
|
|
300
|
+
retried on its ladder, and every retained retry doubled its backpressure
|
|
301
|
+
factor (1, 2, 4, 8). The sampler divides the request sample rate by that
|
|
302
|
+
factor, so for the ~12 s until the old server exited the new container
|
|
303
|
+
recorded 1 in 2, 1 in 4, then 1 in 8 of its requests. The dropped ones were
|
|
304
|
+
never recorded at all; nothing that reached the agent's buffer was lost.
|
|
305
|
+
186, 184 and 197 of ~480 requests went unrecorded, every one at a factor
|
|
306
|
+
of 2, 4 or 8, none at 1. With `RAILWATCH_BACKPRESSURE=false` the same
|
|
307
|
+
overlap stored 476/476: the batches themselves all arrived on retry.
|
|
308
|
+
Starting the server before Puma accepts requests (under `bin/rails s`)
|
|
309
|
+
also removes the one refused delivery at boot that cost 5-9 requests.
|
|
310
|
+
|
|
311
|
+
| handover, ~800 requests | served | stored once | lost |
|
|
312
|
+
|---|---|---|---|
|
|
313
|
+
| before, `docker stop` | 795 | 602 | 193 (new container: 186) |
|
|
314
|
+
| before, SIGKILL | 793 | 582 | 211 |
|
|
315
|
+
| after, `docker stop` | 795 | 795 | 0 |
|
|
316
|
+
| after, SIGKILL of the old one | 789 | 771 | 18, all the old container's last second |
|
|
317
|
+
| after, fresh `rails new` app (Rails 8.1, Solid Queue), `docker stop` | 865 | 865 | 0 |
|
|
318
|
+
| after, fresh `rails new` app, SIGKILL of the old one | 845 | 828 | 17, the old container's last second |
|
|
319
|
+
|
|
320
|
+
A SIGKILLed app loses what its agent had not flushed yet (up to
|
|
321
|
+
`flush_interval`, here 1 s): the records were in that process's memory.
|
|
322
|
+
The new container lost nothing in either run, and no record was stored
|
|
323
|
+
twice. Volumes on two different hosts are not supported (flock and Unix
|
|
324
|
+
sockets don't cross hosts; each host would need its own data dir, which
|
|
325
|
+
splits the history).
|
|
326
|
+
|
|
327
|
+
## Cost (same app, same 1000 requests, single-mode Puma)
|
|
328
|
+
|
|
329
|
+
| | idle RSS | RSS after 1000 req | CPU for 1000 req |
|
|
330
|
+
|---|---|---|---|
|
|
331
|
+
| this gem: `railwatch serve` | 12 MB | 51 MB | 0.15 s |
|
|
332
|
+
| railwatch gem embedded: writer process | 87 MB | 138 MB | 0.08 s |
|
|
333
|
+
| Puma itself (either way) | 126 MB | 162-166 MB | 3.2-3.8 s |
|
|
334
|
+
|
|
335
|
+
The Rust server is a third of the Ruby writer's memory. The Ruby writer
|
|
336
|
+
spends less CPU only because it writes rows and does no grouping,
|
|
337
|
+
rollups, alerts or the dashboard. Rendering the dashboard costs the
|
|
338
|
+
sidecar, not Puma (Puma only proxies), whereas the embedded dashboard
|
|
339
|
+
renders inside Puma's workers.
|
|
340
|
+
|
|
341
|
+
## Limitations and TODOs
|
|
342
|
+
|
|
343
|
+
- Dashboard connections close after each response (no keep-alive
|
|
344
|
+
between the browser and the proxy for `/railwatch`). On loopback or
|
|
345
|
+
behind a reverse proxy that costs nothing measurable; a browser talking
|
|
346
|
+
straight to Puma over the internet opens a new TCP connection per
|
|
347
|
+
dashboard request.
|
|
348
|
+
- The cap is per process and checked before connecting, so a burst can
|
|
349
|
+
pass it by as many requests as Puma has threads.
|
|
350
|
+
- macOS binaries are ad-hoc signed only: fine for a gem installed with
|
|
351
|
+
Bundler (RubyGems sets no quarantine flag). A notarized release needs an
|
|
352
|
+
Apple Developer ID certificate in CI (`codesign -s "Developer ID
|
|
353
|
+
Application: ..." --options runtime --timestamp`, then `notarytool
|
|
354
|
+
submit --wait`); a bare executable can't be stapled, so Gatekeeper checks
|
|
355
|
+
it online the first time it runs from a quarantined download.
|
|
356
|
+
- One environment per data dir (above).
|
|
357
|
+
- The account owner is a placeholder user with a random password nobody
|
|
358
|
+
knows; the proxied users are admins/members. Transferring ownership
|
|
359
|
+
works in Settings > Members.
|
|
360
|
+
- Releases must keep building the platform gems from a published
|
|
361
|
+
railwatch-rs commit (AGPL section 6): `RAILWATCH_RS_SHA` in
|
|
362
|
+
`.github/workflows/macos.yml`, and the same commit for the Linux builds.
|
|
363
|
+
|
|
364
|
+
## License
|
|
365
|
+
|
|
366
|
+
AGPL-3.0-only (LICENSE). The platform gems contain the Railwatch server
|
|
367
|
+
binary; NOTICE explains what that means for your app (nothing: it runs as
|
|
368
|
+
a separate program) and where the corresponding source is.
|