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
traceparentheaders. 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.

