Boilerplate for a Ruby GraphQL API (ruby/) and a Vue client (vuejs/).
The API uses PostgreSQL via Sequel. Connection details for each environment
live in ruby/.env.development and ruby/.env.test as a DATABASE_URL.
-
Start Postgres:
docker compose up -d db
On a fresh volume,
docker/postgres/init-databases.shreadsruby/.env.developmentandruby/.env.testand creates the role and database for each environment automatically. -
If the volume already existed before a
.env.*file's user, password, or database name changed (so the init script above didn't run again), create or update the role/database for an environment directly:cd ruby bundle exec rake db:init # uses APP_ENV, defaults to development bundle exec rake db:init[test] bundle exec rake db:init[development]
This is safe to re-run; it only creates what's missing.
-
Run migrations for each environment:
cd ruby APP_ENV=development bundle exec bin/migrate up APP_ENV=test bundle exec bin/migrate up
With the test database migrated, cd ruby && bundle exec rspec will run
against it (APP_ENV defaults to test in specs).
With the database set up (above), run the API and the client in two separate terminals.
-
Ruby API (GraphQL + REST) —
bin/serverbinds to port 9292, which is where the Vue client expects it, so the only thing to set is the Vite dev origin CORS allows:cd ruby CORS_ALLOWED_ORIGIN=http://localhost:5173 bundle exec bin/server
Verify it's up:
curl http://localhost:9292/healthzshould return{"status":"ok"}. To bind elsewhere, setLISTEN_ADDR(e.g.LISTEN_ADDR=http://0.0.0.0:4000) and point the client at the new address withVITE_GRAPHQL_URL. -
Vue client (Vite dev server, defaults to port 5173):
cd vuejs npm install # first time only npm run dev
Open http://localhost:5173 — it talks to the API at
http://localhost:9292/graphqlby default (override with aVITE_GRAPHQL_URLenv var, e.g. invuejs/.env.local, if you bind the API elsewhere).
If you change the GraphQL schema, regenerate the client's typed operations before restarting Vite:
cd ruby && bundle exec rake graphql:schema:dump
cd ../vuejs && npm run generateAlongside GraphQL, a set of Roda apps serve a versioned, RESTful /api
surface through the same App Rack app and Falcon process — no separate
server or port. App#route dispatches any /api/* path to it via
Rack::URLMap. Routing is split across three layers, each its own file:
app/api/api_app.rb(Api::App) — mounted at/api. Handles CORS preflight for the whole API and dispatches/api/v1/*toApi::V1::App.app/api/v1/app.rb(Api::V1::App) — mounted at/api/v1. Gates each resource by name and hands the rest of the request to its own sub-app.app/api/v1/notes.rb(Api::V1::Notes) — the boilerplate example resource, mounted at/api/v1/notes, backed by the sameNotemodel andServices::SaveNoteservice the GraphQL mutation uses.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/notes |
List all notes |
| GET | /api/v1/notes/:id |
Fetch one note (404 if missing) |
| POST | /api/v1/notes |
Create a note (422 if invalid) |
| OPTIONS | /api/v1/notes |
CORS preflight |
curl http://localhost:9292/api/v1/notes
curl -X POST http://localhost:9292/api/v1/notes \
-H 'Content-Type: application/json' \
-d '{"note": "remember the milk"}'Add a new resource under the current version by creating
app/api/v1/<resource>.rb (a Roda sub-app, following notes.rb) and
mounting it with an r.on('<resource>') { r.run Api::V1::<Resource> } line
in app/api/v1/app.rb. Keep each resource's routes thin, delegating to
Services:: for behavior — the same convention GraphQL mutations follow.
Start a new version by adding app/api/v2/app.rb and mounting it from
app/api/api_app.rb the same way v1 is mounted.
bin/console loads the app (lib/environment.rb) and drops you into IRB, so
models, services, and DB are available directly:
cd ruby
bin/consoleirb(main):001> DB.table_exists?(:notes)
=> true
Background jobs run via Resque, backed by
Redis. Connection details live in ruby/.env.development and
ruby/.env.test as REDIS_URL (each environment uses a different Redis
database index so they don't share state).
-
Start Redis:
docker compose up -d redis
-
Start a worker (in its own terminal), listening on every queue:
cd ruby QUEUE=* bundle exec rake resque:work
To check a worker can actually reach Redis and process a job, enqueue the
built-in smoke-test job (Jobs::IncrementCounterJob, in
ruby/app/jobs/increment_counter_job.rb), which just increments a counter
key:
cd ruby
bundle exec rake resque:test_enqueueWith a worker running, it'll pick the job up and increment test_counter
(stored as resque:test_counter, since Resque namespaces all of its own keys
under resque:). Confirm it worked:
docker compose exec redis redis-cli -n 0 get resque:test_counter(use -n 1 instead if you're checking against the test environment's Redis
database).
Application code talks to Redis through RedisConnection
(ruby/lib/redis_connection.rb), which wraps
async-redis rather than the
blocking redis gem Resque uses. Falcon serves every request in a fiber on a
single reactor per worker, so a blocking command would stall every other
in-flight request; async-redis yields to the reactor instead and gives each
fiber its own pooled connection.
It reads the same REDIS_URL as Resque, including the database index, and
connects lazily, so no sockets exist at boot for Falcon's workers to inherit
when they fork.
# Inside a request, a reactor is already running:
RedisConnection.with { |redis| redis.call('GET', 'some:key') }with also works from a rake task, bin/console, or a spec — it starts a
reactor when there isn't one and blocks until the block returns.
Once you've started the pieces above, check they're all actually up:
bin/doctorIt checks Postgres, Redis, the Ruby API, a Resque worker, the Vite/Vue
client, and the observability backend, reading connection details from
ruby/.env.development. Override API_URL / VITE_URL / GRAFANA_URL /
LOKI_URL if you've bound those somewhere other than this README's defaults
(http://localhost:9292, http://localhost:5173, http://localhost:3000,
http://localhost:3100). Exits non-zero if anything's down — except the
observability backend when its profile isn't running at all, which is opt-in
and only reported on in that case.
Tracing and structured logging live in ruby/lib/observability.rb, in two
tiers, because the OpenTelemetry signals are not equally mature.
Structured logs are always on. Observability::Log writes one JSON object
per line to stdout, each carrying the trace_id and span_id of the span in
scope when it was written — which is what lets a log line be read next to the
trace it belongs to. LOG_LEVEL sets the threshold (info in development,
fatal in the test env so a run stays quiet). Set LOG_FILE (e.g.
LOG_FILE=log/development.log) to write there instead of stdout — the seam
the observability backend's collector tails to get these lines into Loki; see
below.
Traces come from the stable OpenTelemetry trace SDK (1.x) and export over
OTLP. OTEL_SDK_DISABLED switches the tier off, leaving a no-op tracer:
spans still open and close, they just record nothing and cost nothing, and
neither the SDK nor the OTLP exporter is even loaded. It is set in both
ruby/.env.development and ruby/.env.test: there's a collector to run
locally now (below), but it's opt-in, and an SDK left on without one just
retries exports that can never land. The suite therefore needs nothing
running, and specs that assert on spans install an in-memory exporter for
their own duration.
Metrics are deliberately absent. The metrics and logs SDKs are both pre-1.0 and break between minor versions, so RED metrics are left for the collector to derive from the spans below rather than emitted by the app.
Spans are opened at three hand-written seams, and the rest come from the instrumentation gems for GraphQL, pg, Redis and Resque:
App#call— one server span per request, named<METHOD> <route>with record ids collapsed (GET /api/v1/notes/:id) so the names stay usable as metric series. It continues an inboundtraceparent, so a trace that starts in the browser carries on into the API instead of breaking in two, and it records the response status, marking 5xx failed.Services::BaseService#perform— every write in the app funnels through this one method, so one span there covers them all.AppSchema— therescue_fromhandlers record the original exception on the span before translating it into a client-facing GraphQL error, which is otherwise the last place that exception exists.
To see it working without a collector, run the app with OTEL_SDK_DISABLED=false
and OTEL_TRACES_EXPORTER=none, then add an in-memory exporter in
bin/console. To see it working against a real one, start the backend below.
What receives the OTLP above: one container, grafana/otel-lgtm, bundling an
OpenTelemetry Collector with Tempo (traces), Prometheus (metrics), Loki
(logs) and Grafana. It's opt-in — nothing else in this stack needs it, and
it's the heaviest thing here — so it sits behind a compose profile and a
plain docker compose up -d never starts it.
The app speaks OTLP, a wire protocol rather than a vendor SDK, so this whole service is swappable for a hosted backend later without touching application code.
-
Start it:
docker compose --profile observability up -d
Give it about 30 seconds — five processes start inside the container. Verify it's up:
docker compose ps otelshould showhealthy, and http://localhost:3000 should open Grafana (no login; the image runs it with anonymous admin access). -
Run the API with the trace SDK switched on and its logs going to a file the collector can tail, both of which
ruby/.env.developmentleaves off by default:cd ruby OTEL_SDK_DISABLED=false LOG_FILE=log/development.log CORS_ALLOWED_ORIGIN=http://localhost:5173 bundle exec bin/server
Verify spans are landing: make a request (
curl http://localhost:9292/healthz), then in Grafana open Explore → Tempo → Search and search for service namestack-api. AGET /healthztrace shows up within a few seconds.Verify a log line is landing, and that it's linked to a trace: today
Observability::Logis only called from the unhandled-exception paths (seeRequestTracing#log_unhandled), sobin/consoleis the easiest way to produce one on demand, the same way the app-side Observability section above uses it to demonstrate a span:cd ruby LOG_FILE=log/development.log OTEL_SDK_DISABLED=false bundle exec bin/console
irb(main):001> Observability.in_span('manual.test') { Observability::Log.info('note saved') }In Grafana, open Explore → Loki, run
{service_name="stack-api"}, and find thenote savedline. Clicking itstrace_idopens the matchingmanual.testtrace in Tempo, and that trace's span has a Logs for this span button that finds its way back — the correlationObservability::Log::Formatterand the collector'sfilelogreceiver exist to make true. -
Confirm the whole stack at once:
bin/doctor
Its
Observabilitysection checks both OTLP ports, Grafana, and whether any logs have actually reached Loki — a collector that's up and a Loki with your logs in it are different questions, andbin/doctoranswers both. With the profile stopped it says so and leaves the exit status alone, since none of this is required.
The collector's config is docker/otel/otelcol-config.yaml, mounted over the
one the image ships with — the same arrangement as
docker/postgres/init-databases.sh, and for the same reason: container
configuration this app owns belongs in the repo, committed and diffable. It
is the stock pipeline plus three things:
- CORS for
http://localhost:5173on the OTLP/HTTP receiver, so the Vue client can export straight to the collector from the browser. Nothing sends from the browser yet; the entry is what will let it. - The
spanmetricsconnector, deriving rate/error/duration metrics from the spans as they pass through. This is why the Ruby app ships no metrics code at all (see above): the series are computed here instead, from the one signal the app does emit. They land in Prometheus astraces_span_metrics_calls_totalandtraces_span_metrics_duration_milliseconds_*, labelled with the route template, method and status code off each span. - A
filelogreceiver that tailsLOG_FILE(mounted read-only fromruby/log/— the app runs on the host, not in this container, so there is no container stdout to scrape instead), parses each JSON line, and promotes itstrace_id/span_idonto the log record's real trace context rather than leaving them as ordinary attributes. That promotion is what Grafana's LokiderivedFieldsand Tempo'stracesToLogsV2actually match on — atrace_idsitting in the body satisfies neither, so it's the step that makes the trace-to-log link work rather than just delivering the lines.
Everything the backend stores lives in the otel_data volume, so traces
survive a restart. To reclaim the space:
docker compose --profile observability down -v.mcp.json registers Grafana's own MCP server
(mcp-grafana) at project scope, so
an agent can search traces, run PromQL and read dashboards directly rather
than being told what they say. It needs two things on your machine:
-
uvx(uv) onPATH, which is what runs the server. -
GRAFANA_MCP_KEY, a Grafana service account token. The backend creates one on first start; read it out of the running container:docker compose exec otel cat /etc/lgtm/mcp.jsonExport it from your shell profile. It is deliberately not committed —
.mcp.jsononly names the variable — and it lives in Grafana's database inside theotel_datavolume, so thedown -vabove invalidates it and the next start issues a new one.
Two caveats worth knowing before you debug a silent server. A shell profile
that exports it from ~/.zshrc only reaches interactive shells, so an
editor or desktop app launched from the GUI won't see it; ~/.zprofile is
the file that covers both. And Grafana here runs with anonymous admin access,
so curling an endpoint with a bad token still returns 200 — /api/user is
the one that actually rejects it, and is the honest way to check a token
works.
Four independent checks, each an isolated subprocess (so a reading reflects only what it measures, not whatever else is loaded in the process taking it) and each runnable on its own:
cd ruby
bundle exec rake memory:rss:boot # current RSS after boot
bundle exec rake memory:rss:request # RSS growth from a GraphQL + REST request cycle
bundle exec rake memory:profile:boot # allocation breakdown of boot, by gem/file/class
bundle exec rake memory:profile:request # allocation breakdown of the same request cyclerss:* uses the get_process_mem gem to read resident memory (what the OS
says the process actually occupies — interpreter, C extensions, everything).
profile:* uses memory_profiler
for a detailed breakdown of Ruby-level object allocations by gem, file, and
class — a different, narrower measurement (see the tradeoffs above); the two
numbers are not expected to match. Both profile:* tasks print the full
report and also save a copy under ruby/tmp/ (gitignored — these are
point-in-time diagnostic dumps, not committed history).
The "request" checks exercise the saveNote mutation, the notes query,
and REST create + list against /api/v1/notes, wrapped in a rolled-back
transaction (lib/memory_workload.rb) so they never leave persisted rows
behind, whichever database APP_ENV points at. All 4 require a running,
migrated Postgres (same prerequisite as the IRB console above).
bundle exec rake memory:snapshot # run all 4 and append one combined row to benchmarks/memory.csv
bundle exec rake memory:report # print recent snapshots and deltas from that logmemory:snapshot is the one to run as you make changes — it appends a
single row (timestamp, git SHA — +dirty if ruby/ has uncommitted
changes — Ruby version, and all 4 metrics) to ruby/benchmarks/memory.csv,
which is committed history, diffable across commits as the app grows.