Using Cloudflare Workers to create a reverse proxy for Grafana Faro


As I alluded to in my original post discussing Grafana Faro, ad-blockers and anti-tracking software can prevent my website from correctly sending telemetry to Grafana. My strategy to get around this is to proxy all Grafana Faro requests through a Cloudflare Worker. I created a proxy server which uses Cloudflare Workers environment secrets to keep the Grafana ingest tokens secure, allowing me to scale this proxy server for multiple apps.

Here is the basic design:

  1. A frontend app is created in Grafana Faro with a unique ingest token
  2. The ingest token is stored as an environment variable in the Cloudflare Worker grafana-faro-proxy
  3. grafana-faro-proxy receives requests from the client with a query parameter to determine the source app (e.g., ?app=blog)
  4. The request/response to and from the Grafana collector endpoint are proxied between the client

To iterate on the Cloudflare Worker code, I opted to use wrangler to run the worker locally and test using this blog – which I always run locally when writing blog posts just like I am right now.

I updated the faroConfig const used in the frontend to set up the client telemetry.

const faroConfig = {
  // Use appropriate URL based on environment
  url: isLocalDev 
    ? "http://localhost:8787/faro-proxy?app=blog" // Local development proxy
    : "https://michaellamb.dev/faro-proxy?app=blog", // Production proxy
  app: {
    name: "blog",
    version: "1.0.0",
    environment: isLocalDev ? "development" : "production",
  }

grafana-faro-proxy - worker.js

The complete source code for the proxy server is below. You can find the latest version in the GitHub repository.

/**
 * Cloudflare Worker for Grafana Faro RUM Data Proxy
 * Proxies requests from michaellamb.dev to Grafana Cloud
 */

// Default CORS headers with wildcard origin
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, x-faro-session-id',
  'Access-Control-Max-Age': '86400',
};

// New function to generate proper CORS headers
function getCorsHeaders(request) {
  // Get the Origin header from the request
  const origin = request.headers.get('Origin');
  
  // If there's an origin header and it's from localhost or your domains, use it
  if (origin && (origin.includes('localhost') || 
                 origin.includes('michaellamb.dev'))) {
    return {
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, x-faro-session-id',
      'Access-Control-Max-Age': '86400',
      'Vary': 'Origin', // Important when varying response based on Origin
    };
  }
  
  // Default CORS headers (wildcard)
  return CORS_HEADERS;
}

// Bot detection patterns
const BOT_USER_AGENTS = [
  'bot', 'crawler', 'spider', 'scraper', 'facebookexternalhit',
  'twitterbot', 'linkedinbot', 'whatsapp', 'telegram', 'slackbot',
  'googlebot', 'bingbot', 'yandexbot', 'duckduckbot', 'baiduspider'
];

function isBot(userAgent) {
  if (!userAgent) return false;
  const ua = userAgent.toLowerCase();
  return BOT_USER_AGENTS.some(pattern => ua.includes(pattern));
}

function isValidOrigin(origin, allowedOrigins) {
  if (!allowedOrigins) return true;
  const origins = allowedOrigins.split(',').map(o => o.trim());
  return origins.includes(origin) || origins.includes('*');
}

function getIngestTokenForApp(appName, env) {
  const tokenMap = {
    'blog': env.BLOG_INGEST_TOKEN,
    'letterboxd-viewer': env.LETTERBOXD_INGEST_TOKEN
  };
  
  return tokenMap[appName] || env.BLOG_INGEST_TOKEN; // default to blog token
}

function detectAppFromRequest(request) {
  const url = new URL(request.url);
  const referer = request.headers.get('Referer');
  
  // Method 1: Check for app parameter in query string
  const appParam = url.searchParams.get('app');
  if (appParam) {
    return appParam;
  }
  
  // Method 2: Detect from referer header
  if (referer) {
    if (referer.includes('/letterboxd-viewer/')) {
      return 'letterboxd-viewer';
    }
    // Default to blog for main site
    return 'blog';
  }
  
  // Method 3: Check custom header (if you want to add this to your frontend)
  const appHeader = request.headers.get('X-App-Name');
  if (appHeader) {
    return appHeader;
  }
  
  // Default to blog
  return 'blog';
}

async function handleFaroProxy(request, env) {
  // Get configuration from environment variables
  const collectorHost = env.GRAFANA_COLLECTOR_HOST || 'faro-collector-prod-us-east-0.grafana.net';
  const allowedOrigins = env.ALLOWED_ORIGINS;
  
  // Detect which app this request is from and get appropriate token
  const appName = detectAppFromRequest(request);
  const ingestToken = getIngestTokenForApp(appName, env);
  
  if (!ingestToken) {
    console.error(`No ingest token found for app: ${appName}`);
    return new Response('Configuration Error: No ingest token', { 
      status: 500,
      headers: getCorsHeaders(request) 
    });
  }
  
  const collectorPath = `/collect/${ingestToken}`;

  // Validate origin
  const origin = request.headers.get('Origin');
  if (allowedOrigins && !isValidOrigin(origin, allowedOrigins)) {
    return new Response('Forbidden: Invalid origin', { 
      status: 403,
      headers: getCorsHeaders(request) 
    });
  }

  // Bot detection
  const userAgent = request.headers.get('User-Agent');
  if (isBot(userAgent)) {
    console.log('Bot detected, blocking request:', userAgent);
    return new Response('Blocked: Bot detected', { 
      status: 403,
      headers: getCorsHeaders(request) 
    });
  }

  try {
    // Parse the incoming URL to get any additional path
    const url = new URL(request.url);
    const pathSuffix = url.pathname.replace('/faro-proxy', '');
    
    // Construct the target URL
    const targetUrl = `https://${collectorHost}${collectorPath}${pathSuffix}${url.search}`;
    
    // Clone the request to modify headers
    const modifiedRequest = new Request(targetUrl, {
      method: request.method,
      headers: request.headers,
      body: request.body,
    });

    // Add required headers
    modifiedRequest.headers.set('X-Forwarded-For', request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || '');
    modifiedRequest.headers.set('X-Forwarded-Proto', 'https');
    
    // Set Host header to the target host
    modifiedRequest.headers.set('Host', collectorHost);

    // Optional: Add custom data enrichment
    if (request.method === 'POST') {
      // You could modify the request body here to add custom fields
      // For now, we'll pass it through unchanged
    }

    // Make the request to Grafana
    const response = await fetch(modifiedRequest);
    
    // Create the response with CORS headers
    // First, get all the original headers except CORS headers
    const responseHeaders = Object.fromEntries(response.headers.entries());
    
    // Remove any existing CORS headers to prevent duplicates
    delete responseHeaders['access-control-allow-origin'];
    delete responseHeaders['access-control-allow-methods'];
    delete responseHeaders['access-control-allow-headers'];
    delete responseHeaders['access-control-max-age'];
    delete responseHeaders['vary'];
    
    // Create the response with our CORS headers
    const modifiedResponse = new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: {
        ...responseHeaders,
        ...getCorsHeaders(request),
      },
    });

    console.log(`Proxied request for app "${appName}": ${request.method} ${url.pathname} -> ${response.status}`);
    return modifiedResponse;

  } catch (error) {
    console.error('Proxy error:', error);
    return new Response('Internal Server Error', { 
      status: 500,
      headers: getCorsHeaders(request) 
    });
  }
}

async function handleRequest(request, env) {
  const url = new URL(request.url);
  
  // Handle CORS preflight requests
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 200,
      headers: getCorsHeaders(request),
    });
  }

  // Route faro-proxy requests
  if (url.pathname.startsWith('/faro-proxy')) {
    return handleFaroProxy(request, env);
  }

  // For non-proxy requests, return 404
  return new Response('Not Found', { 
    status: 404,
    headers: getCorsHeaders(request) 
  });
}

// Cloudflare Workers entry point
export default {
  async fetch(request, env, ctx) {
    return handleRequest(request, env);
  },
};

Testing and Conclusion

Seeing new data in both development and production environments means that the proxy is working!

dashboard

Grafana Faro is an excellent and easy-to-setup solution for frontend observability but to benefit from RUM it is probably necessary to proxy the collector endpoint for best results.


View on Threads

Authored by Michael Lamb.
Published on 08 August 2025.
Category: Cloudflare


JXN Recommendation List for Magnolia Conf


Welcome to JXN

JXN – Jackson, MS – known as the City with Soul.

I’m just one person who has lived here and there are a LOT of things I don’t know about this city, but I do know the places I like to go and the things I like to do. This list exists because I wanted to create a guide for the audience attending Magnolia Conf in October, but really it is a little map of my life in Jackson.

For Distinguished Smokers - The Country Squire

The Country Squire holds a special place in my heart.

I was given my first pipe in 2009 and found myself enjoying a smoke session with friends as a regular happening. The community at the Squire is filled with JXN locals and the shop has global attraction.

My friends and colleagues go to The Country Squire, but there are a few other places that may strike your fancy.

Honorable Mentions

For Lovers of Beer - Fertile Ground

I’m a big fan of Fertile Ground Beer Co – I sometimes refer to it as the “cultural gateway” of JXN. I’ve hosted a number of events here, some including the Jackson Area Web and App Developers Code && Beer social hours.

The taproom is large and inviting. For me, it’s the perfect place to read a book while sipping good beer.

For Your Coffee Fix - Native & Cultivate

JXN has great craft coffee shops!

My favorite place for a cortado is Native Coffee.

One of the coolest and newest places in the city is an experimental coffee space called Cultivate. It’s a partnership between Northshore Coffee Co. and Fertile Ground Beer Co. and there are some concoctions I love getting – like the coffee/beer beverages. They mix a coffee concentrate into one of multiple beer styles. The beers rotate based on availability, but my favorite was the lager before it ran out. I won’t promise what will be there come October because I do not know, but you could follow Cultivate on Instagram or sign up for their email newsletter to stay in the know.

For the Culture Nerds

Mississippi Museum of Art

YOU ARE LUCKY – the conference venue is ALREADY one of the best places for culture in JXN!

The Mississippi Museum of Art has hosted all sorts of great storytellers over the years, and this conference is a unique opportunity to be part of the museum’s history. I hope you explore the exhibits and walk the grounds – whether individually or with a group, you’ll be taking advantage of an excellent space and one of my favorite places in JXN.

Offbeat

Not far from the art museum is a black-owned comic book & record store called Offbeat. Comic books, graphic novels, casettes, compact discs, and vinyls and merch and more!

Mississipppi Museum of Natural Science

If you’re wanting to check out another great museum in the city, I love the Mississippi Museum of Natural Science! It’s got a lot of great exhibits, both indoor and outdoor. The campus is located on Lefleur’s Bluff, which is a nice little hike just behind the museum.

Lemuria Books

If you value the independent bookstore, you’ll want to visit Lemuria in the historic Banner Hall. What you’ll find is floor-to-ceiling bookshelves, helpful and friendly staff, and a community of readers.

For Nature and Moving the Body - The Museum Trail

The Museum Trail is a 1.5 mile multi-use trail connecting downtown Jackson and the Museum District (where the Children’s and Natural Science museums are located). There is a fitness court located near the trailhead off High Street.

For More Ideas - Visit Jackson

The city tourism office is known as Visit Jackson and is an excellent resource for a more exhaustive list of events and places in JXN. I’d highly recommend browsing their site if I missed anything you might be interested in!

See you at Magnolia Conf 2025!


Authored by Michael Lamb.
Published on 02 August 2025.
Category: Social


Jackson Area Web and App Developers Community Survey


I am the organizer of the Jackson Area Web and App Developers community on Meetup and Discord. I am writing to share a survey I have created to better understand the needs of our community. I would be grateful if you could take a few minutes to complete it.

If you’ve never visited my blog before, I hope you’ll take a look around. I write about technology and my life. I also have a GitHub profile where I share my code and projects. This blog is hosted for free on GitHub Pages.

I’d love to feature some of the projects from our community on the blog. If you have a project you’d like to share, please let me know.

There’s a section to include any freeform feedback, just share your blog, a little bit about yourself, and how I can reach you. I look forward to your contributions!

Why is it important to survey the community?

The survey will help me understand the needs of our community and make sure we are meeting the needs of our members. It will also help me make sure we are providing the best possible experience for our members.

The survey is available on Microsoft Forms using the embed below.

All responses are anonymous.


Authored by Michael Lamb.
Published on 26 July 2025.
Category: Social
Tags: community , feature


All of this has happened before


…and all of it will happen again

Are the Cylons coming?

I love science fiction. I claim it as my favorite genre, mostly for books but also for movies. I’ve especially enjoyed the exploration of machine versus human in television works like Battlestar Galactica.

As a person who enjoys the use and evolution of language, I have been both perplexed and impressed witnessing the rebranding of LLM technology as “Artificial Intelligence.” I prefer to use the term machine intelligence.

“Artificial intelligence” and “machine intelligence” are closely related terms often used interchangeably, yet they carry different conceptual emphases and historical contexts.

Artificial Intelligence is the more established and widely recognized term, coined by John McCarthy in 1956. It emphasizes the creation of systems that can perform tasks typically requiring human intelligence—reasoning, learning, perception, and decision-making. The “artificial” aspect highlights that these capabilities are engineered rather than naturally occurring. AI encompasses a broad range of approaches, from symbolic reasoning and expert systems to modern machine learning and neural networks.

Machine Intelligence, by contrast, emphasizes the computational and mechanistic aspects of intelligent behavior. This term focuses more on the underlying processes and mechanisms that enable intelligent behavior in machines, rather than human-like outcomes. It encompasses both AI systems and broader computational approaches to intelligence, including distributed systems, swarm intelligence, and emergent behaviors from simple rules.

The key differences lie in their framing: AI is often anthropocentric, measuring success against human cognitive abilities and seeking to replicate or surpass human-level performance. Machine intelligence takes a more mechanistic view, focusing on how computational systems can exhibit intelligent behavior through their own unique processes, which may not mirror human cognition at all.

In practice, these terms appear synonymously in technical literature and industry contexts. However, “machine intelligence” typically appears in discussions emphasizing engineering and computational aspects, while “artificial intelligence” dominates conversations about cognitive capabilities, ethics, and societal impact.

Both concepts ultimately describe computational systems that can adapt, learn, and solve problems, but they represent different philosophical approaches to understanding and developing intelligent machines.

Forgive me if this seems pedantic. I’m pursuing precision of language—a task that often feels Sisyphean, yet necessary.

I think it’s important to consider the contrasting goals of artificial intelligence and machine intelligence. Capitalism and aritficial intelligence will create a hellscape benefitting a few. Maybe there’s a better society if more people start thinking about machine intelligence.

BSG feel apt to reference in this discourse because the show explores themes of consciousness, the nature of intelligence, and the blurry line between human and machine.

BSG Quotes for your consideration

Brother Cavil In all your travels, have you ever seen a star go supernova?

Ellen Tigh No.

Brother Cavil No? Well, I have. I saw a star explode and send out the building blocks of the Universe. Other stars, other planets and eventually other life. A supernova! Creation itself! I was there. I wanted to see it and be part of the moment. And you know how I perceived one of the most glorious events in the universe? With these ridiculous gelatinous orbs in my skull! With eyes designed to perceive only a tiny fraction of the EM spectrum. With ears designed only to hear vibrations in the air.

Ellen Tigh The five of us designed you to be as human as possible.

Brother Cavil I don’t want to be human! I want to see gamma rays! I want to hear X-rays! And I want to - I want to smell dark matter! Do you see the absurdity of what I am? I can’t even express these things properly because I have to - I have to conceptualize complex ideas in this stupid limiting spoken language! But I know I want to reach out with something other than these prehensile paws! And feel the wind of a supernova flowing over me! I’m a machine! And I can know much more! I can experience so much more. But I’m trapped in this absurd body! And why? Because my five creators thought that God wanted it that way!


There’s a reason you separate military and the police. One fights the enemies of the state, the other serves and protects the people. When the military becomes both, then the enemies of the state tend to become the people. Commander William Adama


