Deploying Grafana Faro on Docker: End-to-End Frontend RUM and Error Tracking

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

The Problem: Blind Spots in Frontend Performance

Your backend dashboards look spotless. API latencies hover comfortably under 45ms, CPU load stays below 20%, and Nginx reports a crisp 99.98% HTTP 200 rate. Yet support tickets keep landing: checkout buttons fail silently on mobile Safari, single-page app transitions freeze mid-render, and users abandon carts because pages feel sluggish.

Server-side metrics cannot see what happens inside the user’s browser. When an unhandled React error unmounts the component tree or a 3MB banner triggers a 0.45 Cumulative Layout Shift (CLS), backend logs register nothing. Grafana Faro captures these events right where they happen—inside the client browser—and ships runtime logs, performance timings, and network traces straight into your existing Grafana stack.

Frontend Telemetry: Comparing Your Options

Engineering teams typically weigh three approaches for tracking client-side performance and bugs:

1. Commercial SaaS APMs (Datadog RUM, Sentry, New Relic)

SaaS vendors offer zero-friction setups and polished dashboards. The catch? Usage-based pricing ramps up fast. A high-traffic consumer web app pushing 5 million monthly sessions can easily generate thousands of dollars in monthly APM invoices. You also hand over sensitive client telemetry to a third party.

2. Custom In-House Beacon Endpoints

Some teams spin up a lightweight Go or Node.js endpoint to swallow navigator.sendBeacon() payloads and pipe them to Elasticsearch or Loki. It works on day one. But maintenance piles up quickly when you need to parse user sessions, support OpenTelemetry trace headers, and track Core Web Vitals changes.

3. Grafana Faro with OpenTelemetry

Grafana Faro provides a modular, lightweight Web SDK (roughly 14KB gzipped) that instruments console errors, unhandled exceptions, web vitals, and user journeys. Routed through Grafana Agent or Alloy, Faro dispatches log lines directly to Loki and distributed traces to Tempo. Everything lives in your own infrastructure.

Trade-offs of Self-Hosting Grafana Faro

Advantages

  • Complete Data Ownership: Telemetry never leaves your VPC. This keeps compliance straightforward for GDPR, HIPAA, and strict internal security policies.
  • Predictable Costs: Say goodbye to per-session billing tiers. Your costs scale strictly with disk storage and compute instances you already manage.
  • Unified Trace Context: Faro propagates standard W3C traceparent headers. You can trace a slow user click from the React event handler all the way to a PostgreSQL query in your backend.
  • Native Grafana Workflows: Jump between client error logs in Loki and distributed traces in Tempo using standard dashboard variables and Explore queries.

Disadvantages

  • Pipeline Management: You are responsible for storage retention rules, collector uptime, and database disk sizing.
  • Source Map Ingestion: Deobfuscating minified production bundle traces requires hosting an accessible source map endpoint or configuring storage buckets manually.

Architecture Overview

A reliable self-hosted deployment connects five components in Docker:

  • Grafana Faro Web SDK: Runs in the browser to collect errors, user clicks, and Core Web Vitals (LCP, INP, CLS).
  • Grafana Agent (or Grafana Alloy): Listens on an HTTP port, accepts Faro JSON batches, parses metadata, and fans out data downstream.
  • Grafana Loki: Indexes frontend console logs, network errors, and session events.
  • Grafana Tempo: Ingests OpenTelemetry trace spans generated by frontend fetch and XHR requests.
  • Grafana Dashboard: Visualizes RUM metrics and lets engineers pivot between traces and logs with one click.

Connecting a frontend click to slow backend queries cuts debugging time from hours to minutes. Let’s build the stack.

Step-by-Step Deployment with Docker Compose

Step 1: Create the Docker Compose Stack

Create a directory named grafana-faro-stack and add this docker-compose.yml file:

version: '3.8'

