Stop Paying for SpeedCurve: Self-Host Real User Monitoring with Grafana Faro

SpeedCurve starts at $20/month for a handful of sites and quickly climbs into triple digits once you add real traffic volumes. Datadog RUM? Don’t get me started. You’re essentially paying a subscription to see Core Web Vitals data that your own infrastructure is generating. That’s a bad deal, especially when the open-source ecosystem has matured enough to handle this properly.

This guide walks through setting up production-ready Real User Monitoring on your own infrastructure. No SaaS lock-in, no per-session pricing, no vendor deciding what data you can access.

What RUM Actually Gives You (vs. Synthetic)

Before jumping into tools, it’s worth being precise about what you’re capturing. Synthetic monitoring (Lighthouse CI, WebPageTest) runs scheduled tests from known locations against your site. It’s consistent, reproducible, and great for catching regressions. But it lies to you about what real users experience.

RUM captures the actual browser metrics from actual visitors: their network conditions, their devices, their geographic latency to your CDN edge. A user in Jakarta on a 4G connection hitting your EU-hosted app will show you numbers that no synthetic test from a Frankfurt data center will ever surface.

The core metrics worth tracking:

  • LCP (Largest Contentful Paint) — perceived load speed
  • FID / INP (Interaction to Next Paint, replaced FID in March 2024) — responsiveness
  • CLS (Cumulative Layout Shift) — visual stability
  • TTFB (Time to First Byte) — server + network latency
  • FCP (First Contentful Paint) — when something first appears
  • Resource timing — which assets are slow, which third-party scripts are killing you

A proper RUM setup lets you slice all of this by country, browser, connection type, URL pattern, and deployment version.

The Contenders

Three tools are worth your time for self-hosted RUM in 2026:

Grafana Faro (github.com/grafana/faro-web-sdk) — Purpose-built for frontend observability. Ships as an SDK you embed in your JS, pushes data to Grafana Agent or Alloy, then flows into Loki (logs/events), Tempo (traces), and Prometheus/Mimir (metrics). If you’re already running the Grafana stack, this is the natural choice. First-class Web Vitals support, source map integration, and session tagging.

OpenReplay (github.com/openreplay/openreplay) — Session replay plus performance data. Think FullStory or LogRocket, self-hosted. Captures click heatmaps, network requests, console errors, and performance timelines. Heavier infrastructure footprint than Faro, but unbeatable when you need to watch an actual user struggling through your checkout flow.

highlight.io (github.com/highlight/highlight) — Full-stack observability: error tracking, session replay, logging, and basic RUM. Self-hosted via Docker Compose. Good choice if you want one tool to replace Sentry + LogRocket + basic performance monitoring.

For pure performance monitoring at scale, Grafana Faro wins. For debugging UX issues, OpenReplay. For a batteries-included single-pane-of-glass, highlight.io.

The rest of this guide focuses on Grafana Faro because it integrates cleanly with infrastructure most teams already run, and its data model maps directly to how you’d query Web Vitals in practice.


Setting Up Grafana Faro: The Full Stack

The architecture is straightforward: Faro SDK (browser) → Grafana Alloy (collector) → Loki + Tempo + Prometheus → Grafana dashboards.

The Docker Compose Stack

Create a directory for this — ~/rum-stack/ — and drop in a docker-compose.yml:

version: "3.8"

volumes:
  grafana-data:
  loki-data:
  tempo-data:
  prometheus-data:

networks:
  rum:
    driver: bridge

services:
  # Grafana Alloy — the collector that receives Faro SDK data
  alloy:
    image: grafana/alloy:latest
    container_name: alloy
    ports:
      - "12345:12345"   # Alloy UI
      - "12347:12347"   # Faro receiver endpoint (HTTP)
    volumes:
      - ./alloy/config.alloy:/etc/alloy/config.alloy:ro
    command: run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
    networks:
      - rum
    restart: unless-stopped

  loki:
    image: grafana/loki:latest
    container_name: loki
    ports:
      - "3100:3100"
    volumes:
      - ./loki/config.yaml:/etc/loki/config.yaml:ro
      - loki-data:/loki
    command: -config.file=/etc/loki/config.yaml
    networks:
      - rum
    restart: unless-stopped

  tempo:
    image: grafana/tempo:latest
    container_name: tempo
    ports:
      - "3200:3200"   # Tempo HTTP API
      - "4317:4317"   # OTLP gRPC
    volumes:
      - ./tempo/config.yaml:/etc/tempo/config.yaml:ro
      - tempo-data:/tmp/tempo
    command: -config.file=/etc/tempo/config.yaml
    networks:
      - rum
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    networks:
      - rum
    restart: unless-stopped

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    ports:
      - "3000:3000"
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=changeme   # change this
      - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor
    networks:
      - rum
    restart: unless-stopped
    depends_on:
      - loki
      - tempo
      - prometheus

