Skip to content

The stack, one service at a time

The ouo11y stack is the same whichever way the browser is instrumented. Nothing in it knows whether the page runs the ouo11y SDK or your own code: it reads OpenTelemetry and rrweb chunks. That is the point of this quickstart.

Your apps
Browserspans, events, replay
APIspans
10
Collectorchecks the project credential
routes each signal
20 · 30 · 40 · 50
Tempotraces · 3200
Lokievents, logs · 3100
Mimirmetrics · 9009
rum-apisessions, replay · 8080 inside
60
Grafanaouo11y RUM app · :3000
Your apps talk only to the collector. Everything behind it is internal, apart from Grafana.

Six services, one folder each. Each folder holds that service’s compose file and its config, and each file starts with what the service is for, who sends it data and its ports. Open a block, copy all of it, and paste it into the file named in its title. VS Code creates the folders when you save a new file as stack/10-collector/compose.yaml.

compose.yaml only lists the service folders:

stack/compose.yaml
stack/compose.yaml
# The ouo stack for tiny-shop-otel: one folder per service, each with its compose file and
# its config. This file only lists them. Start everything from this folder: `docker compose up -d`.
# Grafana http://localhost:3000 (admin / testing567), OTLP http://localhost:4318, replay :4319.
# Ports taken? Export OUO_GRAFANA_PORT, OUO_OTLP_HTTP_PORT, OUO_REPLAY_PORT before `up`.
include:
- 10-collector/compose.yaml # ingest: the only thing the apps talk to
- 20-tempo/compose.yaml # traces
- 30-loki/compose.yaml # logs and browser events
- 40-mimir/compose.yaml # metrics (none from this shop yet; the collector accepts /v1/metrics)
- 50-rum-api/compose.yaml # sessions and replay for the Grafana plugin
- 60-grafana/compose.yaml # the UI: datasources and the ouo11y RUM app

The collector is the only thing your apps talk to. It checks the project credential (projects.htpasswd) and routes traces, logs, metrics and replay chunks.

stack/10-collector/compose.yaml
stack/10-collector/compose.yaml
# ouo-collector: the only ingest. The browser and the Node API send OTLP here; it checks the
# project credential and forwards traces to Tempo, logs to Loki, metrics to Mimir and replay
# chunks to rum-api. Ports: 4318 OTLP/HTTP, 4319 replay (both public), 13133 health (loopback).
services:
otel-collector:
# `latest` follows each release. Tested with 0.4.0: pin `collector:0.4.0` or
# `collector@sha256:ca0d001dc58153671fc501cbe280a69f86dc3e681a1b4621e827dedadb7727f2` to stay on it.
image: ghcr.io/ouo11y/collector:latest
command: ["--config=/etc/otelcol/config.yaml"]
ports:
- "${OUO_OTLP_HTTP_PORT:-4318}:4318"
- "${OUO_REPLAY_PORT:-4319}:4319"
- "127.0.0.1:${OUO_COLLECTOR_HEALTH_PORT:-13133}:13133"
environment:
OUO_INGEST_TOKEN: it_dev_0000000000000000 # what it sends to rum-api; must equal rum-api's
volumes:
- ./config.yaml:/etc/otelcol/config.yaml:ro
- ./projects.htpasswd:/etc/otelcol/projects.htpasswd:ro
depends_on: [tempo, loki, mimir]
stack/10-collector/config.yaml
stack/10-collector/config.yaml
# ouo-collector pipelines, a trimmed copy of the full stack's config (no Sentry bridge, no
# rate limits). Receivers: OTLP/HTTP on 4318 and replay chunks on 4319, both behind the
# project credential in projects.htpasswd. Exporters: Tempo, Loki, Mimir and rum-api.
extensions:
health_check:
endpoint: 0.0.0.0:13133
basicauth/server:
htpasswd:
file: /etc/otelcol/projects.htpasswd
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
include_metadata: true # keeps the authenticated project for resource/project
auth:
authenticator: basicauth/server
cors: # the browser app runs on another origin
allowed_origins: ["http://localhost:*", "http://127.0.0.1:*"]
allowed_headers: [Authorization, Content-Type, Content-Encoding, traceparent, baggage]
ouoreplay:
endpoint: 0.0.0.0:4319
include_metadata: true
max_request_body_size: 8388608
auth:
authenticator: basicauth/server
cors:
allowed_origins: ["http://localhost:*", "http://127.0.0.1:*"]
allowed_headers: [Authorization, Content-Type, Content-Encoding, X-Ouo-Session-Id, X-Ouo-Seq]
processors:
# Stamp the authenticated project id on every resource (and drop any value the client sent).
resource/project:
attributes:
- key: ouo.project.id
action: delete
- key: ouo.project.id
from_context: auth.username
action: upsert
# Copy the OTLP event name into the `event.name` attribute, which Loki turns into a label.
transform/events:
error_mode: ignore
log_statements:
- context: log
statements:
- set(log.attributes["event.name"], log.event_name) where log.event_name != "" and log.attributes["event.name"] == nil
batch:
timeout: 1s
exporters:
otlp_grpc/tempo:
endpoint: tempo:4317
tls: { insecure: true }
otlp_http/loki:
endpoint: http://loki:3100/otlp
tls: { insecure: true }
otlp_http/mimir:
endpoint: http://mimir:9009/otlp
tls: { insecure: true }
ouoreplay:
endpoint: http://rum-api:8080
ingest_token: ${env:OUO_INGEST_TOKEN}
service:
extensions: [health_check, basicauth/server]
pipelines:
traces:
receivers: [otlp]
processors: [resource/project, batch]
exporters: [otlp_grpc/tempo]
logs:
receivers: [otlp]
processors: [resource/project, transform/events, batch]
exporters: [otlp_http/loki]
metrics:
receivers: [otlp]
processors: [resource/project, batch]
exporters: [otlp_http/mimir]
logs/replay:
receivers: [ouoreplay]
exporters: [ouoreplay]
stack/10-collector/projects.htpasswd
stack/10-collector/projects.htpasswd
# Projects allowed to send data: one line per project, project id and SHA hash of its token.
# Read by the collector's basicauth extension. The `demo` token (pt_dev_demo_0000000000000000)
# is public: local use only. Make a line with htpasswd -nbs PROJECT TOKEN. No colon in comments.
demo:{SHA}IUpHBAUPHvXoJmvv4inmF4cN96I=