services:
  grafana:
    image: grafana/grafana:10.4.0
    container_name: faro-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
    depends_on:
      - loki
      - tempo

  loki:
    image: grafana/loki:2.9.4
    container_name: faro-loki
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml

  tempo:
    image: grafana/tempo:2.3.1
    container_name: faro-tempo
    ports:
      - "3200:3200"
      - "4317:4317" # OTLP gRPC
      - "4318:4318" # OTLP HTTP
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./tempo/tempo.yaml:/etc/tempo.yaml

  grafana-agent:
    image: grafana/agent:v0.40.0
    container_name: faro-agent
    ports:
      - "12347:12347" # Faro receiver port
      - "12345:12345" # Agent internal metrics
    volumes:
      - ./agent/agent-config.yaml:/etc/agent/agent-config.yaml
    command:
      - -config.file=/etc/agent/agent-config.yaml
      - -server.http.address=0.0.0.0:12345
    depends_on:
      - loki
      - tempo

Step 2: Configure Grafana Agent for Faro Telemetry

Create agent/agent-config.yaml. The app_agent_receiver block accepts browser payloads, extracts session metadata, and routes logs to Loki and traces to Tempo:

server:
  log_level: info

integrations:
  event_handler:
    enabled: true

app_agent_receiver:
  instance: production-web
  server:
    host: 0.0.0.0
    port: 12347
    cors_allowed_origins:
      - "*"
  sourcemaps:
    download: true
  logs:
    send_to: loki
  traces:
    send_to: tempo

logs:
  configs:
    - name: loki
      clients:
        - url: http://loki:3100/loki/api/v1/push

traces:
  configs:
    - name: tempo
      remote_write:
        - endpoint: tempo:4317
          insecure: true

Step 3: Configure Tempo Storage

Create tempo/tempo.yaml with local file storage configuration:

server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

ingester:
  trace_idle_period: 10s
  max_block_bytes: 1_000_000
  max_block_duration: 5m

compactor:
  compaction:
    block_retention: 24h

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/traces
    wal:
      path: /tmp/tempo/wal

Step 4: Provision Grafana Data Sources

Create grafana/datasources.yaml so Grafana registers Loki and Tempo automatically during boot:

apiVersion: 1

datasources:
  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    isDefault: true

  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      httpMethod: GET
      tracesToLogs:
        datasourceUid: 'Loki'
        tags: ['job', 'instance', 'app']
        mappedTags:
          - key: 'service.name'
            value: 'app'

Step 5: Start the Stack

Launch all containers in detached mode:

docker compose up -d

Confirm all containers report healthy states:

docker compose ps

Step 6: Integrate Faro Web SDK into Your Frontend

Install the Faro core SDK and the tracing package:

npm install @grafana/faro-web-sdk @grafana/faro-web-tracing

Initialize Faro as early as possible in your frontend bundle (such as src/index.tsx or src/main.js):

import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';

export const faro = initializeFaro({
  url: 'http://localhost:12347/collect',
  app: {
    name: 'storefront-ui',
    version: '2.1.4',
    environment: 'production',
  },
  instrumentations: [
    // Instruments console errors, unhandled promise rejections, and Web Vitals
    ...getWebInstrumentations({
      captureConsole: true,
      capturePerformance: true,
    }),
    // Propagates W3C trace headers across fetch/XHR calls
    new TracingInstrumentation(),
  ],
});

// Track custom business milestones
faro.api.pushEvent('checkout_completed', {
  cartTotal: '142.50',
  itemCount: '3',
  currency: 'USD',
});

Step 7: Verify Ingestion and Inspecting Data

Test the pipeline by dispatching an artificial error from the browser console:

faro.api.pushError(new Error('StripeElementsError: Card element failed initialization'));

Open Grafana at http://localhost:3000. Head over to Explore, pick the Loki data source, and run this LogQL query:

{app="storefront-ui"} |= "StripeElementsError"

The log record shows the exact browser version, operating system, URL path, and source line. To monitor aggregate user performance, import Grafana Dashboard ID 17997. You will immediately see production distributions for Largest Contentful Paint (target: < 2.5s), Interaction to Next Paint (INP, target: < 200ms), and Cumulative Layout Shift (< 0.1) across real devices.

Share: