Skip to content

The stack

Five services, each in its own folder with its compose file and its config. Every file starts with what the service is for and its ports.

In
Shop and API:4318 events and traces
:4319 replay chunks
10-collector
ouo-collectorchecks the project credential
routes each signal
Stores
20-tempotraces
30-lokipage views · custom events
50-rum-apifunnels · sessions · replays
UI
60-grafanaouo11y RUM app · :3000
The apps only talk to the collector. Grafana reads Loki and Tempo directly, and funnels, sessions and replays through rum-api.

The two orange boxes are built from your clone of the ouo11y repository; the others are released images.

In the third terminal:

Terminal window
cd ~/Desktop/tiny-shop-funnels/stack

compose.yaml only lists the service folders:

stack/compose.yaml
stack/compose.yaml
# The ouo stack for tiny-shop-funnels: 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`.
name: tiny-shop-funnels # compose project name: containers and the rum-api-data volume use it
include:
- 10-collector/compose.yaml # ingest: the only thing the apps talk to, replay included
- 20-tempo/compose.yaml # traces
- 30-loki/compose.yaml # browser events: page views and custom events, the funnel steps
- 50-rum-api/compose.yaml # sessions, funnels, and where replay chunks are stored (built from source)
- 60-grafana/compose.yaml # the UI: datasources and the ouo11y RUM app (built from source)

The collector is the only thing your apps talk to. It checks the project credential (projects.htpasswd) and routes traces to Tempo, logs (the SDK’s page views and custom events) to Loki, and replay chunks to rum-api. It also copies the OTLP event name into the event.name attribute, which Loki turns into a label.

stack/10-collector/compose.yaml
stack/10-collector/compose.yaml
# ouo-collector: the only ingest. The browser SDK and the Node API send OTLP here; it checks the
# project credential and forwards traces to Tempo, logs to Loki and replay chunks to rum-api.
# The browser uploads replay to 4319 only: rum-api is never exposed. Ports: 4318 OTLP/HTTP, 4319 replay (both public), 13133 health (loopback).
services:
otel-collector:
image: ghcr.io/ouo11y/collector:0.4.0
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, rum-api]
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, no metrics). Receivers: OTLP/HTTP on 4318 and replay chunks on 4319, both behind
# the project credential in projects.htpasswd. Exporters: Tempo, Loki 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]
# Replay: the SDK POSTs gzipped rrweb chunks here (X-Ouo-Session-Id, X-Ouo-Seq headers).
ouoreplay:
endpoint: 0.0.0.0:4319
include_metadata: true
max_request_body_size: 8388608 # 8 MB per chunk; the SDK flushes at 256 KB
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 }
# Replay chunks go on, unchanged, to rum-api, which stores them (see 50-rum-api/config.yaml).
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]
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=

Tempo stores the traces.

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

Loki stores the browser’s events: a navigation event with url.path for every page, and the custom events. These are the funnel steps. event_name becomes a label, so rum-api can fetch one kind of step in one query.

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

rum-api builds sessions and funnels from Loki and stores replays. It is built from the clone (OUO11Y_REPO), because the 0.4.0 image this guide was verified with has no funnels.

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) and the funnel reports 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.
#
# Built from source: funnels (POST /v1/funnels/query) are newer than the released
# ghcr.io/ouo11y/rum-api:0.4.0. Once a rum-api release with funnels is tagged, replace `build:`
# with `image: ghcr.io/ouo11y/rum-api:<that version>` (see the README, "Released images").
services:
rum-api:
build:
# services/rum-api of an ouo11y checkout. OUO11Y_REPO: the checkout's root (default: this
# folder's place inside the repository).
context: ${OUO11Y_REPO:-../../../../../..}/services/rum-api
args: { VERSION: funnels-from-source }
image: tiny-shop-funnels/rum-api:from-source
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
volumes:
- ./config.yaml:/etc/rum-api/config.yaml:ro
- rum-api-data:/home/nonroot
depends_on: [loki, tempo]
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).
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
# Where replay chunks are stored. filesystem: files under DATA_DIR, here the rum-api-data volume
# (<dir>/replays/<session id>/, one chunk per upload, in sequence order). For more
# than one rum-api, or real retention, use backend: s3 with MinIO or any S3 bucket:
# storage:
# backend: s3
# s3: { endpoint: "minio:9000", bucket: ouo-replays, insecure: true, force_path_style: true,
# access_key_id: ..., secret_access_key: ... }
storage:
backend: filesystem

