Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6579ea8
feat(analytics): add tools event tracking and shared event constants
kjmitchelljr Apr 15, 2026
118e943
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 15, 2026
3dd8b6a
Resolve pnpm lockfile
kjmitchelljr Apr 15, 2026
95649ef
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 16, 2026
f5b43de
removed shared analytics and made TrackFn constrained to ToolsEventMap
kjmitchelljr Apr 21, 2026
613f985
resolve pnpm lockfile
kjmitchelljr Apr 21, 2026
5b8bc1c
use id instead of authServer for wallet provider capture
kjmitchelljr Apr 21, 2026
51274ac
Add simple tracking for fields changed
kjmitchelljr Apr 21, 2026
db4fd66
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 21, 2026
100df55
Revert "Add simple tracking for fields changed"
kjmitchelljr Apr 21, 2026
28ae2bc
Remove tools prefix
kjmitchelljr Apr 21, 2026
4613aa0
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 23, 2026
598f8e9
Remove tool settings and update link tag tracking
kjmitchelljr Apr 23, 2026
5dcc993
Tool name added to event
kjmitchelljr Apr 23, 2026
5890bda
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 23, 2026
9621e4a
Add analytic tracking for extension link clicks on banner and offerwall
kjmitchelljr Apr 23, 2026
8e74ad4
prettier fix
kjmitchelljr Apr 23, 2026
6a750ba
lint fix
kjmitchelljr Apr 23, 2026
6827127
Clean up code - don't need to match frontend as much
kjmitchelljr Apr 24, 2026
81753d0
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 27, 2026
5f1532b
Simplify logic for analytic events being tracked in umami
kjmitchelljr Apr 28, 2026
6cbdcec
Merge branch 'main' into feat/663-tools-analytics-events
kjmitchelljr Apr 28, 2026
c473b26
Merge branch 'feat/663-tools-analytics-events' into feat/682-cdn-anal…
kjmitchelljr Apr 28, 2026
1d9ee2d
Merge branch 'main' into feat/682-cdn-analytics-events
kjmitchelljr Apr 28, 2026
e0c0f6a
feat(analytics): proxy CDN extension-link clicks to Umami via API
kjmitchelljr Apr 30, 2026
132294d
Merge branch 'main' into feat/682-cdn-analytics-events
kjmitchelljr May 6, 2026
6858838
feat(analytics): proxy Umami payload andinclude url and extension lin…
kjmitchelljr May 7, 2026
9db44cd
Resolve prettier issue
kjmitchelljr May 7, 2026
8e215c8
set prettier path for pnpm
kjmitchelljr May 7, 2026
918c997
Merge branch 'main' into feat/682-cdn-analytics-events
kjmitchelljr May 11, 2026
f671e58
Resolve issue where zValidator wasn't accepting text/plain and fix nit
kjmitchelljr May 12, 2026
a1b024a
Use build time variables for API and hostname from CDN
kjmitchelljr May 12, 2026
903bfe1
Added event detail in controller
kjmitchelljr May 12, 2026
1d22a5a
introduce trackEventFactory and rename events
kjmitchelljr May 12, 2026
19ba347
Merge branch 'main' into feat/682-cdn-analytics-events
kjmitchelljr May 12, 2026
2ce05a5
Remove bubbles and composed flags on click-extension-link
kjmitchelljr May 12, 2026
b8b5162
Fix prettier error
kjmitchelljr May 12, 2026
0aad2d9
Forward Cloudflare geo headers to Umami
kjmitchelljr May 12, 2026
9fc349e
Add a few mock tests if anything to catch any future changes
kjmitchelljr May 12, 2026
87b0a5c
Remove API_HOST variable
kjmitchelljr May 13, 2026
a6063d1
add comments for schema object and afterEach for each test
kjmitchelljr May 13, 2026
2876510
Export of EventsData and more ergonomic function handling
kjmitchelljr May 13, 2026
2354170
Nit - clean up event url
kjmitchelljr May 13, 2026
af0ff16
Merge branch 'main' into feat/682-cdn-analytics-events
kjmitchelljr May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-worker/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ inputs:
description: 'AWS_PREFIX used for config storage'
required: true
BUILD_UMAMI_HOST:
description: 'Umami analytics host URL'
description: 'Umami host (e.g. https://cloud.umami.is)'
required: false
BUILD_UMAMI_WEBSITE_ID:
description: 'Umami analytics website ID'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ jobs:
mode: ${{ steps.deploy-mode.outputs.mode }}
skip-deploy: ${{ steps.deploy-check.outputs.should-deploy == 'false' }}
BUILD_AWS_PREFIX: ${{ vars.AWS_PREFIX }}
BUILD_UMAMI_HOST: ${{ vars.UMAMI_HOST }}
BUILD_UMAMI_WEBSITE_ID: ${{ vars.UMAMI_WEBSITE_ID }}
origin: ${{ vars.API_ORIGIN }}
accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@ build

# React Router
.react-router/