Grafana Alloy Configuration

Create alloy/config.alloy. Alloy is the modern replacement for Grafana Agent — it uses a River-based config language that’s more expressive than YAML:

// Faro receiver — accepts data pushed by the browser SDK
faro.receiver "rum_collector" {
  server {
    listen_address = "0.0.0.0"
    listen_port    = 12347
    cors_allowed_origins = ["*"]  // Tighten this in production!
  }

  // Ship logs/events to Loki
  output {
    logs   = [loki.write.default.receiver]
    traces = [otelcol.exporter.otlp.tempo.input]
  }

  extra_log_labels = {
    app = "faro-rum",
  }
}

// Write logs to Loki
loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}

// Forward traces to Tempo via OTLP
otelcol.exporter.otlp "tempo" {
  client {
    endpoint = "tempo:4317"
    tls {
      insecure = true
    }
  }
}

Loki Configuration

loki/config.yaml — minimal but functional:

auth_enabled: false

server:
  http_listen_port: 3100

common:
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2020-10-24
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

limits_config:
  # Faro sends structured log lines — raise ingestion rate for high-traffic sites
  ingestion_rate_mb: 16
  ingestion_burst_size_mb: 32

Tempo Configuration

tempo/config.yaml:

server:
  http_listen_port: 3200

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

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

compactor:
  compaction:
    block_retention: 72h  # 3 days — tune based on your disk budget

metrics_generator:
  registry:
    external_labels:
      source: tempo
  storage:
    path: /tmp/tempo/generator/wal
    remote_write:
      - url: http://prometheus:9090/api/v1/write
        send_exemplars: true

Grafana Data Source Provisioning

grafana/provisioning/datasources/datasources.yaml:

apiVersion: 1

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

  - name: Tempo
    type: tempo
    url: http://tempo:3200
    access: proxy
    jsonData:
      tracesToLogs:
        datasourceUid: loki
        tags:
          - key: app
      lokiSearch:
        datasourceUid: loki
      serviceMap:
        datasourceUid: prometheus

  - name: Prometheus
    type: prometheus
    url: http://prometheus:9090
    access: proxy
    isDefault: true

Start the stack:

docker compose up -d

Integrating the Faro SDK Into Your Frontend

Install the SDK:

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

Initialize it early — ideally before your app framework mounts. For a plain JS/TS project:

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

const faro = initializeFaro({
  url: 'http://your-server:12347/collect',  // Alloy Faro receiver
  app: {
    name: 'my-frontend',
    version: __APP_VERSION__,  // inject at build time — critical for correlating deploys
    environment: import.meta.env.MODE,
  },
  instrumentations: [
    // Captures Web Vitals (LCP, INP, CLS, FCP, TTFB) automatically
    ...getWebInstrumentations({
      captureConsole: true,
      captureConsoleDisabledLevels: [],
    }),
    // Distributed tracing — links frontend spans to backend traces
    new TracingInstrumentation({
      instrumentationOptions: {
        propagateTraceHeaderCorsUrls: [
          new RegExp(`${import.meta.env.VITE_API_URL}.*`),
        ],
      },
    }),
  ],
});

// Optionally attach user context after auth
faro.api.setUser({
  id: user.id,
  // Don't send PII without consent
});

For Next.js, initialize this in _app.tsx or a client component that loads before navigation events fire. The SDK hooks into the PerformanceObserver API automatically — no manual instrumentation needed for Web Vitals.


Building the Dashboard

Once data flows in, the Grafana Explore tab lets you write LogQL queries against Faro’s structured log format:

# Web Vitals for LCP — filter to real users, exclude bots
{app="faro-rum"} 
  | json 
  | type = "measurement" 
  | measurement_type = "webVitals"
  | line_format "{{.measurement_values_LCP}}"

For a proper dashboard, import Grafana’s official Faro dashboards from the dashboard catalog. Dashboard IDs 22209 and 22210 cover Web Vitals and error rates respectively — save yourself the 2 hours of panel config.


Gotchas

CORS will bite you immediately. The Faro collector needs to accept requests from your frontend’s origin. The cors_allowed_origins = ["*"] in the Alloy config above is fine for local testing, but in production lock it down to your actual domain(s). If you forget, you’ll see CORS errors in DevTools and zero data in Grafana.

