Repository navigation
Development
bin/docker-test # all three suites on PostgreSQL, in Docker — preferred
bundle exec rake test # needs a PostgreSQL cluster and DATABASE_URL
bundle exec cucumber # needs a PostgreSQL cluster and DATABASE_URL
bundle exec brakeman -q # static analysis, must report 0 warnings
bundle exec bundle-audit check # dependency CVEsbin/docker-test needs only Docker. It builds the image, runs minitest and
cucumber against the Compose db service, and then repeats the two assertions the
CI test job makes: that events and events_words really are partitioned, and
that no index was left INVALID by a failed CONCURRENTLY build. Extra arguments
replace the container command rather than appending to it, so
bin/docker-test bundle exec rails test test/models/event_test.rb runs one file
and bin/docker-test bash opens a shell inside it.
PostgreSQL is the only engine, in every environment. SQLite was dropped as the
development and test engine: it cannot partition events/events_words, so a
SQLite run never exercised the shape production queries, and the adapter-conditional
skips it caused are what made a green suite meaningless. The suite now reports
0 skips — 542 unit/controller runs plus 40 system runs, 1883 assertions in
total — and a green run means the partitioned path actually ran.
| command | covers |
|---|---|
bundle exec rake test |
models, ingest, controllers |
bundle exec rake test:system |
rendered HTML — the layout, nav bar, partials, asset tags |
bundle exec cucumber |
the ingest and search feature files |
rake test excludes test/system by design: railties sets DEFAULT_TEST_EXCLUDE
to test/{system,dummy,fixtures}/**/*_test.rb. So the browser-level suite needs its
own task, or it never runs — which is how a whole class of view breakage reached
master. bin/docker-test and CI both run all three.
The system tests use rack-test, not a real browser, and that is deliberate. A
real browser is not viable here: ApplicationController#ssl forces http → https
outside development, and there is no matching chromium-driver for the installed
Chromium, so Selenium fails at session creation. The https requirement is satisfied
the way the controller tests already satisfy it, with HTTPS=on on the request env.
The consequence matters when you write one: rack-test runs no JavaScript. Assert
on the static shell the server rendered, never on AJAX-populated content — the
dashboard's #events and #stats divs are empty here. Where the interesting
behaviour is a script, render the script and assert on its text:
test/controllers/dashboard_render_test.rb renders /events.js and pipes it
through node --check.
Use the cucumber binary, not rake cucumber:ok — the rake task runs cucumber
in-process and minitest's autorun then dies on cucumber's --profile flag.
Two support files carry the machinery the suite needs, and both exist because the simple approach does not work:
-
test/support/scratch_database.rbcreates a throwaway database per test for the ingest tests that run the importer in a subprocess. They cannot share the test database: transactional fixtures hold an open transaction, so the child's committed writes are invisible to the parent and vice versa. It opens its own rawPGconnection rather than going through ActiveRecord, becauseCREATE DATABASEfails withPG::ActiveSqlTransactioninside a transaction. The role behindDATABASE_URLtherefore needsCREATEDB;bin/docker-testand CI run as the cluster superuser. -
test/support/query_plan.rbgives testsexplain(sql, binds). It setsenable_seqscan = offdeliberately: on a test-sized table the planner is entitled to prefer a sequential scan, so asserting its cost choice asserts a guess. Turning scans off asks the question the finding was about — can this index serve this query shape at all?
docker compose up --build # migrate, then web + importer + scheduler + syslogBrowse to http://localhost:3000 and sign in as admin / dad_dev_password
(override with DAD_ADMIN_PASSWORD=... docker compose up). Without a seeded
account the stack comes up with zero users: the login form renders fine but can
never succeed, because db/seeds.rb raises rather than inventing a well-known
account. The migrate service seeds only when the users table is empty —
seeding on every start would re-create the maintenance Jobs an operator deleted.
One image, one container per long-lived
process — db (Postgres 17), migrate (one-shot), web, importer, scheduler,
syslog — mirroring the three systemd units rather than supervising all three in
one container, so the container topology is the topology production runs.
docker compose logs -f importer
docker compose exec db psql -U dad dad_development
docker compose down -v # discard the database tooFour things that will otherwise cost you an afternoon:
-
The image
WORKDIRis/var/DAD, not/app. The collector hardcodesDEFAULT_STAGING="/var/DAD/Logs/LogStaging", whileimport_syslog.rbuses paths relative to its own working directory (Dir.chdir('Logs/LogsToProcess'), thenPROCESSED_DIR = '../ProcessedLogs'). The two agree only because every systemd unit setsWorkingDirectory=/var/DAD. Nothing validates that they line up: pick any other working directory and the collector keeps accepting UDP packets and writes into a tree the importer never reads — silently, with no error anywhere. -
Rotation is traffic-driven.
ensure_openis called from the per-message path, so with no inbound traffic the open staging file is never moved intoLogsToProcessand the importer sees nothing — a quiet system looks like a broken importer. A single probe packet sits inLogStaginguntil a second packet arrives after the 60s boundary, which is why thedockerjob sends two. The collector flushes each record as it writes, so the staging file is readable immediately rather than only after rotation. -
5432 is not published by default. The app containers reach
dbover the Compose network by service name; publishing the port collides with a Postgres already running on your machine anddocker compose upfails with "address already in use" before any container starts. For host access use the opt-in override, bound to loopback because the credentials aredad/dad:docker compose -f docker-compose.yml -f docker-compose.db-port.yml up -
Code is baked into the image, not bind-mounted. The image runs as uid 1000;
mounting a checkout owned by a different uid makes Rails'
log/andtmp/writes fail withEACCESin a way that reads like an app bug. The gem layer is cached, so after an editdocker compose up --buildonly re-runsCOPY . ..
web runs with RAILS_ENV=development, not production: production sets
public_file_server.enabled = false and assets.compile = false because Apache
serves assets, and there is no Apache in the stack. A production-mode container
boots fine and 404s every s
8000
tylesheet and script.
The migrate service uses db:migrate_from_scratch on an empty database and plain
db:migrate afterwards. Plain db:migrate on an empty Postgres database loads the
committed portable db/schema.rb — which cannot express PARTITION BY — and skips
db/migrate entirely, so the partitioned shape is never built. And without a schema
at all, the importer crash-loops on relation "statistics" does not exist.
A CI job now builds this image. It did not used to, which is how a
Dockerfile.dev with a bare gem install bundler line — not a Dockerfile
instruction, so the image could never build — sat on master unnoticed. The
docker job in .github/workflows/ci.yml builds it, boots the stack, and then
drives it: it asserts the migrate service left a partitioned
events/events_words and at least one user, that the login page actually
renders, that the app signs in and loads /events, that an asset path the page
emitted returns 200, and that a UDP packet sent to syslog:514 ends up as a row
in events. That last one is the only automated end-to-end test of the collector
through the importer — no Ruby-level job would notice it failing to start. The
collector job covers its unit and integration tests, which docker does not run.
The same job also boots the windows profile and drives the Windows path end to
end: it copies a fixture in as openwec.log, then asserts a row reaches
events joined to the expected systems row. Keying on the fixture's
EventRecordID rather than editing it to carry a probe marker keeps the fixture
looking like real upstream output. Without this leg, a compose service that could
not start or a forwarder that could not find its input would have shipped
unnoticed — the same gap the syslog assertion exists for. See Windows Events.
Two details that cost a debugging cycle each:
- The probe line must carry a syslog timestamp.
EventIngestorparses one off the front and counts an unparseable line as a failure rather than storing it, so a bare probe proves only that UDP is open. - The wait must poll, not sleep a fixed interval. Rotation needs a packet after
the 60s boundary and the importer scans
LogsToProcessonce a minute, so packet-to-row latency is 0–60 s on top of that.
CI steps run bash -e, so a bare code=$(curl ...) aborts the step if curl
fails — the assignment carries its exit status. While Rails is still binding, the
first request can be reset rather than refused, and that killed the boot poll on
the runner against a perfectly healthy stack. Guard polling commands with
|| fallback.
config/database.yml uses the PostgreSQL adapter in development, test and
production. The old split — SQLite in development and test — is what produced the
worst bug in the project's history: Event.insertStatement emitted SQLite ?
placeholders through exec_query, Postgres rejected them outright, and production
could not insert a single event while every CI job stayed green. pg also sat in
the :production Gemfile group, so any job that set BUNDLE_WITHOUT=production
silently lacked the driver it needed.
To run without Docker, point DATABASE_URL at a local cluster:
createuser dad && createdb -O dad dad_test
export DATABASE_URL=postgres://dad:dad@127.0.0.1:5432/dad_test
export SECRET_KEY_BASE=not_a_secret # require_secrets.rb fails the boot without it
bundle exec rake db:migrate_from_scratch
bundle exec rake test
bundle exec cucumberdb:migrate_from_scratch (not db:migrate) is what builds the partitioned shape
the tests assert. db:migrate loads the committed db/schema.rb dump into an empty
database and skips db/migrate, so the partitioning, backup and index tests then
fail in ways that read like application bugs.
db/schema.rb is still committed, as a portable, non-partitioned dump. It is
what the subprocess tests load into a scratch database, because those tests want the
tables without the partition machinery. It is not the schema production runs, and
config/environments/test.rb sets dump_schema_after_migration = false so a test
run never rewrites it. maintain_test_schema is unconditionally false for the
same reason: Rails purges and reloads schema.rb before a run when a migration is
newer than the dump, which would install the non-partitioned shape over what
db/migrate built. That setting used to be conditional on DATABASE_URL matching
postgres:// — with PostgreSQL now the default, the common case is DATABASE_URL
being unset, so the old condition evaluated to true and would have done exactly
that damage on every run.
Raw SQL is still written with ? and routed through SqlPlaceholders.query, which
rewrites it to $1, $2, …. That is not SQLite support; it is the single seam where
the form Rails documents becomes the form PostgreSQL wants, and it is the seam the
bug above slipped through.
Cucumber against a remote database needs one extra allowance. DatabaseCleaner treats
any host outside localhost / 127.0.0.1 / *.local as remote and refuses to clean it,
so the Compose service name db trips the safeguard and every scenario errors before
its first step. features/support/env.rb whitelists exactly that URL rather than
clearing allow_remote_database_url, so a hand-set DATABASE_URL aimed at a real
server still fails loudly. The whitelist must be set before
strategy = :transaction: assigning a strategy constructs a cleaner, and the cleaner
runs its safeguards in its constructor.
The app has no build step, no bundler, and no framework. Sprockets concatenates
app/assets/javascripts/ and app/assets/stylesheets/ into one JS bundle and one
CSS bundle, and production serves them through Apache, not Rails.
Assets. There is exactly one stylesheet, application.css. It used to be two:
application.css plus a stock scaffolds.css.scss that had never been edited since
the app was generated. Both went into the same bundle and neither referenced a
selector the other defined, so they were merged. The scaffold rules still matter —
every generated _form.html.erb uses div.field, div.actions and
#error_explanation, every show page uses #notice, and .field_with_errors is
emitted by Rails itself and appears in no view. Deleting them would restyle every
form.
config.assets.compile = false and config.assets.digest = true in production mean
precompile writes only digested filenames, and Apache — not Rails — answers the
request. So a hardcoded path like src="/assets/spinner.gif" 404s in production even
though it works in development, where the pipeline compiles on demand. Use the
helpers. script/check_asset_paths.rb runs in CI after a production precompile and
fails if any emitted path has no file behind it.
JavaScript. application.js is the manifest plus the five draw_* chart helpers;
dad_charts.js holds the Chart.js plugins that supply the bands, rules and gauge
Highcharts had built in; events.js and systems.js are per-page bootstrap. The
draw_* names and argument lists are kept from the Highcharts era because the
.js.erb templates call them positionally.
The app has no .coffee files, but coffee-rails cannot be removed:
//= require turbolinks pulls in turbolinks-2.5.4's own turbolinks.js.coffee,
and Gemfile.lock shows turbolinks (2.5.4) → coffee-rails. Removing the gem fails
the production precompile.
Two traps specific to this layer:
- A call inside a jQuery options object runs while the object is being built.
complete: $.ajax()calls$.ajax()with no options; jQuery then usesajaxSettings.url, which is the current page, so it fires a stray XHR — and the returned jqXHR is dropped as a callback, becauseCallbacks.addignores anything that is not a function. CI greps the built bundle for[A-Za-z_$]+\.ajax\(\s*\). - Comments in a
.js.erbfile are shipped to the browser. Use<%# %>for a note that should stay in source; a//note there is part of the response and can trip an assertion made against that response.
Every id in rendered HTML must be unique. Browsers resolve getElementById and
#id CSS to the first match, so every later element sharing an id is silently
unaddressable. A scan of rendered HTML (not source) once found 209 duplicate ids
across 17 pages — the nav bar's li#active/li#inactive on every page, the alert
table's per-row ids, and 23 views re-emitting the layout's <p id="notice">. They
are now classes (.criticality-5, .alert-time, li.active) and the layout owns
the single #notice. test/system/unique_ids_test.rb fails on any duplicate id in
rendered output, so this cannot regress.
Forms. The error summary is one partial,
app/views/shared/_error_explanation.html.erb, rendered as
render 'shared/error_explanation', record: @model. It used to be the same 11-line
block copy-pasted into all 14 _form.html.erb files, with the loop variable already
drifted between |msg| and |message|. The heading noun comes from
record.model_name.human.downcase, not a hardcoded string.
Responsive. The layout declares width=device-width, initial-scale=1 and
lang="en". Below 720px the nav bar tabs wrap and the alert queue stops being a
fixed-width table. There is no hamburger menu by design — it would need JavaScript
the app does not have. test/system/responsive_test.rb guards the markup these
rules key off.
Charts run on Chart.js 4.4.7 (MIT), vendored at
vendor/assets/javascripts/chartjs/chart.umd.js. It replaced a vendored Highcharts
4.0.4 build that was an unlicensed commercial dependency. Chart.js lacks Highcharts'
plotBands/plotLines and gauge series, so those live as plugins in
app/assets/javascripts/dad_charts.js (dadBands, dadGauge, dad_enable_x_zoom).
Uglifier was replaced by terser, because UglifyJS 2.x is ES5-only and aborts on
Chart.js's ES2015 output.
Read the comments in app/models/event_ingestor.rb and lib/log_queue.rb. The
checkpoint and the batch flush are coupled, and getting the ordering wrong either
loses rows or duplicates them. Ingest tests drain the queue (flush_and_wait)
between staging steps so exactly one writer is active — that is what makes them
deterministic instead of racing the insert thread opening a second connection while
the main thread holds a write lock.
All ingest state must be initialised in resetCaches. It is the single place
that class-level state is defined. Two defects came from variables it did not
cover: @cachelifetime was never assigned anywhere, so the first prune_words
on a long ingest raised TypeError: can't convert NilClass into an exact number
and killed the importer mid-file; and the statement-parameter ceiling needed a
home alongside it.
Some ingest limits are invisible to an in-process test run.
DEFAULT_BULK_INSERT_SIZE is 1 in development and test, so every in-process test
flushes one event at a time and no test can reach a production-sized batch. That
is how a batch exceeding the adapter's bind-parameter ceiling survived: 2000
events is roughly 16 events_words rows each, about 96,000 parameters against
PostgreSQL's ceiling of 65,535, and past the ceiling the adapter raises instead of
degrading. Only subprocess tests with RAILS_ENV=development cross it — see
test/models/event_ingest_limits_test.rb. Same shape as the SqlPlaceholders
failure above: green in the suite, broken in production.
Words are resolved in bulk at flush time, not per token during staging.
stage_event defers any token missing from the word cache into @staged_tokens,
and performPendingInserts resolves the batch through Word.resolve_ids before
building the statements. Three things that shape protects, and that any change
here has to keep:
-
@staged_tokenscarries the event id andgeneratedvalue per event, not just per batch. Dropping a token makes an event no search can find; attributing it to the wrong event makes a wrong search hit. Neither raises. - Resolution is all-or-nothing. Rows are built locally and appended only once
every token has an id, and
storeEvent's rollback unwinds@staged_tokensalong with the other two staging arrays. The importer's per-linerescuecallsflush_and_wait, so a partial append followed by a retry would write some index rows twice and return the same event twice from search. -
words.texthas no unique index, so one text can already have several rows.Word.resolve_idskeeps the lowest id, which is what the old per-tokenfind_byreturned. Apluck().to_hkeeps the highest instead, silently pointing new index rows at a different word than the existing ones use — and searches for that token then stop matching the older events.
Truncation to the 255-character column lives in Word.truncate_for_column and is
applied by both stage_event and resolve_ids. If lookup and storage ever
disagree about the form of a long token, every token over 255 characters misses on
every pass and inserts a fresh row each time. Word.find_or_add is the
single-token form of resolve_ids; do not reimplement it alongside it.
Throughput regressions here are silent — nothing fails, ingest just gets slower.
test/models/event_ingest_word_resolution_test.rb asserts the mechanism rather
than a timing: that the (event_id, word_id) rows written match the tokens, that
each token is attributed to its own event, and that a batch whose tokens are all
cached issues no statement against words at all.
syslog_collector/ replaced syslog.pl. It is a zero-dependency crate — std
only, so there is no supply chain to audit and no dependency lock to maintain.
Build and test it on its own:
cd syslog_collector && cargo test # 26 unit + 13 integration
cargo fmt --all -- --check && cargo clippy --all-targets -- -D warningsThe integration tests spawn the real binary on high UDP ports and compare bytes on
disk, so they need neither root nor Postgres. The DAD_SYSLOG_* variables exist
for exactly that: production uses the defaults, and the tests override the port
(above 1024) and the log directories.
What is the contract. The collector appends <source-ip> <message>\n.
EventIngestor stores that line whole in events.hunks, and Event#event_fields
splits it on spaces — so the source IP is field 1 and the syslog timestamp follows
it. The read side depends on the write side's formatting. The parsing is
byte-oriented (&[u8], never String) because Perl never decoded: a payload with
invalid UTF-8 reaches hunks verbatim, and EventIngestor does the scrubbing.
Three deliberate departures from the Perl collector, all because it lost data. Measured, not assumed:
- It block-buffered stdio with no signal handler. 40 messages sent, 0 bytes durable after both SIGTERM and SIGKILL. The Rust collector flushes per record; measured at 20 000 datagrams, that costs nothing (~400 k msgs/s).
- It left the in-flight file stranded in
LogStagingon every restart, and nothing readsLogStaging— so up to 60s of logs were orphaned permanently, including on a plannedsystemctl restart.recover_orphans()claims them at startup, which covers SIGKILL, OOM, and power loss in a way a shutdown handler cannot. -
recv($buf, 1540)silently truncated anything larger. The cap is now 65 507, the maximum UDP payload;events.hunksis a:textcolumn, so no schema change.
Everything else is byte-identical, verified by running both collectors on UDP/514 against the same 22-packet corpus: the rotated file the importer reads was the same 2116 bytes with the same SHA-256. That equivalence check is not automated — it needed root, port 514, and a 61s rotation wait, and the Perl collector no longer exists to compare against. If you change the parse, re-verify the record format by hand.
- Ruby 3.3.8, Bundler 2.6.7, Rails 8.1.4.1. The upgrade path was 4.2 → 5.2 → 6.1
→ 7.1 → 7.2 → 8.0 → 8.1.4 (tag
rails-8.1.4), one commit per minor step; the lock now sits on the 8.1.4.1 patch release. -
minitest ~> 5.25is load-bearing and easy to "clean up" by mistake: minitest 6 narrowedMinitest::Test.run, and railties' TestUnit runner still calls it with three arguments. -
rspec-railsis required even though there is nospec/directory: cucumber-rails only mixesRSpec::MatchersintoCucumber::Rails::Worldwhenrspec/rails/matchersis loadable, and the step definitions callexpect. - The app never calls
config.load_defaults, so noload_defaults-gated Rails default is ever applied here — every behaviour change must be opted into by hand. -
Gemfile.lockanddb/schema.rbare tracked on purpose; do not re-add them to.gitignore.
AGENTS.md in the repo root carries the full set of upgrade landmines and is the
best starting point for anything version-related.