Two parts of the collector config matter for a browser you instrument yourself. The cors blocks let a page on localhost call the collector, and they list the headers your code sends: traceparent and baggage on OTLP, X-Ouo-Session-Id and X-Ouo-Seq on replay. And transform/events copies the OTLP event name into the event.name attribute, which Loki turns into a label.

The three stores: Tempo for traces, Loki for logs and browser events, Mimir for metrics. This shop sends no metrics yet; Mimir is there so the collector’s /v1/metrics has somewhere to go.

stack/20-tempo/compose.yaml
stack/20-tempo/compose.yaml
# Tempo: stores traces. The collector sends it spans from the browser and the API over OTLP/gRPC;
# Grafana and rum-api query it on 3200. Port 3200 is published for curl; nothing else needs it.
services:
tempo:
image: grafana/tempo:3.0.3
command: ["-config.file=/etc/tempo/config.yaml"]
ports:
- "127.0.0.1:${OUO_TEMPO_PORT:-3200}:3200"
volumes:
- ./config.yaml:/etc/tempo/config.yaml:ro
- tempo-data:/var/tempo
volumes:
tempo-data:
stack/20-tempo/config.yaml
stack/20-tempo/config.yaml
# Tempo 3.x single binary with local storage. Receives OTLP from the collector on 4317, serves
# queries on 3200 (Grafana streams search results over that port, hence stream_over_http).
stream_over_http_enabled: true
server:
http_listen_port: 3200
log_level: warn
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
querier:
frontend_worker:
frontend_address: 127.0.0.1:9095
storage:
trace:
backend: local
wal:
path: /var/tempo/wal
local:
path: /var/tempo/blocks
stack/30-loki/compose.yaml
stack/30-loki/compose.yaml
# Loki: stores logs. The collector sends it the browser's events and frontend logs over OTLP
# (/otlp); Grafana and rum-api query it on 3100 (rum-api builds sessions from these records).
# Port 3100 is published on loopback for curl.
services:
loki:
image: grafana/loki:3.7.7
command: ["-config.file=/etc/loki/config.yaml"]
ports:
- "127.0.0.1:${OUO_LOKI_PORT:-3100}:3100"
volumes:
- ./config.yaml:/etc/loki/config.yaml:ro
- loki-data:/loki
volumes:
loki-data:
stack/30-loki/config.yaml
stack/30-loki/config.yaml
# Loki 3.x single binary, filesystem storage, native OTLP ingestion on 3100.
# Only service_name, deployment_environment_name, event_name and ouo_platform become labels;
# everything else (session.id, user.id, trace_id, your log attributes) is structured metadata.
auth_enabled: false
server:
http_listen_port: 3100
log_level: warn
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
allow_structured_metadata: true # required for OTLP
otlp_config:
resource_attributes:
ignore_defaults: true # the defaults would index per-browser ids
attributes_config:
- action: index_label
attributes: [service.name, deployment.environment.name, ouo.platform]
log_attributes:
- action: index_label
attributes: [event.name, ouo.platform]
analytics:
reporting_enabled: false
stack/40-mimir/compose.yaml
stack/40-mimir/compose.yaml
# Mimir: stores metrics. The collector pushes the API's OTLP metrics to /otlp; Grafana queries
# it with PromQL on 9009 (/prometheus). Port 9009 is published on loopback for curl.
services:
mimir:
image: grafana/mimir:3.2.1
command: ["-config.file=/etc/mimir/config.yaml"]
ports:
- "127.0.0.1:${OUO_MIMIR_PORT:-9009}:9009"
volumes:
- ./config.yaml:/etc/mimir/config.yaml:ro
- mimir-data:/data
volumes:
mimir-data:
stack/40-mimir/config.yaml
stack/40-mimir/config.yaml
# Mimir in monolithic mode (target: all) on one machine, filesystem storage.
# OTLP push at :9009/otlp/v1/metrics, PromQL at :9009/prometheus.
target: all
multitenancy_enabled: false
server:
http_listen_port: 9009
grpc_listen_port: 9095
log_level: warn
common:
storage:
backend: filesystem
filesystem:
dir: /data/blocks
blocks_storage:
storage_prefix: blocks
bucket_store:
sync_dir: /data/tsdb-sync
tsdb:
dir: /data/tsdb
compactor:
data_dir: /data/compactor
sharding_ring:
instance_addr: 127.0.0.1
kvstore: { store: memberlist }
distributor:
ring:
instance_addr: 127.0.0.1
kvstore: { store: memberlist }
ingester:
ring:
instance_addr: 127.0.0.1
replication_factor: 1
kvstore: { store: memberlist }
store_gateway:
sharding_ring:
instance_addr: 127.0.0.1
replication_factor: 1
kvstore: { store: memberlist }
ruler_storage:
storage_prefix: rules
alertmanager_storage:
storage_prefix: alertmanager
memberlist:
bind_addr: [127.0.0.1]
join_members: []
limits:
# Keep OTel metric names as sent: shop.orders becomes shop_orders, not shop_orders_total.
otel_metric_suffixes_enabled: false

