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.
routes each signal
4a. Create the files
Section titled “4a. Create the files”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
# 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 appThe 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
# 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
# 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
# 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
# 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/blocksstack/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: falsestack/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
# Mimir in monolithic mode (target: all) on one machine, filesystem storage.# OTLP push at :9009/otlp/v1/metrics, PromQL at :9009/prometheus.target: allmultitenancy_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: falserum-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
# 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
# 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: filesystemGrafana with the ouo11y RUM app and its datasources.
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
# 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: 15sstack/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}4b. Log in to ghcr.io
Section titled “4b. Log in to ghcr.io”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:
echo $GHCR_TOKEN | docker login ghcr.io -u <your-github-user> --password-stdinIt prints Login Succeeded.
4c. Check the ports
Section titled “4c. Check the ports”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:
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/:
code .envand set the ports that are taken, for example:
OUO_GRAFANA_PORT=13000OUO_OTLP_HTTP_PORT=14318OUO_REPLAY_PORT=14319Compose 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.
4d. Start it
Section titled “4d. Start it”In the third terminal tab, from tiny-shop-otel/:
cd stackdocker compose up -ddocker compose psWhat you should see
Section titled “What you should see”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.