# Local CDN embed test harness
cdn/public/embed-test.html
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"prettier.trailingComma": "none",
"prettier.semi": false
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"prettier.prettierPath": "node_modules/prettier/index.cjs"
}
12 changes: 12 additions & 0 deletions api/build.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { build } from 'esbuild'

try {
process.loadEnvFile('.dev.vars')
} catch {
// do nothing
}

await build({
entryPoints: ['src/index.ts'],
define: {
BUILD_AWS_PREFIX: JSON.stringify(process.env.BUILD_AWS_PREFIX ?? ''),
BUILD_UMAMI_HOST: JSON.stringify(
process.env.BUILD_UMAMI_HOST || process.env.UMAMI_HOST || '',
),
BUILD_UMAMI_WEBSITE_ID: JSON.stringify(
process.env.BUILD_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID || '',
),
},
bundle: true,
format: 'esm',
Expand Down
3 changes: 3 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"build": "node build.ts",
"preview": "wrangler dev",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"deploy": "wrangler deploy"
},
"keywords": [],
Expand Down Expand Up @@ -35,6 +37,7 @@
"@shared/types": "workspace:^",
"esbuild": "0.28.0",
"typescript": "5.9.3",
"vitest": "^4.1.5",
"wrangler": "^4.87.0"
}
}
1 change: 1 addition & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import './routes/get-profile.js'
import './routes/probabilistic-revshare.js'
import './routes/payment/index.js'
import './routes/wallet.js'
import './routes/events.js'