rum-api builds sessions from Loki and Tempo and gives each a status (healthy, errored, crashed); the Grafana plugin reads it. It also stores the replay chunks.

stack/50-rum-api/compose.yaml
stack/50-rum-api/compose.yaml
# rum-api: the ouo platform service behind the Grafana plugin. It builds the session list
# (healthy / errored / crashed) from Loki and Tempo and stores replay chunks the collector
# forwards. Internal: only the collector and Grafana talk to it, on 8080 inside the network.
services:
rum-api:
# `latest` follows each release. Tested with 0.5.0: pin `rum-api:0.5.0` or
# `rum-api@sha256:1ee9e606220df2c42f4ade4131fdd3f82085d6e814676a771754a85a6ae78336` to stay on it.
image: ghcr.io/ouo11y/rum-api:latest
command: ["-config.file=/etc/rum-api/config.yaml", "-config.expand-env=true"]
environment:
GRAFANA_URL: http://localhost:${OUO_GRAFANA_PORT:-3000} # for deep links
OUO_INGEST_TOKEN: it_dev_0000000000000000 # the collector's; development value
OUO_READ_TOKEN: rt_dev_0000000000000000 # Grafana's; development value
DATA_DIR: /home/nonroot # the image runs as nonroot
# rum-api's own traces and logs (0.5.0 and later), through the collector like the shop's.
# Without these it retries localhost:4318 inside its container and logs every failure.
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_HEADERS: Authorization=Basic%20ZGVtbzpwdF9kZXZfZGVtb18wMDAwMDAwMDAwMDAwMDAw
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_SERVICE_NAME: rum-api
volumes:
- ./config.yaml:/etc/rum-api/config.yaml:ro
- rum-api-data:/home/nonroot
depends_on: [loki, tempo, otel-collector]
volumes:
rum-api-data:
stack/50-rum-api/config.yaml
stack/50-rum-api/config.yaml
# rum-api configuration: where Loki and Tempo are, and the session timeouts.
# The tokens come from the environment set in compose.yaml (-config.expand-env=true).
# Replays are stored on the rum-api-data volume.
server:
http_listen_address: ":8080"
auth:
ingest_token: "${OUO_INGEST_TOKEN}"
read_token: "${OUO_READ_TOKEN}"
loki:
url: http://loki:3100
datasource_uid: loki
tempo:
url: http://tempo:3200
datasource_uid: tempo
session:
inactivity: 30m
max_duration: 4h
storage:
backend: filesystem

