Skip to content

Commit f262bea

Browse files
authored
Average Scroll Depth Metric: Track custom props on pageleaves (#4964)
* improve tracker test util to handle custom props * track pageleave props * make test util more powerful * also test with pageview-props extension * add test for sending props on hash navigation * simplify tracker logic around currentPageLeaveURL * clarify the intent of a timeout by extracting a fn * bugfix: stop sending pageleaves from ignored pages + fix the test to actually fail if this doesn't work. * refactor: drop expectCustomEvent in favour of the new test util * add test for pageleave props ingestion * add query tests * improve test util readability
1 parent 90a2c5d commit f262bea

17 files changed

+577
-242
lines changed

test/plausible_web/controllers/api/external_controller_test.exs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,29 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
649649
assert Map.get(event, :"meta.value") == ["true", "12"]
650650
end
651651

652+
test "records custom props for a pageleave event", %{conn: conn, site: site} do
653+
post(conn, "/api/event", %{
654+
n: "pageview",
655+
u: "https://ab.cd",
656+
d: site.domain
657+
})
658+
659+
post(conn, "/api/event", %{
660+
name: "pageleave",
661+
url: "http://ab.cd/",
662+
domain: site.domain,
663+
props: %{
664+
bool_test: true,
665+
number_test: 12
666+
}
667+
})
668+
669+
pageleave = get_events(site) |> Enum.find(&(&1.name == "pageleave"))
670+
671+
assert Map.get(pageleave, :"meta.key") == ["bool_test", "number_test"]
672+
assert Map.get(pageleave, :"meta.value") == ["true", "12"]
673+
end
674+
652675
test "filters out bad props", %{conn: conn, site: site} do
653676
params = %{
654677
name: "Signup",

test/plausible_web/controllers/api/external_stats_controller/query_test.exs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3759,6 +3759,40 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do
37593759
]
37603760
end
37613761

3762+
test "can query scroll_depth with page + custom prop filter", %{conn: conn, site: site} do
3763+
populate_stats(site, [
3764+
build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00]),
3765+
build(:pageleave, user_id: 123, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 40),
3766+
build(:pageview,
3767+
"meta.key": ["author"],
3768+
"meta.value": ["john"],
3769+
user_id: 123,
3770+
timestamp: ~N[2021-01-01 00:00:10]
3771+
),
3772+
build(:pageleave,
3773+
"meta.key": ["author"],
3774+
"meta.value": ["john"],
3775+
user_id: 123,
3776+
timestamp: ~N[2021-01-01 00:00:20],
3777+
scroll_depth: 60
3778+
),
3779+
build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]),
3780+
build(:pageleave, user_id: 456, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 80)
3781+
])
3782+
3783+
conn =
3784+
post(conn, "/api/v2/query-internal-test", %{
3785+
"site_id" => site.domain,
3786+
"filters" => [["is", "event:page", ["/"]], ["is", "event:props:author", ["john"]]],
3787+
"date_range" => "all",
3788+
"metrics" => ["scroll_depth"]
3789+
})
3790+
3791+
assert json_response(conn, 200)["results"] == [
3792+
%{"metrics" => [60], "dimensions" => []}
3793+
]
3794+
end
3795+
37623796
test "scroll depth is 0 when no pageleave data in range", %{conn: conn, site: site} do
37633797
populate_stats(site, [
37643798
build(:pageview, timestamp: ~N[2021-01-01 00:00:00])
@@ -4048,6 +4082,78 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do
40484082
%{"dimensions" => ["/blog", "2020-01-02"], "metrics" => [20]}
40494083
]
40504084
end
4085+
4086+
test "breakdown by a custom prop with a page filter", %{conn: conn, site: site} do
4087+
populate_stats(site, [
4088+
build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00], pathname: "/blog"),
4089+
build(:pageleave,
4090+
user_id: 123,
4091+
timestamp: ~N[2021-01-01 00:01:00],
4092+
pathname: "/blog",
4093+
scroll_depth: 91
4094+
),
4095+
build(:pageview,
4096+
user_id: 123,
4097+
timestamp: ~N[2021-01-01 00:02:00],
4098+
"meta.key": ["author"],
4099+
"meta.value": ["john"],
4100+
pathname: "/blog/john-post"
4101+
),
4102+
build(:pageleave,
4103+
user_id: 123,
4104+
timestamp: ~N[2021-01-01 00:03:00],
4105+
"meta.key": ["author"],
4106+
"meta.value": ["john"],
4107+
pathname: "/blog/john-post",
4108+
scroll_depth: 40
4109+
),
4110+
build(:pageview,
4111+
user_id: 123,
4112+
timestamp: ~N[2021-01-01 00:02:00],
4113+
"meta.key": ["author"],
4114+
"meta.value": ["john"],
4115+
pathname: "/another-blog/john-post"
4116+
),
4117+
build(:pageleave,
4118+
user_id: 123,
4119+
timestamp: ~N[2021-01-01 00:03:00],
4120+
"meta.key": ["author"],
4121+
"meta.value": ["john"],
4122+
pathname: "/another-blog/john-post",
4123+
scroll_depth: 90
4124+
),
4125+
build(:pageview,
4126+
user_id: 456,
4127+
timestamp: ~N[2021-01-01 00:02:00],
4128+
"meta.key": ["author"],
4129+
"meta.value": ["john"],
4130+
pathname: "/blog/john-post"
4131+
),
4132+
build(:pageleave,
4133+
user_id: 456,
4134+
timestamp: ~N[2021-01-01 00:03:00],
4135+
"meta.key": ["author"],
4136+
"meta.value": ["john"],
4137+
pathname: "/blog/john-post",
4138+
scroll_depth: 46
4139+
)
4140+
])
4141+
4142+
conn =
4143+
post(conn, "/api/v2/query-internal-test", %{
4144+
"site_id" => site.domain,
4145+
"metrics" => ["scroll_depth"],
4146+
"order_by" => [["scroll_depth", "desc"]],
4147+
"date_range" => "all",
4148+
"filters" => [["matches", "event:page", ["/blog.*"]]],
4149+
"dimensions" => ["event:props:author"]
4150+
})
4151+
4152+
assert json_response(conn, 200)["results"] == [
4153+
%{"dimensions" => ["(none)"], "metrics" => [91]},
4154+
%{"dimensions" => ["john"], "metrics" => [43]}
4155+
]
4156+
end
40514157
end
40524158