Grafana with the ouo11y RUM app, which has the Funnels page. Its Dockerfile builds the plugin from the clone and copies it into grafana/grafana, like the released image does. provisioning/ adds the Loki and Tempo datasources and points the app at rum-api.

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 and Loki
# directly, and sessions, funnels and replays from rum-api through the plugin. Port 3000,
# admin / testing567. provisioning/ sets up the datasources and the plugin.
#
# Built from source: the plugin's Funnels page is newer than the released
# ghcr.io/ouo11y/grafana:0.4.0. Dockerfile (next to this file) builds plugins/ouo11y-rum-app of an
# ouo11y checkout and copies it into grafana/grafana, like the released image. OUO11Y_REPO: the
# checkout's root (default: this folder's place inside the repository). Once a grafana image with
# funnels is tagged, replace `build:` with `image: ghcr.io/ouo11y/grafana:<that version>`.
services:
grafana:
build:
context: .
additional_contexts:
ouo11y: ${OUO11Y_REPO:-../../../../../..}
image: tiny-shop-funnels/grafana:from-source
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, rum-api]
stack/60-grafana/Dockerfile
stack/60-grafana/Dockerfile
# syntax=docker/dockerfile:1
# Grafana with the ouo11y RUM app plugin built from an ouo11y checkout (the Funnels page is not
# in a released image yet). compose.yaml passes the checkout as the named build context
# `ouo11y`; only the files copied below are sent. Same result as the released grafana image.
FROM node:24-bookworm-slim AS plugin
WORKDIR /src
# The plugin is an npm workspace of the repository: install from the root lockfile, plugin only.
COPY --from=ouo11y package.json package-lock.json ./
COPY --from=ouo11y sdk/browser/package.json sdk/browser/
COPY --from=ouo11y sdk/react-native/package.json sdk/react-native/
COPY --from=ouo11y examples/ouo-web/package.json examples/ouo-web/
COPY --from=ouo11y plugins/ouo11y-rum-app/package.json plugins/ouo11y-rum-app/
RUN npm ci --no-audit --no-fund --ignore-scripts -w plugins/ouo11y-rum-app
COPY --from=ouo11y plugins/ouo11y-rum-app/src plugins/ouo11y-rum-app/src
COPY --from=ouo11y plugins/ouo11y-rum-app/.config plugins/ouo11y-rum-app/.config
COPY --from=ouo11y plugins/ouo11y-rum-app/tsconfig.json plugins/ouo11y-rum-app/.cprc.json plugins/ouo11y-rum-app/README.md plugins/ouo11y-rum-app/CHANGELOG.md plugins/ouo11y-rum-app/LICENSE plugins/ouo11y-rum-app/
RUN npm run build -w plugins/ouo11y-rum-app
FROM grafana/grafana:13.2.1
COPY --from=plugin --chown=grafana:root /src/plugins/ouo11y-rum-app/dist /var/lib/grafana/plugins/ouo11y-rum-app
ENV GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=ouo11y-rum-app \
GF_SECURITY_DISABLE_FRONTEND_SANDBOX_FOR_PLUGINS=ouo11y-rum-app
stack/60-grafana/provisioning/datasources/datasources.yaml
stack/60-grafana/provisioning/datasources/datasources.yaml
# Grafana datasources: Loki (browser events), Tempo (traces), both 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
isDefault: true
jsonData:
lokiSearch:
datasourceUid: loki
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 collector image is private for now: log in to ghcr.io once with a GitHub token that has read:packages. Without pull access the collector does not start; email hello@ouo11y.com.

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

Point the builds at your clone and start everything. The first run builds rum-api and Grafana, two to three minutes:

Terminal window
export OUO11Y_REPO=~/ouo11y
Terminal window
docker compose up -d --build

rum-api has the funnel endpoint. An empty funnel gets a 400, not a 404:

Terminal window
docker compose exec grafana curl -s -X POST http://rum-api:8080/v1/funnels/query -H 'Authorization: Bearer rt_dev_0000000000000000' -d '{"steps":[]}'

It prints:

{"error":"a funnel has 2 to 10 steps"}

Open http://localhost:3000 and sign in as admin / testing567. More apps → ouo11y RUM has a Funnels page. It is empty for now.

Ports taken by another stack? See When something does not work.

Next: instrument the API and the shop.