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.
:4319 replay chunks
routes each signal
The two orange boxes are built from your clone of the ouo11y repository; the others are released images.
In the third terminal:
cd ~/Desktop/tiny-shop-funnels/stackcompose.yaml only lists the service folders:
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 itinclude: - 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
Section titled “The collector”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
# 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
# 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
# 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
# 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
# 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/blocksLoki 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
# 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
# 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: falserum-api
Section titled “rum-api”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
# 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
# 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: filesystemGrafana
Section titled “Grafana”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
# 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
# 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 pluginWORKDIR /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-appCOPY --from=ouo11y plugins/ouo11y-rum-app/src plugins/ouo11y-rum-app/srcCOPY --from=ouo11y plugins/ouo11y-rum-app/.config plugins/ouo11y-rum-app/.configCOPY --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.1COPY --from=plugin --chown=grafana:root /src/plugins/ouo11y-rum-app/dist /var/lib/grafana/plugins/ouo11y-rum-appENV GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=ouo11y-rum-app \ GF_SECURITY_DISABLE_FRONTEND_SANDBOX_FOR_PLUGINS=ouo11y-rum-appstack/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: lokistack/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}Start it
Section titled “Start it”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.
echo $GHCR_TOKEN | docker login ghcr.io -u <github-user> --password-stdinPoint the builds at your clone and start everything. The first run builds rum-api and Grafana, two to three minutes:
export OUO11Y_REPO=~/ouo11ydocker compose up -d --buildWhat you should see
Section titled “What you should see”rum-api has the funnel endpoint. An empty funnel gets a 400, not a 404:
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.