40534159
test "can filter by utm_medium case insensitively", %{conn: conn, site: site} do

tracker/.eslintrc.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@
1919
{
2020
"assertFunctionNames": [
2121
"expect",
22-
"clickPageElementAndExpectEventRequests",
23-
"expectCustomEvent"
22+
"expectPlausibleInAction"
2423
]
2524
}
2625
]

tracker/src/customEvents.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ function handleTaggedElementClickEvent(event) {
193193
var eventAttrs = getTaggedEventAttributes(taggedElement)
194194

195195
if (clickedLink) {
196+
// if the clicked tagged element is a link, we attach the `url` property
197+
// automatically for user convenience
196198
eventAttrs.props.url = clickedLink.href
197199
sendLinkClickEvent(event, clickedLink, eventAttrs)
198200
} else {

tracker/src/plausible.js

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
var endpoint = scriptEl.getAttribute('data-api') || defaultEndpoint(scriptEl)
1313
var dataDomain = scriptEl.getAttribute('data-domain')
1414

15-
function onIgnoredEvent(reason, options) {
15+
function onIgnoredEvent(eventName, reason, options) {
1616
if (reason) console.warn('Ignoring Event: ' + reason);
1717
options && options.callback && options.callback()
18+
19+
{{#if pageleave}}
20+
if (eventName === 'pageview') {
21+
currentPageLeaveIgnored = true
22+
}
23+
{{/if}}
1824
}
1925

2026
function defaultEndpoint(el) {
@@ -31,10 +37,9 @@
3137
{{#if pageleave}}
3238
// :NOTE: Tracking pageleave events is currently experimental.
3339

34-
// Keeps track of the URL to be sent in the pageleave event payload.
35-
// Should get updated on pageviews triggered manually with a custom
36-
// URL, and on SPA navigation.
40+
var currentPageLeaveIgnored
3741
var currentPageLeaveURL = location.href
42+
var currentPageLeaveProps = {}
3843

3944
// Multiple pageviews might be sent by the same script when the page
4045
// uses client-side routing (e.g. hash or history-based). This flag
@@ -92,7 +97,7 @@
9297
})
9398

9499
function triggerPageLeave() {
95-
if (pageLeaveSending) {return}
100+
if (pageLeaveSending || currentPageLeaveIgnored) {return}
96101
pageLeaveSending = true
97102
setTimeout(function () {pageLeaveSending = false}, 500)
98103

@@ -101,6 +106,7 @@
101106
sd: Math.round((maxScrollDepthPx / currentDocumentHeight) * 100),
102107
d: dataDomain,
103108
u: currentPageLeaveURL,
109+
p: currentPageLeaveProps
104110
}
105111

106112
{{#if hash}}
@@ -126,15 +132,15 @@
126132

127133
{{#unless local}}
128134
if (/^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) || location.protocol === 'file:') {
129-
return onIgnoredEvent('localhost', options)
135+
return onIgnoredEvent(eventName, 'localhost', options)
130136
}
131137
if ((window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) && !window.__plausible) {
132-
return onIgnoredEvent(null, options)
138+
return onIgnoredEvent(eventName, null, options)
133139
}
134140
{{/unless}}
135141
try {
136142
if (window.localStorage.plausible_ignore === 'true') {
137-
return onIgnoredEvent('localStorage flag', options)
143+
return onIgnoredEvent(eventName, 'localStorage flag', options)
138144
}
139145
} catch (e) {
140146

@@ -147,7 +153,7 @@
147153
var isIncluded = !dataIncludeAttr || (dataIncludeAttr && dataIncludeAttr.split(',').some(pathMatches))
148154
var isExcluded = dataExcludeAttr && dataExcludeAttr.split(',').some(pathMatches)
149155

150-
if (!isIncluded || isExcluded) return onIgnoredEvent('exclusion rule', options)
156+
if (!isIncluded || isExcluded) return onIgnoredEvent(eventName, 'exclusion rule', options)
151157
}
152158

153159
function pathMatches(wildcardPath) {
@@ -167,10 +173,6 @@
167173
{{#if manual}}
168174
var customURL = options && options.u
169175

170-
{{#if pageleave}}
171-
isPageview && customURL && (currentPageLeaveURL = customURL)
172-
{{/if}}
173-
174176
payload.u = customURL ? customURL : location.href
175177
{{else}}
176178
payload.u = location.href
@@ -220,6 +222,9 @@
220222
if (request.readyState === 4) {
221223
{{#if pageleave}}
222224
if (isPageview) {
225+
currentPageLeaveIgnored = false
226+
currentPageLeaveURL = payload.u
227+
currentPageLeaveProps = payload.p
223228
registerPageLeaveListener()
224229
}
225230
{{/if}}
@@ -245,7 +250,6 @@
245250
{{#if pageleave}}
246251
if (isSPANavigation && listeningPageLeave) {
247252
triggerPageLeave();
248-
currentPageLeaveURL = location.href;
249253
currentDocumentHeight = getDocumentHeight()
250254
maxScrollDepthPx = getCurrentScrollDepthPx()
251255
}
Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,36 @@
1-
const { mockRequest, mockManyRequests, expectCustomEvent } = require('./support/test-utils')
2-
const { expect, test } = require('@playwright/test')
1+
const { expectPlausibleInAction } = require('./support/test-utils')
2+
const { test } = require('@playwright/test')
33
const { LOCAL_SERVER_ADDR } = require('./support/server')
44

55
test.describe('script.file-downloads.outbound-links.tagged-events.js', () => {
66
test('sends only outbound link event when clicked link is both download and outbound', async ({ page }) => {
77
await page.goto('/custom-event-edge-case.html')
88
const downloadURL = await page.locator('#outbound-download-link').getAttribute('href')
99

10-
const plausibleRequestMockList = mockManyRequests(page, '/api/event', 2)
11-
await page.click('#outbound-download-link')
12-
13-
const requests = await plausibleRequestMockList
14-
expect(requests.length).toBe(1)
15-
expectCustomEvent(requests[0], 'Outbound Link: Click', {url: downloadURL})
10+
await expectPlausibleInAction(page, {
11+
action: () => page.click('#outbound-download-link'),
12+
expectedRequests: [{n: 'Outbound Link: Click', p: {url: downloadURL}}]
13+
})
1614
})
1715

1816
test('sends file download event when local download link clicked', async ({ page }) => {
1917
await page.goto('/custom-event-edge-case.html')
2018
const downloadURL = LOCAL_SERVER_ADDR + '/' + await page.locator('#local-download').getAttribute('href')
2119

22-
const plausibleRequestMock = mockRequest(page, '/api/event')
23-
await page.click('#local-download')
24-
25-
expectCustomEvent(await plausibleRequestMock, 'File Download', {url: downloadURL})
20+
await expectPlausibleInAction(page, {
21+
action: () => page.click('#local-download'),
22+
expectedRequests: [{n: 'File Download', p: {url: downloadURL}}]
23+
})
2624
})
2725

2826
test('sends only tagged event when clicked link is tagged + outbound + download', async ({ page }) => {
2927
await page.goto('/custom-event-edge-case.html')
3028

31-
const plausibleRequestMockList = mockManyRequests(page, '/api/event', 3)
32-
await page.click('#tagged-outbound-download-link')
33-
34-
const requests = await plausibleRequestMockList
35-
expect(requests.length).toBe(1)
36-
expectCustomEvent(requests[0], 'Foo', {})
29+
await expectPlausibleInAction(page, {
30+
action: () => page.click('#tagged-outbound-download-link'),
31+
expectedRequests: [{n: 'Foo', p: {url: 'https://awesome.website.com/file.pdf'}}],
32+
awaitedRequestCount: 3,
33+
expectedRequestCount: 1
34+
})
3735
})
3836
})

tracker/test/file-downloads.spec.js

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { mockRequest, expectCustomEvent, mockManyRequests, metaKey } = require('./support/test-utils')
1+
const { mockRequest, mockManyRequests, metaKey, expectPlausibleInAction } = require('./support/test-utils')
22
const { expect, test } = require('@playwright/test')
33
const { LOCAL_SERVER_ADDR } = require('./support/server')
44

@@ -7,35 +7,38 @@ test.describe('file-downloads extension', () => {
77
await page.goto('/file-download.html')
88
const downloadURL = await page.locator('#link').getAttribute('href')
99

10-
const plausibleRequestMock = mockRequest(page, '/api/event')
1110
const downloadRequestMock = mockRequest(page, downloadURL)
12-
await page.click('#link', { modifiers: [metaKey()] })
1311

14-
expectCustomEvent(await plausibleRequestMock, 'File Download', { url: downloadURL })
12+
await expectPlausibleInAction(page, {
13+
action: () => page.click('#link', { modifiers: [metaKey()] }),
14+
expectedRequests: [{n: 'File Download', p: { url: downloadURL }}]
15+
})
16+
1517
expect(await downloadRequestMock, "should not make download request").toBeNull()
1618
})
1719

1820
test('sends event and starts download when link child is clicked', async ({ page }) => {
1921
await page.goto('/file-download.html')
2022
const downloadURL = await page.locator('#link').getAttribute('href')
2123

22-
const plausibleRequestMock = mockRequest(page, '/api/event')
2324
const downloadRequestMock = mockRequest(page, downloadURL)
24-
await page.click('#link-child')
2525

26-
expectCustomEvent(await plausibleRequestMock, 'File Download', { url: downloadURL })
26+
await expectPlausibleInAction(page, {
27+
action: () => page.click('#link-child'),
28+
expectedRequests: [{n: 'File Download', p: { url: downloadURL }}]
29+
})
30+
2731
expect((await downloadRequestMock).url()).toContain(downloadURL)
2832
})
2933

3034
test('sends File Download event with query-stripped url property', async ({ page }) => {
3135
await page.goto('/file-download.html')
3236
const downloadURL = await page.locator('#link-query').getAttribute('href')
3337

34-
const plausibleRequestMock = mockRequest(page, '/api/event')
35-
await page.click('#link-query')
36-
37-
const expectedURL = downloadURL.split("?")[0]
38-
expectCustomEvent(await plausibleRequestMock, 'File Download', { url: expectedURL })
38+
await expectPlausibleInAction(page, {
39+
action: () => page.click('#link-query'),
40+
expectedRequests: [{n: 'File Download', p: { url: downloadURL.split("?")[0] }}]
41+
})
3942
})
4043

4144
test('starts download only once', async ({ page }) => {

0 commit comments

Comments
 (0)