Skip to content

feat(analytics): add click extension link event tracking for banner and offerwall#683

Open
kjmitchelljr wants to merge 29 commits intomainfrom
feat/682-cdn-analytics-events
Open

feat(analytics): add click extension link event tracking for banner and offerwall#683
kjmitchelljr wants to merge 29 commits intomainfrom
feat/682-cdn-analytics-events

Conversation

@kjmitchelljr
Copy link
Copy Markdown
Collaborator

@kjmitchelljr kjmitchelljr commented Apr 23, 2026

Summary

Adds embed.click_link_banner and embed.click_link_offerwall event tracking when visitors click "Install the Web Monetization Extension" in the banner or offerwall embeds. Events are proxied CDN → API → Umami

Implementation

  • api/src/routes/events.tsPOST /events proxy: validates event name, injects website/hostname from env, forwards to Umami
  • cdn/src/lib/analytics.ts — builds the Umami payload and sends via navigator.sendBeacon
  • components/src/banner.ts + offerwall install-required screen — dispatch click-extension-link with { link }

Follow Ups

  • Origin allow-list + rate limit on /events

Part of

#625 / #682

Three small departures from your original:

@kjmitchelljr kjmitchelljr changed the base branch from main to feat/663-tools-analytics-events April 23, 2026 21:40
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Deployment results

Worker Alias URL Outcome
API - 3b3db205 success
CDN - 868c9342 success
App - 24449f9d success

Logs #25474495805

Comment thread cdn/src/lib/analytics.ts Outdated
type ClickLinkEvent = `click_link_${ExtensionLinkSource}`

export type CdnEventMap = {
[K in ClickLinkEvent]: undefined
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the event payload include { link: string } (the resolved extension store URL) to allow slicing by browser/store in the dashboard or is the tracking from Posthog fine on this front?

Copy link
Copy Markdown
Member

@sidvishnoi sidvishnoi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will end up injecting umami into the website. It'll track the website's page views etc. Also, what if the site already has umami? Which window.track() will get used? Also, what if site's CSP doesn't allow unami script/API?

We should instead proxy the events from CDN to go via a custom endpoint in API. That'll mean calling umami API from our API, and not using umami script.

Comment thread cdn/src/lib/analytics.ts Outdated
Copy link
Copy Markdown
Member

@sidvishnoi sidvishnoi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving in right direction now..

Comment thread api/src/routes/events.ts
Comment thread api/src/routes/events.ts Outdated
Comment on lines +22 to +24
'Content-Type': 'application/json',
'User-Agent': req.header('user-agent') ?? '',
'X-Forwarded-For': req.header('cf-connecting-ip') ?? '', // do we need this?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to pass through most of the headers here, not just these.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still debating whether to just pass-through all here. Went with allowlist that made sense to me, but we could include more. Let me know your thoughts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umami docs shows these headers: https://docs.umami.is/docs/enable-cloudflare-headers
I think we want to skip the Host as headers for sure, but we do want to pass through the website domain to be available in our event payload/data, so we know which sites are embedding our tools.

Comment thread api/src/routes/events.ts Outdated
Comment thread api/src/routes/events.ts Outdated
Comment thread cdn/src/lib/analytics.ts Outdated
Comment thread cdn/src/lib/analytics.ts Outdated
Comment thread api/src/routes/events.ts
payload: {
...event.payload,
website: env.UMAMI_WEBSITE_ID,
hostname: env.UMAMI_HOSTNAME,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DarianM @sidvishnoi would you be able to provide clarity on where the environment variables are kept? Would need to have one for UMAMI_HOSTNAME if I understand correctly, but please feel free to provide more insight

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

@sidvishnoi sidvishnoi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nearly there, but little broken..

Comment thread cdn/src/lib/analytics.ts
Comment on lines +6 to +9
export function trackEvent(event: TrackArgs): void {
if (!API_URL) return
// assumes all event names follow 'embed.click_link_<source>'
const url = `/embed/${event.name.replace('embed.click_link_', '')}`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative: How about we use a higher-order function like this:

export function trackEventFactory(tool: Tool) {
  return (event: TrackArgs) => {
    // ...
  }
}

const trackEvent = trackEventFactory('banner') // at some top-level in each of our tool script in CDN
trackEvent({name, /* ... */ })

Comment on lines +102 to +103
onExtensionLinkClick(e) {
const { link } = (e as CustomEvent<{ link: string }>).detail
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Let's update the Controller interface to denote what the type of e is.

Comment thread api/src/app.ts
Comment on lines +16 to +18
UMAMI_HOST?: string
UMAMI_WEBSITE_ID?: string
UMAMI_HOSTNAME?: string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 3 won't be available in env vars, but rather available as build time vars:

define: {
BUILD_AWS_PREFIX: JSON.stringify(process.env.BUILD_AWS_PREFIX ?? ''),
},

Then, we'd use them via @shared/defines:

import { AWS_PREFIX } from '@shared/defines'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would need to update CI a bit as well:

BUILD_AWS_PREFIX: ${{ vars.AWS_PREFIX }}

Comment thread api/src/app.ts
Comment on lines +16 to +18
UMAMI_HOST?: string
UMAMI_WEBSITE_ID?: string
UMAMI_HOSTNAME?: string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe call it UMAMI_API_HOST

Comment thread cdn/src/lib/analytics.ts
import type { TrackFn } from 'publisher-tools-api'
import { API_URL } from '@shared/defines'

type TrackArgs = Omit<TrackFn, 'url'>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps call TrackFn as something like: TrackPayload?

Comment thread cdn/src/lib/analytics.ts
Comment on lines +11 to +15
const blob = new Blob(
[JSON.stringify({ type: 'event', payload: { ...event, url } })],
{ type: 'text/plain' },
)
navigator.sendBeacon?.(`${API_URL}/events`, blob)
Copy link
Copy Markdown
Member

@sidvishnoi sidvishnoi May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gave me a HTTP 400 validation error as I used the PR preview as well as checked out locally.

{
  "error": {
    "message": "Validation failed",
    "code": "VALIDATION_ERROR",
    "details": {
      "issues": [
        {
          "path": "type",
          "message": "Invalid input: expected \"event\"",
          "code": "invalid_value"
        },
        {
          "path": "payload",
          "message": "Invalid input",
          "code": "invalid_union"
        }
      ]
    }
  }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could be because we're sending text/plain, but server validator is validating on zValidator('json', schema)`?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This worked locally:

diff --git a/api/src/routes/events.ts b/api/src/routes/events.ts
index 582bcfff..163f1278 100644
--- a/api/src/routes/events.ts
+++ b/api/src/routes/events.ts
@@ -24,14 +24,13 @@ export type TrackFn = z.infer<typeof payloadSchema>
 
 app.post(
   '/events',
-  zValidator('json', eventSchema),
   async ({ req, env, body }) => {
+    const event = z.parse(eventSchema, await req.json())
+    
     if (!env.UMAMI_HOST || !env.UMAMI_WEBSITE_ID || !env.UMAMI_HOSTNAME) {
       return body(null, 204)
     }
 
-    const event = req.valid('json')
-
     const headers = new Headers({ 'content-type': 'application/json' })
     for (const h of ['user-agent', 'accept-language', 'referer']) {
       const v = req.header(h)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants