-
Notifications
You must be signed in to change notification settings - Fork 12
feat(analytics): add click extension link event tracking for banner and offerwall #683
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6579ea8
118e943
3dd8b6a
95649ef
f5b43de
613f985
5b8bc1c
51274ac
db4fd66
100df55
28ae2bc
4613aa0
598f8e9
5dcc993
5890bda
9621e4a
8e74ad4
6a750ba
6827127
81753d0
5f1532b
6cbdcec
c473b26
1d9ee2d
e0c0f6a
132294d
6858838
9db44cd
8e215c8
918c997
f671e58
a1b024a
903bfe1
1d22a5a
19ba347
2ce05a5
b8b5162
0aad2d9
9fc349e
87b0a5c
a6063d1
2876510
2354170
af0ff16
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,3 +52,6 @@ build | |
|
|
||
| # React Router | ||
| .react-router/ | ||
|
|
||
| # Local CDN embed test harness | ||
| cdn/public/embed-test.html | ||
| 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" | ||
| } |
| 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() | ||
| }) | ||
|
|
||
| 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() | ||
| }) | ||
| }) | ||
| 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, | ||||||||
| }) | ||||||||
|
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, | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's also add a tag for events from embeds.
Suggested change
|
||||||||
| }, | ||||||||
| }), | ||||||||
| }) | ||||||||
| } catch (err) { | ||||||||
| console.error('umami forward failed', { name: event.payload.name }, err) | ||||||||
| } | ||||||||
|
|
||||||||
| return body(null, 204) | ||||||||
| }) | ||||||||
| 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'], | ||
| }, | ||
| }) |
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Compared to Perhaps we should add it manually. |
||
| } | ||
| navigator.sendBeacon?.(`${API_URL}/events`, JSON.stringify(body)) | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.