app.get('/', (c) => {
const routes = app.routes
Expand Down
76 changes: 76 additions & 0 deletions api/src/routes/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { app } from '../app.js'
import './events.js'

vi.mock('@shared/defines', () => ({
UMAMI_HOST: 'http://umami.test',
UMAMI_WEBSITE_ID: 'test-website-id',
}))

describe('POST /events', () => {
let fetchMock: ReturnType<typeof vi.fn>

beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
})
Comment thread
kjmitchelljr marked this conversation as resolved.

it('forwards a valid event to Umami and returns 204', async () => {
const res = await app.request('/events', {
method: 'POST',
body: JSON.stringify({
type: 'event',
payload: {
name: 'embed.banner.click_extension_link',
url: '/embed/banner',
data: {
hostname: 'example.com',
link: 'https://example.com/install',
},
},
}),
})

expect(res.status).toBe(204)
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://umami.test/api/send')
const forwarded = JSON.parse(init.body as string)
expect(forwarded.payload).toMatchObject({
website: 'test-website-id',
name: 'embed.banner.click_extension_link',
data: { hostname: 'example.com', link: 'https://example.com/install' },
})
})

it('rejects an event name without the embed. prefix with 400', async () => {
const res = await app.request('/events', {
method: 'POST',
body: JSON.stringify({
type: 'event',
payload: {
name: 'click_extension_link',
url: '/embed/banner',
data: { hostname: 'example.com' },
},
}),
})

expect(res.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})

it('rejects malformed JSON with 400', async () => {
const res = await app.request('/events', {
method: 'POST',
body: 'not json',
})

expect(res.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})
})
74 changes: 74 additions & 0 deletions api/src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import z from 'zod'
import { UMAMI_HOST, UMAMI_WEBSITE_ID } from '@shared/defines'
import { app } from '../app.js'

const payloadSchema = z.object({
/** Prefixed with `embed.` to separate from frontend events */
name: z.string().startsWith('embed.'),
/** Umami "page" path, e.g. `/embed/banner` (groups events per tool) */
url: z.string(),
data: z.looseObject({
/** Publisher's domain from `window.location.hostname` (we can see which sites embed us) */
hostname: z.string(),
}),
})

const eventSchema = z.object({
type: z.literal('event'),
payload: payloadSchema,
})
Comment thread
kjmitchelljr marked this conversation as resolved.

export type EventPayload = z.infer<typeof payloadSchema>
export type EventBody = z.infer<typeof eventSchema>

app.post('/events', async ({ req, body }) => {
if (!UMAMI_HOST || !UMAMI_WEBSITE_ID) {
return body(null, 204)
}

let event: z.infer<typeof eventSchema>
try {
event = z.parse(eventSchema, await req.json())
} catch {
return body(null, 400)
}

// https://docs.umami.is/docs/enable-cloudflare-headers
const passthrough = [
'user-agent',
'accept-language',
'referer',
'cf-connecting-ip',
'cf-ipcountry',
'cf-ipcity',
'cf-region',
'cf-postal-code',
'cf-continent',
'cf-latitude',
'cf-longitude',
'cf-timezone',
]
const headers = new Headers({ 'content-type': 'application/json' })
for (const h of passthrough) {
const v = req.header(h)
if (v) headers.set(h, v)
}

try {
await fetch(`${UMAMI_HOST}/api/send`, {
method: 'POST',
headers,
body: JSON.stringify({
...event,
payload: {
...event.payload,
website: UMAMI_WEBSITE_ID,
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.

Let's also add a tag for events from embeds.

Suggested change
website: UMAMI_WEBSITE_ID,
website: UMAMI_WEBSITE_ID,
tag: 'embed',

},
}),
})
} catch (err) {
console.error('umami forward failed', { name: event.payload.name }, err)
}

return body(null, 204)
})
1 change: 1 addition & 0 deletions api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
PaymentInitiateResult,
PaymentStatus,
} from './routes/payment'
export type { EventPayload, EventBody } from './routes/events'

export type ApiErrorResponse = {
error: {
Expand Down
8 changes: 8 additions & 0 deletions api/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
})
7 changes: 7 additions & 0 deletions cdn/src/banner.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { API_URL } from '@shared/defines'
import type { BannerProfile } from '@shared/types'
import { Banner } from '@tools/components/banner'
import { trackEventFactory } from './lib/analytics'
import { appendPaymentPointer, fetchProfile, getScriptParams } from './utils'

customElements.define('wm-banner', Banner)

const trackEvent = trackEventFactory('banner')
const params = getScriptParams('banner')

appendPaymentPointer(params.walletAddress)
Expand Down Expand Up @@ -39,6 +41,11 @@ function drawBanner(profile: BannerProfile) {
cdnUrl: params.cdnUrl,
}

bannerElement.addEventListener('click-extension-link', (e) => {
const { link } = (e as CustomEvent<{ link: string }>).detail
trackEvent('click_extension_link', { link })
})

const position = profile.position ? profile.position.toLowerCase() : 'bottom'

bannerElement.style.position = 'fixed'
Expand Down
22 changes: 22 additions & 0 deletions cdn/src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { EventBody, EventPayload } from 'publisher-tools-api'
import { API_URL } from '@shared/defines'
import type { Tool } from '@shared/types'

type EventData = Omit<EventPayload['data'], 'hostname'>

export function trackEventFactory(tool: Tool) {
const hostname = window.location.hostname

return (name: string, data?: EventData): void => {
if (!API_URL) return
const body: EventBody = {
type: 'event',
payload: {
name: `embed.${tool}.${name}`,
url: `/embed/${tool}`,
data: { hostname, ...data },
},
Comment on lines +14 to +18
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.

Compared to umami.track(), we're missing the screen size details.
https://docs.umami.is/docs/tracker-functions#pageviews

Perhaps we should add it manually.

}
navigator.sendBeacon?.(`${API_URL}/events`, JSON.stringify(body))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import type {
OfferwallCustomChoice,
StoredEvent,
} from './types'
import { trackEventFactory } from '../../lib/analytics'

const trackEvent = trackEventFactory('offerwall')

export class WebMonetizationCustomOfferwallChoice implements OfferwallCustomChoice {
#browserSupportKey = getBrowserSupportForExtension(
Expand Down Expand Up @@ -98,8 +101,8 @@ export class WebMonetizationCustomOfferwallChoice implements OfferwallCustomChoi

const owElem = document.createElement(elementName) as OfferwallModal
const actions = owElem.setController({
onExtensionLinkClick() {
// can start tracking
onExtensionLinkClick(e) {
trackEvent('click_extension_link', { link: e.detail.link })
},
onModalClose() {
abortController.abort('modal closed by user')
Expand Down
6 changes: 5 additions & 1 deletion components/src/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ export class Banner extends LitElement {
}

private handleLinkClick() {
// TODO: do anything other than open the link in a new tab, like analytics, showing some thank you message etc.
this.dispatchEvent(
new CustomEvent('click-extension-link', {
detail: { link: this.extensionUrl },
}),
)
}

/**
Expand Down
1 change: 1 addition & 0 deletions components/src/offerwall/components/install-required.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class InstallRequired extends LitElement {
#onExtensionLinkClick = (ev: MouseEvent) => {
const event = new CustomEvent('click-extension-link', {
cancelable: true,
detail: { link: this.extensionUrl },
})
this.dispatchEvent(event)
if (event.defaultPrevented) {
Expand Down
4 changes: 3 additions & 1 deletion components/src/offerwall/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export type Screen = 'install-required' | 'contribution-required' | 'all-set'

export type ExtensionLinkClickEvent = CustomEvent<{ link: string }>

export interface Controller {
onModalClose(ev: Event): void
onExtensionLinkClick(ev: Event): void
onExtensionLinkClick(ev: ExtensionLinkClickEvent): void
onDone(ev: Event): void
/**
* In preview mode, instead of rendering the component as "dialog" modal; we
Expand Down
9 changes: 7 additions & 2 deletions components/src/offerwall/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
InstallRequired,
} from './components/index.js'
import { NO_OP_CONTROLLER } from './controller.js'
import type { Actions, Controller, Screen } from './controller.js'
import type {
Actions,
Controller,
ExtensionLinkClickEvent,
Screen,
} from './controller.js'
import styles from './styles.css?raw'
import styleTokens from './vars.css?raw'

Expand Down Expand Up @@ -115,7 +120,7 @@ export class OfferwallModal extends LitElement {
this.#closeDialog()
}

#onExtensionLinkClick = (ev: Event) => {
#onExtensionLinkClick = (ev: ExtensionLinkClickEvent) => {
this.#controller.onExtensionLinkClick(ev)
if (ev.defaultPrevented) return
}
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading