A tour of what a Cloudflare Worker can do when you put it in someone else's request path. The integration is from 2023. The capabilities are still there.
Every e-commerce analytics setup has the same hole in it. Most teams never look straight at it, because there's no report that shows you the thing that isn't there.
You fire purchase from the browser. Somewhere between the customer's phone and Google's collector, a chunk of those events die. Ad blockers eat them. Safari's ITP shortens the cookie that identifies the session. Someone closes the tab on the thank-you page before the beacon flushes. Hotel wifi drops the request. None of it shows up anywhere, because analytics can only report the events it actually received.
And the missing ones aren't a random sample. They skew toward privacy-conscious users, toward Safari, toward mobile, toward exactly the segments you'd most want to understand. So your revenue is under-reported and your channel attribution is quietly wrong in a direction you can't see.
This was late 2023. I was at Lumino Labs building analytics infrastructure for Shopify merchants, and "your numbers don't match my Shopify admin" was the complaint we heard more than any other.
Nobody had to report it to us. It isn't a bug you get told about, it's ambient. Any merchant who has opened GA and their Shopify admin side by side has seen the two numbers disagree, shrugged, and carried on making decisions from the smaller one.
We fixed it with one Cloudflare Worker. The Worker is called lambda-dev-worker, which tells you roughly how much of this was planned.
Worth saying up front: this particular integration isn't running anymore. It worked in production through 2023 and into 2024 and then we turned it off, not because it broke, which I'll come back to at the end. Shopify's pixel surface and Google's collector have both moved since, so I wouldn't promise the specifics still line up.
But the specifics were never the interesting part. What made this possible was a handful of things a Worker can do that are awkward or expensive to do anywhere else, and every one of them is still true today. Several are more true than they were in 2023. So read this as a tour of those capabilities, with a slightly unusual analytics problem as the excuse for the tour.
The obvious fix, and why we threw it away
Ask anyone how to solve this and you get the same answer within about four seconds: send the event server-side. Shopify already fires an orders/create webhook on every completed order. Server to server, no browser involved, no ad blocker can touch it. Take that webhook, forward it to GA4's Measurement Protocol, go home.
We built exactly that. The function is still in the repo, sendServerSideEvent, assembling a proper MP payload with an API secret. It works precisely as documented, and it made things worse.
A Measurement Protocol hit arrives at Google with no browser attached to it. GA4's MP won't derive geography from an IP you hand it, so every purchase lands with no country, no city, no region. There's no user agent, so no device and no browser. And unless you happen to be holding the visitor's real client ID and session ID, it can't join to the session that produced the order, which means the purchase floats free of the campaign and the referrer and the landing page that earned it.
We had traded missing revenue for unattributable revenue. In a report whose whole purpose is working out which ad spend paid off, that's not obviously an improvement. We'd gone from orders we couldn't see to a pile of orders from "(not set) / (not set)".
So the question stopped being "how do I send this from a server." It became: how do I send this from a server and have Google receive it as though it came from the customer's browser?
Two doors into GA4
GA4 has two ingestion endpoints and most people only know about one.
There's the Measurement Protocol: documented, supported, deliberately limited in the ways above. And there's /g/collect, which is where gtag.js in the browser actually posts. Same collector. But a hit arriving there carries the full browser context, including the client ID, the session ID, the user agent, the origin and referer, and the source IP of the connection.
If you can produce a request to /g/collect that's indistinguishable from one the browser would have sent, GA doesn't process it as a server event. It processes it as a client event, because by every signal available to it, that's what it is.
A Worker turns out to be a very good place to produce that request. It's a real HTTP client running at an edge location near the customer, it can hold state in KV across the gap between the browser's visit and the webhook that shows up ten minutes later, and it costs approximately nothing.
Getting in the path
Shopify's Web Pixels let you configure the endpoint that client-side events are sent to. We pointed it at the Worker.
That one config change is what makes the rest possible. Every client event now arrives at our Worker before it goes anywhere near Google: page_viewed, product_viewed, checkout_started, checkout_completed. The Worker translates Shopify's event shape into GA4's parameter format and forwards it on.
// The browser's hit lands here first.
const url = new URL(request.url)
const gaPath = "/g/collect?" + url.search.split("/g/collect?")[1]
// Shopify shape -> GA4 params. ep. is a string, epn. a number, up. a user property.
const params = attachParamsToEvent(body)
await fetch("https://www.google-analytics.com" + gaPath + params, {
headers: {
"user-agent": request.headers.get("user-agent"),
"origin": request.headers.get("origin"),
"referer": request.headers.get("referer"),
},
})
Forwarding is the boring half. The useful half is what the Worker keeps on the way through.
Remembering who the browser was
Every /g/collect request the browser makes carries the visitor's identity in the query string:
v=2&cid=1234567890.1699999999&tid=G-XXXXXXX&sid=1700000000
cid is the GA client ID, the durable identifier for this browser. sid is the session. tid is the property. Those four fields are the entire key to landing a later event inside the right session, attributed to the right campaign.
So the Worker stashes that string in KV along with the user agent and the IP it saw, keyed under everything it might plausibly be able to look the visitor up by later:
const identity = JSON.stringify({
cacheFinalString: `v=2&cid=${cid}&tid=${tid}&sid=${sid}`,
agent: request.headers.get("user-agent"),
ip: request.headers.get("cf-connecting-ip"),
})
await kv.put(`cid_${cid}`, identity, { expirationTtl: 3600 })
await kv.put(`cart_token_cid_${cartToken}`, identity, { expirationTtl: 3600 })
await kv.put(`ip_${ip}`, identity, { expirationTtl: 3600 })
Three keys for one visitor, because at webhook time we have no idea which of them we'll be holding. That redundancy is most of the value of the system.
We also wrote the identifiers into Shopify itself as an order note attribute, lumino_identifiers, so the webhook payload would carry them directly wherever possible. Belt and braces. The note attribute survives even when the KV entry has aged out.
Everything in KV is stored with a 333### prefix in front of the JSON, which gets stripped on read. There is no clever reason for 333###. It was a string random enough to be unambiguous at the front of a stringified blob, and once it was in production across a few thousand keys it was never worth the migration to remove.
Four ways to find the same person
When the orders/create webhook lands, we need to work out which browser this order belongs to. There are four independent ways to answer that, and we try them in descending order of how much we trust them.
let identity = null, foundWhere = null
// 1. Note attributes. Shopify handed the identifiers straight back to us.
if (cid && sid && measurementId) {
identity = { cacheFinalString: `v=2&cid=${cid}&tid=${measurementId}&sid=${sid}` }
foundWhere = "NoteAttributes"
}
// 2. The client ID we cached during the session.
if (!identity && cid) {
identity = await kv.get(`cid_${cid}`); foundWhere = "CID"
}
// 3. Cart token. Survives some cases where the cookie didn't.
if (!identity && cartToken) {
identity = await kv.get(`cart_token_cid_${cartToken}`); foundWhere = "CartToken"
}
// 4. Last resort: the IP Shopify recorded against the order.
if (!identity && browserIp) {
identity = await kv.get(`ip_${browserIp}`); foundWhere = "IP"
}
if (!identity) foundWhere = "NoWhere"
Each fallback catches a different failure mode. The cart token rescues the customer whose GA cookie got cleared partway through checkout. The IP lookup picks up someone who blocked the pixel outright but still touched the Worker for something else earlier in the session. When all four miss we send the purchase anyway, minus the session join, which is still better than not sending it.
The part I'd keep if I could only keep one thing from this whole article is the next line. We attach foundWhere to the outgoing event as a parameter.
finalUrl += `&ep.foundWhere=${foundWhere}`
The fallback chain reports on itself, inside GA. You can build a report of how often each path fires. When NoWhere starts climbing for one merchant, their theme has broken the pixel and you know before they do. When CartToken starts carrying more than its usual share, something has changed in how cookies survive their checkout.
Instrument your own recovery logic. It's the cheapest monitoring you will ever build and it lives in the tool you already open every morning.
Replaying it as the browser
Now the Worker rebuilds the hit. It takes the cached identity string, appends the purchase parameters assembled from the webhook body (transaction ID, value, tax, shipping, currency, coupon, every line item) and fires it at /g/collect with the original user agent and origin.
await fetch("https://www.google-analytics.com" + finalUrl, {
headers: {
"user-agent": identity.agent, // the customer's browser, not ours
"origin": shopDomain,
"host": "www.google-analytics.com",
},
})
Two parameters here cost us more time than they should have.
If richsstsse survives into your reconstructed URL, GA responds with its server-side streaming format instead of accepting a normal hit. So it gets stripped:
finalUrl = finalUrl.replace("&richsstsse=", "").replace("&richsstsse", "")
The other one is v=2. Under some reconstruction paths the version param doesn't survive, and a hit without it is silently discarded. Not rejected, discarded. No error, no DebugView entry, nothing.
And then the parameter that does the actual work:
if (identity.ip) finalUrl += "&_uip=" + identity.ip
_uip is the IP override, and it's the single line separating this whole approach from the Measurement Protocol, because it's precisely the thing MP will not honour. We cached cf-connecting-ip when the browser came through. We hand it back here. GA resolves geography from it the same way it would for a live visitor, so country, region and city come back on an event that was assembled minutes later from a server-to-server webhook.
That's the trick. Everything else in this article is plumbing to guarantee that by the time you want to send the event, you still know the four things (cid, sid, user agent, IP) that let you send it as a person rather than as a datacenter.
Not sending it twice
The client fires purchase too, and most of the time it succeeds. If both paths fire you double the revenue, which is a substantially more embarrassing bug than under-reporting, because someone will make a budget decision on it before anyone notices.
So the client path writes the order ID to KV on its way through, and the webhook path checks first.
if (await isOrderAlreadySent(orderId)) {
url += "&ep.foundInCache=YES" // tag it, then drop it
return
}
The dedup key gets a much longer TTL than the identity keys: ten hours against one. That asymmetry is deliberate. A stale identity is actively harmful, because matching an order to the wrong visitor corrupts an attribution path. A stale dedup marker is harmless, since the worst case is declining to send a duplicate nobody was going to send anyway. Identity expires fast, dedup lingers.
Same instrumentation habit as before: the drop gets tagged rather than dropped silently, so "how often does the client path actually work?" is a question with a chart behind it instead of a shrug.
Using a webhook's retry as a scheduler
This is the part that took longest to work out, and it's my favourite thing in the codebase.
Shopify's orders/create webhook fires fast. Often it fires before the order has finished becoming an order: post-purchase upsells haven't been appended to the line items, discount allocations aren't settled. Send immediately and you record a purchase missing a chunk of its revenue.
The instinct is to reach for a delay mechanism. A queue, a scheduled job, a Durable Object alarm. But webhooks already have a retry mechanism built in, and a retry is a delay you didn't have to build.
const age = Date.now() - new Date(order.updated_at).getTime()
if (age < 10 * 60 * 1000) {
// Warm the cache for the retry that's coming.
await prefetchProductTypes(order.line_items)
// Then fail on purpose. Shopify will redeliver.
throw new Error("Early message received, not proceeding")
}
The Worker fails the webhook deliberately. Shopify redelivers later, by which point the order has settled, and that time it goes through. In the window before failing we prefetch what the retry is going to want (product types, category metadata) into KV, so the redelivery finds everything warm.
You get a delay, a retry budget and backoff for free, out of infrastructure somebody else operates. No queue to provision, no alarm to schedule, no state machine to reason about at 2am.
The same order comparison hands you upsell detection for nothing. If the stored copy of the order has more line items than the one in this webhook, an upsell happened in between:
if (webhookOrder.line_items.length < storedOrder.line_items.length) {
storedOrder.includesUpsell = "Yes"
}
What the Worker was actually doing
Strip out the analytics and this system is exercising six capabilities. I want to go through them individually, because each one generalises well beyond e-commerce, and all six still hold.
It was in the request path of a third party's client code. Shopify's Web Pixels let you configure the endpoint that client-side events get sent to. We put a Worker there, and from that moment every storefront event ran through our code before reaching anyone else. This is the one I'd most encourage you to steal, because the general form is much broader than Shopify: any product that lets you configure a URL is a product you can put a Worker in front of. Webhooks, pixels, callbacks, SSO redirects, CDN origins, SDK base URLs. Most teams treat that config field as plumbing. It's an interception point, and being able to claim one for the cost of a DNS record is unusual.
It got the visitor's real network identity for free. cf-connecting-ip, the true user agent, origin and referer, and if we'd wanted it, the whole request.cf object with country, region, ASN and TLS details already resolved. No proxy headers to reason about, no X-Forwarded-For chain to parse and mistrust. That's what made _uip possible at all. You cannot give GA back an IP you never reliably had.
It held state across requests separated by minutes. The browser's visit and the order webhook are different requests, from different clients, potentially ten minutes apart, and quite possibly handled in different cities. KV with a TTL bridged that gap, and the TTL mattered as much as the storage, because this is data you actively want to expire and never want to think about again. This capability has grown the most since. In 2023 KV was mostly what you had. Today the same problem gives you Durable Objects when you need a single consistent owner for a session, D1 when the relationships matter, and Queues when you want real async instead of the retry trick further up this page.
It was a credible HTTP client sitting near the user. This is the subtle one and it's the reason the whole approach works. The Worker runs at an edge location close to the customer and makes an ordinary outbound fetch with full control over the headers. Not a single fixed region emitting requests that look exactly like a single fixed region. When you're trying to produce a request indistinguishable from the browser's, being in roughly the right place with roughly the right headers is most of the job.
Failing was a scheduling primitive. Throwing from the handler put the work back in Shopify's retry queue, which handed us delay, backoff and a retry budget out of infrastructure someone else runs and pages for. Nothing about that is Cloudflare-specific, but Workers make it cheap to reach for, because there's no long-lived process sitting there that you feel obliged to use instead. Before you build a queue, it's worth asking who upstream is already willing to call you back.
It was cheap enough to sit in front of everything. This sounds like a footnote and it isn't. The reason we could put a Worker in the path of every single storefront event for every merchant is that doing so cost nearly nothing and required no capacity planning. If this had been a fleet of servers with a scaling policy and an on-call rotation, the design conversation would have started at "which events are important enough to route through us," and that question has no good answer. A dropped event is data that never comes back. Being able to say "all of them, always" is an architectural freedom that comes directly from the pricing model.
And the seventh, which isn't a platform capability so much as a habit the platform encourages: because the Worker was already rewriting every event, instrumenting our own logic into someone else's product was nearly free. ep.foundWhere cost one line and turned an invisible fallback chain into a chart. When you own the request path, observability stops being a separate system you have to build and becomes a parameter you append.
The whole thing was a few hundred lines and one KV namespace.
Where this could bite you
/g/collect is not a documented API. Google can change the format whenever they like and this breaks with no deprecation notice. The mitigating factor is that gtag.js in every browser on earth depends on the same format, so in practice it's stable. But this is unsupported territory and you should ship it knowing that. Google's own server-side GTM does approximately the same thing with official blessing, so if you want the sanctioned version of the idea, look there first.
Keep it to first-party data. Everything above is a merchant's own event data going to that merchant's own GA property, rebuilt from a hit the customer's browser was already making. That's the line and it's worth being deliberate about which side of it you're on. Consent state should travel with the event and get honoured on the replay path exactly as it would in the browser.
Watch the TTLs, and here I get to be specific, because while writing this article I went back through the code and found a bug I shipped two years ago:
await redis.put("cid_" + cid, "333###" + JSON.stringify({...}, {'expirationTtl': 3600}))
// ^ inside stringify, not put
The options object landed as JSON.stringify's replacer argument, where a plain object is silently ignored. So the cid_ and ip_ keys were written with no TTL at all. They never expired. cart_token_cid_ has the paren in the right place and was always fine.
Two consequences. KV grows forever, which is annoying but survivable. And ip_ lookups match against arbitrarily old entries, which is the bad one: shared NATs and mobile carrier IPs mean a months-old ip_ record can cheerfully attribute an order to a complete stranger's session. The IP fallback is the weakest link in the cascade by design, and a missing TTL turns "weak" into "wrong."
If you build this, put the IP fallback behind a short TTL and check that the TTL is actually reaching the function you think it is.
What it actually bought us, and how it ended
Not more events. What it bought was GA revenue that reconciled with the Shopify admin.
Every merchant has run that comparison, seen the gap, and quietly stopped trusting the dashboard. Not enough to stop using it, just enough to hedge every decision made from it. Once the two numbers agree, the dashboard becomes something you can spend real money against.
And then we shut the company down. Not the Worker, the company. We couldn't make a business out of analytics infrastructure. The market already had several large, well-funded players giving merchants an analytics product, and some of them were giving it away as a wedge into something else they actually wanted to sell. We were better at one specific thing than they were, which is not the same as having a business.
So the honest ending is that this system never failed. It was doing its job on the day we switched it off. Every piece of engineering here worked and none of it mattered, because being right about _uip doesn't create a market where there wasn't one.
The integration had a shelf life. What the Worker could do didn't, and I've reached for those six capabilities more times since than I can usefully count.
Top comments (0)