Skip to content

Onboarding an app

Four steps: copy agent/, label the containers, add the SDK, make one call. The claim this document has to survive is that the diff in your app repo is exactly those four things — no new services to write, no config to tune, no address to hardcode beyond alloy:4317.

demo/ is a worked example of all four, and make demo-up runs agent/ unmodified. If something here disagrees with compose.demo.yml, that file is the tested one.

Before you start you need one ingest credential from whoever runs the server: ./scripts/add-ingest-user.sh <app>-<env> there prints both halves.


1. Copy the agent

cp -r observability-stack/agent your-app/observability
cp your-app/observability/.env.agent.example your-app/observability/.env.agent

Fill in .env.agent. Nothing in agent/ is app-specific and config.alloy is not meant to be edited — if you find yourself editing it, that is a bug in the label convention, not in your app.

OBS_APP, OBS_ENV, OBS_HOST The identity stamped on every metric and log stream.
OBS_PROM_URL, OBS_LOKI_URL, OBS_TRACES_URL https://ingest.<domain>/... — native upstream paths. OBS_TRACES_URL is a base URL; the OTLP exporter appends /v1/traces.
OBS_INGEST_USER, OBS_INGEST_PASSWORD The credential minted for this app+env.

.env.agent is read inside the container, not interpolated by Compose — so a $ in the password needs no escaping here. (The server's .env.lgtm is the opposite; see deploy-server.md §3.)

Then run it alongside your app:

docker compose -f compose.lgtm.yml -f observability/compose.agent.yml up -d

Add --profile postgres for postgres-exporter, --profile containers for cAdvisor, or both.

If the app deploys from git

Dokploy, Coolify and anything else that re-clones the repo on every deploy takes one compose file and re-creates the working tree each time, which breaks two things silently: a bind-mounted repo file comes back empty or missing, and variables from the platform's UI are written to a .env for interpolation rather than injected into containers. Use observability/compose.agent.deploy.yml, which builds config.alloy into an image and reads its settings from environment::

# the one file the platform points at
include:
  - observability/compose.agent.deploy.yml

services:
  api: { networks: [obs], expose: ["8000"], labels: { obs.service: api, obs.metrics.port: "8000" } }

Same variable names, plus OBS_NETWORK (this app's Docker network name — required, because Docker network names are global to the host) and COMPOSE_PROFILES=postgres,containers in place of --profile. agent/README.md has the detail; compose.demo.deploy.yml and .env.demo.example in this repo are the worked example and its annotated environment. The same trap applies to postgres-exporter-init.sql — bake it into a Postgres image rather than mounting it, as demo/db/Dockerfile does.

2. Label the containers

services:
  api:
    labels:
      obs.service: api
      obs.metrics.port: "8000"    # presence = scrape me
      obs.metrics.path: /metrics  # optional, defaults to /metrics
    expose:
      - "8000"                    # required — see below
  db:
    labels:
      obs.service: db             # logs only
  migrations:
    labels:
      obs.logs: "false"           # collect nothing

Everything is collected by default: a container with no labels still has its logs shipped, with service falling back to its container name. Only obs.metrics.port is opt-in, because scraping something that does not serve Prometheus text is a waste rather than a mistake.

service is the role a process plays inside your app, not what software it is. A Postgres container belonging to asset-management is app=asset-management, service=db — never app=postgres. labels.md is the contract; it wins over this page.

The port must be exposed

Important

This is the first thing that goes wrong. The agent matches obs.metrics.port against Docker's port metadata, which means EXPOSE in the image or expose: in Compose. Publishing the port (ports:) is neither required nor wanted — the app should expose nothing to the host.

A container that genuinely cannot expose its port is what OBS_EXTRA_TARGET is for:

OBS_EXTRA_TARGET=host.docker.internal:9100
OBS_EXTRA_SERVICE=worker

A container on two networks is scraped twice

Docker discovery emits one target per (network × exposed port), so the same series arrives twice with the same timestamp and Prometheus rejects the whole write. Set the network the agent shares with your app:

OBS_DOCKER_NETWORK=yourapp_default    # form: <project>_<network>

Exposing three ports is not the same problem: the Docker log source keys its tailers by container id, so that container is still tailed exactly once.

OBS_DOCKER_NETWORK also scopes log collection, which is the reason to set it even where nothing is scraped twice. Unset, the agent tails every container on the host and stamps this app's identity on all of them — fine on a dedicated app host, wrong on a box that runs anything else, and on a box running two apps each agent ships the other's logs under its own name. The cost of setting it is that a container with no interface on that network is not collected at all.

3. Add the SDK

uv add "obstack[grpc,sqlalchemy]"
Extra When
grpc app → local Alloy on :4317 (the default)
http app → a collector over HTTP/1.1
sqlalchemy you pass engine=
errors you set OBS_ERROR_DSN

4. Wire it up

from fastapi import FastAPI
from obstack import setup_observability

app = FastAPI()
obs = setup_observability(app, engine=engine, excluded_paths=["/health"])

One call configures structlog, registers the metrics and request-logging middleware, serves /metrics, and starts exporting traces when OBS_OTLP_ENDPOINT is set. For a process with no FastAPI app, setup_worker_observability(metrics_port=9100).

Then set the app's own environment:

environment:
  OBS_APP: yourapp
  OBS_SERVICE: api
  OBS_ENV: production
  OBS_HOST: app-vps-01
  OBS_VERSION: 1.4.0
  OBS_OTLP_ENDPOINT: http://alloy:4317   # the only address the app needs

Important

OBS_APP / OBS_ENV / OBS_HOST must match what .env.agent says. Keeping them in one file both halves read is the easiest way to guarantee it, and the failure when they drift is not an error — it is two sets of series that never join.

Things that will bite you

OBS_HOST is set by the app on the trace path, not by the agent. Metrics and logs get the agent's value; traces get the app's. An app that leaves it unset reports socket.gethostname(), which inside a container is the container id — so its traces silently disagree with its own metrics. Set it explicitly.

OBS_LOG_FORMAT: auto renders to the console when env=local. The agent parses JSON. If you run a container with OBS_ENV=local and expect a collector to read its logs, set OBS_LOG_FORMAT: json explicitly.

Recreating a container can cost one batch of cAdvisor samples. id is dropped from cAdvisor's series because a container id is unbounded over time; while a recreated container briefly coexists with its predecessor the two collapse onto one series, and Prometheus rejects the write with duplicate sample for timestamp. It resolves within a scrape or two.

Caution

OBS_INGEST_TLS_INSECURE is not a convenience flag. It disables certificate verification on all three writers, which makes the basic auth worthless — the credentials become readable by whoever terminates the connection. It exists so this stack's own end-to-end test can push through Traefik on a laptop. Leave it alone.

alloy validate does not build components. It checks syntax and the component graph, so a malformed selector or an unresolvable endpoint passes validation and fails at startup. Watch docker compose logs alloy after the first up.

Verify the onboarding

From the app host:

docker compose logs alloy | grep -i error     # expect nothing
curl -s localhost:12345/metrics | head        # Alloy's own UI; /graph shows what it discovered

From Grafana, within a minute or two: fastapi_requests_total{app="yourapp"} has series, the Applications dashboard populates when you pick your app, and a log line for that app carries a trace_id in structured metadata that resolves in Tempo.

If metrics are missing but logs are not, it is almost always step 2's expose:. If both are missing, it is the credential — the server's Traefik access log names the user on every request, and a 401 there is a wrong or mis-escaped hash rather than a wrong URL.

Postgres metrics

The exporter connects as its own least-privilege role, not the application user:

POSTGRES_EXPORTER_PASSWORD=... docker compose exec -T db \
  psql -U postgres -d appdb -f - < observability/postgres-exporter-init.sql

On a database that does not exist yet, mount that file into /docker-entrypoint-initdb.d/ instead and set POSTGRES_EXPORTER_PASSWORD on the Postgres container — it reads the variable itself. Re-running rotates the password rather than failing.

--collector.stat_checkpointer is not optional on PostgreSQL 17+: the checkpoint counters moved out of pg_stat_bgwriter into pg_stat_checkpointer, and that collector is off by default. agent/compose.agent.yml already passes it.

Error tracking, optionally

If the server runs GlitchTip, add the errors extra and one variable:

OBS_ERROR_DSN: https://<key>@errors.<domain>/<project-id>

Unset or empty means no error reporting, which is the default. Events arrive tagged with the same app / service / env / host, so an issue in GlitchTip and a trace in Tempo describe themselves the same way.