Don’t expose Alloy’s port directly to the internet. Put Nginx or Caddy in front of it with TLS. The Faro endpoint receives raw browser data — you don’t want it crawlable or abusable. Rate-limit at the proxy layer too; a misconfigured SDK could flood your collector.

Version tagging is non-negotiable. If you don’t inject a build version into app.version, every performance regression becomes a debugging nightmare. Correlate deploys with metric changes by baking the git SHA or semver into the bundle at build time. In Vite: define: { __APP_VERSION__: JSON.stringify(process.env.npm_package_version) }.

Loki storage grows fast. Faro emits an event for every route change, every user interaction if you enable it, plus Web Vitals measurements. Set a retention policy from day one — the limits_config in Loki supports retention_period. For a site doing 50K daily active users, expect 2-5 GB/day of raw log data before compression.

INP replaced FID. If you’re reading old RUM documentation, FID (First Input Delay) was deprecated. The Faro SDK measures INP (Interaction to Next Paint) automatically as of v1.3+. Don’t build dashboards around FID for new deployments.

Source maps. Without source maps, JavaScript errors show you minified stack traces. Faro supports uploading source maps to Grafana — configure this in your CI pipeline or you’ll spend time manually mapping line numbers.


OpenReplay: When You Need to Watch the Replay

Grafana Faro gives you metrics and traces. OpenReplay gives you video.

If your conversion funnel is broken and the metrics tell you users are rage-clicking without knowing where, OpenReplay is what you want. It reconstructs the DOM state on each event so you can literally replay what a user saw.

Quick Docker Compose deploy from their repo:

git clone https://github.com/openreplay/openreplay
cd openreplay/scripts/docker-compose
cp .env.example .env
# Edit .env: set DOMAIN_NAME, POSTGRES_PASSWORD, REDIS_PASSWORD
docker compose up -d

OpenReplay needs a domain with HTTPS — it won’t work over plain HTTP for the session capture to function correctly. It’s heavier (PostgreSQL, Redis, MinIO, Kafka), so allocate at least 4 GB RAM and 4 cores.

The SDK integration is similar to Faro:

import OpenReplay from '@openreplay/tracker';

const tracker = new OpenReplay({
  projectKey: 'YOUR_PROJECT_KEY',  // from OpenReplay dashboard
  ingestPoint: 'https://your-openreplay.domain/ingest',
  capturePerformance: true,  // enables Web Vitals capture
});

tracker.start();

Use both Faro and OpenReplay together if the budget allows — Faro for aggregate metrics and alerting, OpenReplay for surgical debugging of specific user paths.


Production-Ready Checklist

Before going live, run through these:

Security:

  • TLS on every public endpoint (Alloy receiver, OpenReplay ingest)
  • CORS locked to your actual domain list
  • Alloy UI (port 12345) not exposed publicly
  • Rate limiting at the reverse proxy layer

Data hygiene:

  • Strip PII before it hits Faro — don’t send email addresses or tokens in user context
  • Review what captureConsole: true actually captures; console logs sometimes contain auth tokens during development
  • Check your GDPR/privacy policy covers RUM data collection if you operate in the EU

Scaling:

  • Single Loki instance is fine to ~500K events/day; beyond that, switch to Loki in microservices mode or consider Grafana Cloud for just the storage tier
  • Tempo’s local storage needs regular compaction — the config above handles this, but monitor disk usage
  • Alloy is stateless and horizontally scalable; put it behind a load balancer for high-traffic sites

Alerting:

  • Set Grafana alerts on P75 LCP > 4s and P75 CLS > 0.25 — these are Google’s "needs improvement" thresholds
  • Alert on error rate spikes (Faro logs JavaScript errors automatically)
  • Alert on INP degradation after deploys

The Bottom Line

SpeedCurve and Datadog RUM are polished products. But you’re paying for someone else’s infrastructure to store and query data that originates in your users’ browsers. Grafana Faro, backed by the LGTM stack (Loki, Grafana, Tempo, Mimir), gives you equivalent observability at the cost of a $10/month VPS and an afternoon of configuration.

The tradeoff is real: you own the ops burden. Disk fills up, Loki configs need tuning, Alloy will restart at inopportune moments. But for any team that’s already running Kubernetes or Docker on their own infrastructure, this is not materially different from what you’re already managing.

The SpeedCurve money can go toward a faster CDN edge instead. That actually moves your LCP numbers.

Leave a comment

👁 Views: 6,788 · Unique visitors: 10,739