Rails 8.1 / Ruby 4 app that maps USGS water monitoring locations (water level, flow, temperature) with cached station and state pages.
- PostgreSQL, Redis, Sidekiq (+ sidekiq-scheduler)
- Tailwind CSS v4, esbuild, Stimulus, Leaflet, Chart.js
- ViewComponent (sidecar)
rvm use 4.0.4
nvm use # Node 20+ required (see .nvmrc / .node-version)
bundle install
yarn install
createdb waterlevels_development waterlevels_test
bin/rails db:migrate
cp .env.example .env # add USGS_API_KEY
bin/devBootstrap USGS data (rate-limit aware; prints progress). Catalog sync keeps active continuous water-body sites only (streams/lakes/estuaries with current latest-continuous data — not the full USGS well archive).
With the Sidekiq worker running, enqueue staggered per-state catalog+latest jobs:
bin/rails usgs:enqueue_bootstrap
# optional: STATE=wa DELAY_SECONDS=120Hourly LatestObservationSyncJob keeps readings fresh. Hourly FloodStageSyncJob refreshes NWS NWPS flood categories from the national gauge list (by LID), prioritizes linking any currently flooding unlinked gauges, and discovers stage thresholds via USGS site-number detail lookups (STATE=wa bin/rails nwps:sync_flood_stages, or bin/rails nwps:enqueue_sync for staggered per-state jobs). Bootstrap also runs flood sync per state. Hourly HistoryBackfillBatchJob fills gap-aware continuous history (up to ~35 days) and year daily history into R2 in batches (gauge page views also enqueue a station when charts are empty). Prefer this over a national one-off usgs:bootstrap on a small dyno.
STATE=wa bin/rails usgs:purge ALL=1 # wipe a bad/partial import
STATE=wa bin/rails usgs:bootstrapOptional history backfill after bootstrap:
STATE=wa RANGE=1y LIMIT=25 bin/rails usgs:backfill
# optional deep daily fill after year history exists:
STATE=wa RANGE=3y LIMIT=25 bin/rails usgs:backfillTunables: USGS_REQUEST_PAUSE_MS (default 100 outside test), HISTORY_BACKFILL_BATCH (default 50 stations per cron tick for cold 1y work), HISTORY_DEEP_BACKFILL_BATCH (default 400 stations for 3y deep fills; set 0 to pause). History backfill pins one USGS key per purpose (USGS_API_HISTORY_CONTINUOUS_KEY / _DAILY_KEY / _PEAKS_KEY) and opens that purpose’s circuit on a 429 for the rest of the UTC hour. The batch runs Mon–Sat every 10 minutes and no-ops when all purpose circuits are open or the backfill queue is still draining. Circuit state per key is on /admin.
See doc/postgres-r2-daily-archive.md (current R2-first retention), doc/plan-3y-daily-history.md (historical 3y plan), and doc/future.md (hourly POR) for retention tiers and longer-history notes.
Local archive iteration (no Cloudflare): .env.example sets DAILY_ARCHIVE_STORE=local. After seeding or backfill, run bin/rails archive:export_daily — shards land in tmp/daily_archive and 1y / 3y charts read them when DAILY_ARCHIVE_READS=1.
OpenTelemetry traces export to Honeycomb when OTEL_EXPORTER_OTLP_* is set (see .env.example). ActiveRecord, PG, Redis, and Net::HTTP auto-spans are disabled to stay within event budgets; domain spans via Telemetry remain. Query recipes: doc/honeycomb-queries.md.
bin/rails test- Dynos:
web,worker(default queue + scheduler),sync_worker(syncqueue),historical_worker(backfillqueue) - Add-ons: Postgres, Redis
- Set
USGS_API_KEY(tip/catalog), optionalUSGS_API_HISTORY_CONTINUOUS_KEY/USGS_API_HISTORY_DAILY_KEY/USGS_API_HISTORY_PEAKS_KEY(purpose-pinned history backfill),REDIS_URL,DATABASE_URL,APP_HOST,SENTRY_DSN; optionalCLOUDFLARE_ZONE_ID+CLOUDFLARE_API_TOKENfor post-sync Cache-Tag purge; optionalCLOUDFLARE_R2_*for the yearly daily-means archive (doc/postgres-r2-daily-archive.md) - Enable runtime dyno metadata so
HEROKU_RELEASE_VERSIONis available; Sentry uses it as the release and tags environment asproduction - Open Graph PNGs are rendered with
rsvg-convert(Aptfile→librsvg2-bin). Requiresheroku-community/aptas buildpack #1 (before Ruby) so the Aptfile packages install on the dyno. - Redis TLS: Sidekiq, cache, and Action Cable use
ssl_params.verify_mode = VERIFY_NONEfor Heroku self-signedrediss://certs - After deploy:
heroku run bin/rails usgs:enqueue_bootstrap -a <app> - Optional:
MALLOC_ARENA_MAX=2if worker RSS climbs - Put Cloudflare in front; honor
Cache-Control/Cache-Tagfrom the app. Use a Cache Rule (Eligible for cache + Origin Cache Control) for public HTML; bypass/contact,/admin, and/api/*. Public pages skip the Rails session cookie so HTML is not forced toBYPASS. - Internal
/api/*JSON is first-party-only (X-WaterLevels-Client: web+ same-origin browser context), returnsprivate, no-store, and is cached in Redis viaApiResponseCache(invalidated when syncs bump generation counters). - Optional ops dashboard at
/adminwhenDASHBOARD_PWis set (session login at/admin/login). Returns 404 when the env var is unset. Login attempts are rate-limited (Railsrate_limit, 10 per 3 minutes per IP). Sidekiq Web is at/admin/sidekiqbehind the same session. - Cold first request: Eco/Hobby web dynos sleep when idle; the next hit waits for Puma/Rails boot (often multi-second). Prefer an always-on web dyno, or ping
/upevery few minutes. Puma also warms DB/Redis/SiteStatson boot so a post-sleep origin render is cheaper once the process is up.
GET /contact is served by PagesController (not edge-cached). POST /contact uses ContactMessage + invisible_captcha + Cloudflare Turnstile, then ContactMailer.
Set in .env:
TURNSTILE_SITE_KEY(defaults to the existing widget) /TURNSTILE_SECRETCONTACT_TO_EMAIL/MAIL_FROMBENTO_SITE_UUID,BENTO_PUBLISHABLE_KEY,BENTO_SECRET_KEY(Action Mailer viabento-actionmailer+premailer-rails)
- Map may be empty until catalog sync lands locations.
- Temperature is stored in °C; UI defaults to °F via a preference cookie.
- Local PostGIS is optional; nearby stations use haversine precompute, map bbox uses lat/lon indexes.