The content of this post was edited by Claude.ai


Authored by Michael Lamb.
Published on 09 July 2025.


Adding frontend observability to michaellamb.dev


After working with Grafana at my company, I decided to see how far the free accounts stretch. After all, I’ve got an internet domain and reasons to observe it, so why not use Grafana for observability?

What I get from Grafana Faro

Grafana Faro dashboard for the blog application

This is the current view of the dashboard with metrics from the blog application I’m observing. It’s activity since my last blog post. Grafana Faro is loaded on the client-side and is blocked by some ad-blockers automatically, yet I get to see how my page performs for an end user for the folks who access my page without one.

Sometimes, users visit a website without an ad-blocker without even realizing it because they’re using something like a social media app and it loads a web browser to keep a user in-app. I’ve noticed most sessions like this are generated from my LinkedIn post advertising last week’s update.

What it took to start using Faro

Grafana makes it very easy to integrate Faro in a frontend app. Either through a web SDK or by importing it through the CDN, anyone can have observability in minutes. Most of the code below is generated within the Grafana dashboard, but I refactored it to work with my development flow. I have observability segmented by environment, so I can see how my website performs in dev and prod.

(function () {
  // Check if we're in a local development environment
  const isLocalDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
  
  console.log('isLocalDev', isLocalDev);
  var webSdkScript = document.createElement("script");

  // fetch the latest version of the Web-SDK from the CDN
  webSdkScript.src =
    "https://unpkg.com/@grafana/faro-web-sdk@^1.4.0/dist/bundle/faro-web-sdk.iife.js";

  webSdkScript.onload = () => {
    // Configure Faro differently based on environment
    const faroConfig = {
      url: "https://faro-collector-prod-us-east-0.grafana.net/collect/...",
      app: {
        name: "blog",
        version: "1.0.0",
        environment: isLocalDev ? "development" : "production",
      }
    };
    
    // Add transport configuration for local development to handle CORS
    if (isLocalDev) {
      faroConfig.transport = {
        mode: 'no-cors'
      };
    }
    
    // Initialize Faro with the appropriate configuration
    window.GrafanaFaroWebSdk.initializeFaro(faroConfig);

    // Load instrumentations at the onLoad event of the web-SDK and after the above configuration.
    // This is important because we need to ensure that the Web-SDK has been loaded and initialized before we add further instruments!
    var webTracingScript = document.createElement("script");

    // fetch the latest version of the Web Tracing package from the CDN
    webTracingScript.src =
      "https://unpkg.com/@grafana/faro-web-tracing@^1.4.0/dist/bundle/faro-web-tracing.iife.js";

    // Initialize, configure (if necessary) and add the the new instrumentation to the already loaded and configured Web-SDK.
    webTracingScript.onload = () => {
      window.GrafanaFaroWebSdk.faro.instrumentations.add(
        new window.GrafanaFaroWebTracing.TracingInstrumentation()
      );
    };

    // Append the Web Tracing script script tag to the HTML page
    document.head.appendChild(webTracingScript);
  };

  // Append the Web-SDK script script tag to the HTML page
  document.head.appendChild(webSdkScript);
})();

What else does Faro do

I don’t really know.

And in most ways, that’s because I’m not a frontend developer – but I do know that Faro allows you to further instrument your application for error tracking and custom signals. And yet, I am a frontend developer, I just don’t prefer it and so work on it begrudgingly. Although I love me some HTML.

In contexts where people care, Faro instrumentation enables things like conversion funnel analysis, feature usage tracking, A/B testing, monitoring and alerting longer API response times, feature adoption, etc.

If that’s you and you want something to help you do that, I suggest Grafana Faro.


Authored by Michael Lamb.
Published on 18 June 2025.
Category: Social



About michaellamb.dev

Michael Lamb is a software engineer working at C Spire. If you have a blog-specific inquiry please create a new issue on GitHub. Feel free to fork this blog and build your own!

Get to know who I am in my first post Hello, World!

© Copyright 2021-2025
Michael Lamb Blog