Grafana with the ouo11y RUM app and its datasources.

stack/60-grafana/compose.yaml
stack/60-grafana/compose.yaml
# Grafana with the ouo11y RUM app plugin: the UI for everything above. It reads Tempo, Loki and
# Mimir directly and sessions from rum-api through the plugin. Port 3000, admin / testing567.
# provisioning/ sets up the datasources and the plugin.
services:
grafana:
# `latest` follows each release. Tested with 0.5.0: pin `grafana:0.5.0` or
# `grafana@sha256:57607a3616c88867644be1f69abb4681ca73df05d6bf2682543418f0dfeb622f` to stay on it.
image: ghcr.io/ouo11y/grafana:latest
ports:
- "${OUO_GRAFANA_PORT:-3000}:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: testing567
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: ouo11y-rum-app
GF_SECURITY_DISABLE_FRONTEND_SANDBOX_FOR_PLUGINS: ouo11y-rum-app
OUO_READ_TOKEN: rt_dev_0000000000000000 # read by provisioning/plugins; equals rum-api's
volumes:
- ./provisioning:/etc/grafana/provisioning:ro
depends_on: [loki, tempo, mimir, rum-api]
stack/60-grafana/provisioning/datasources/datasources.yaml
stack/60-grafana/provisioning/datasources/datasources.yaml
# Grafana datasources: Loki (logs), Tempo (traces), Mimir (metrics), all inside the network.
# The uids are fixed because rum-api's config refers to them.
# Log lines with a trace_id link to the trace in Tempo.
apiVersion: 1
datasources:
- name: Loki
uid: loki
type: loki
url: http://loki:3100
jsonData:
derivedFields:
- name: TraceID
datasourceUid: tempo
matcherType: label
matcherRegex: trace_id
url: "$${__value.raw}"
urlDisplayLabel: View trace
- name: Tempo
uid: tempo
type: tempo
url: http://tempo:3200
jsonData:
lokiSearch:
datasourceUid: loki
- name: Mimir
uid: mimir
type: prometheus
url: http://mimir:9009/prometheus
isDefault: true
jsonData:
prometheusType: Mimir
timeInterval: 15s
stack/60-grafana/provisioning/plugins/ouo11y-rum-app.yaml
stack/60-grafana/provisioning/plugins/ouo11y-rum-app.yaml
# Enables the ouo11y RUM app (More apps -> ouo11y RUM) and points it at rum-api.
# Grafana adds the read token to every call it forwards; ${OUO_READ_TOKEN} comes from the
# container environment and must equal rum-api's.
apiVersion: 1
apps:
- type: ouo11y-rum-app
org_id: 1
jsonData:
rumApiUrl: http://rum-api:8080
secureJsonData:
readToken: ${OUO_READ_TOKEN}

The ouo11y images are private for now. You need read access to the ouo11y packages and a classic GitHub token with only the read:packages scope. Put the token in your shell (export GHCR_TOKEN=...), never in a file, then:

Terminal window
echo $GHCR_TOKEN | docker login ghcr.io -u <your-github-user> --password-stdin

It prints Login Succeeded.

The stack publishes 3000 (Grafana) and the collector’s 4318 (OTLP) and 4319 (replay) to your network, and 3100, 3200, 9009 and 13133 on loopback only. This prints nothing when they are all free:

Terminal window
lsof -nP -iTCP -sTCP:LISTEN | grep -E ':(3000|3100|3200|4318|4319|9009|13133) '

If something is listed, do not stop it: move this stack instead. From stack/:

Terminal window
code .env

and set the ports that are taken, for example:

stack/.env
OUO_GRAFANA_PORT=13000
OUO_OTLP_HTTP_PORT=14318
OUO_REPLAY_PORT=14319

Compose reads .env on its own. The others are OUO_LOKI_PORT, OUO_TEMPO_PORT, OUO_MIMIR_PORT and OUO_COLLECTOR_HEALTH_PORT (the ${OUO_..._PORT:-...} lines in the compose files). Remember the new collector ports: step 5 and step 6a tell you where they go.

In the third terminal tab, from tiny-shop-otel/:

Terminal window
cd stack
Terminal window
docker compose up -d
Terminal window
docker compose ps

Six services are Up, and rum-api becomes healthy. Open http://localhost:3000 and log in with admin / testing567. More apps → ouo11y RUM is there and still empty: nothing sends data yet.

Next: trace the backend.