@whilesmart/engagement 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/client.js +77 -12
- package/dist/index.d.ts +1 -1
- package/dist/posthog.d.ts +12 -0
- package/dist/posthog.js +39 -0
- package/dist/types.d.ts +28 -2
- package/package.json +14 -1
package/README.md
CHANGED
|
@@ -24,6 +24,43 @@ engagement.track('signup.started', { plan: 'business' })
|
|
|
24
24
|
|
|
25
25
|
The client records a random visitor ID in local storage and a random session ID in session storage. It derives source attribution from `utm_source`, then the referrer host, then `direct`. Events are batched, retried after failed requests, and flushed with `sendBeacon` when the page becomes hidden.
|
|
26
26
|
|
|
27
|
+
Set `autoLinkClicks: true` to record every link click as `link.clicked`, with
|
|
28
|
+
the link's text, where it goes, and whether it leaves the site. Use
|
|
29
|
+
`{ text: false }` where link text can be private, such as inside an app.
|
|
30
|
+
|
|
31
|
+
## Other analytics services
|
|
32
|
+
|
|
33
|
+
A destination receives everything the client records: page views, events,
|
|
34
|
+
`identify` on sign-in and `reset` on sign-out. The client still sends its own
|
|
35
|
+
events to the endpoint, so the admin console keeps its figures whichever
|
|
36
|
+
services are added, and one consent check covers them all.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createEngagementClient } from '@whilesmart/engagement'
|
|
40
|
+
import { posthogDestination } from '@whilesmart/engagement/posthog'
|
|
41
|
+
|
|
42
|
+
const engagement = createEngagementClient({
|
|
43
|
+
endpoint,
|
|
44
|
+
siteKey,
|
|
45
|
+
autoLinkClicks: true,
|
|
46
|
+
destinations: [
|
|
47
|
+
posthogDestination({
|
|
48
|
+
token: 'your-project-token-here',
|
|
49
|
+
host: 'https://us.i.posthog.com',
|
|
50
|
+
config: { session_recording: { maskAllInputs: true } },
|
|
51
|
+
}),
|
|
52
|
+
],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
engagement.identify({ id: '42', properties: { email: 'ada@example.com' } })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`posthog-js` is an optional peer dependency, downloaded only once the client
|
|
59
|
+
first has something to send. Any service can be added by passing an object with
|
|
60
|
+
a `name` and whichever of `start`, `page`, `track`, `identify` and `reset` it
|
|
61
|
+
needs. One that throws is reported to `onDestinationError` and never stops the
|
|
62
|
+
others.
|
|
63
|
+
|
|
27
64
|
The site key identifies a website. It is not a secret. The server must also restrict accepted browser origins.
|
|
28
65
|
|
|
29
66
|
## License
|
package/dist/client.js
CHANGED
|
@@ -12,15 +12,38 @@ export function createEngagementClient(options) {
|
|
|
12
12
|
const visitorId = persistentId(localStorage, visitorStorageKey, browserCrypto);
|
|
13
13
|
const sessionId = persistentId(sessionStorage, sessionStorageKey, browserCrypto);
|
|
14
14
|
const queue = [];
|
|
15
|
+
const destinations = options.destinations ?? [];
|
|
16
|
+
let started = false;
|
|
15
17
|
let flushing;
|
|
18
|
+
function deliver(call) {
|
|
19
|
+
if (!started) {
|
|
20
|
+
started = true;
|
|
21
|
+
deliver((destination) => destination.start?.({ visitorId, sessionId }));
|
|
22
|
+
}
|
|
23
|
+
for (const destination of destinations) {
|
|
24
|
+
try {
|
|
25
|
+
call(destination);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
options.onDestinationError?.(destination.name, error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function consented() {
|
|
33
|
+
return !options.consent || options.consent();
|
|
34
|
+
}
|
|
16
35
|
const onVisibilityChange = () => {
|
|
17
36
|
if (browserDocument?.visibilityState === 'hidden')
|
|
18
|
-
|
|
37
|
+
background(true);
|
|
19
38
|
};
|
|
20
39
|
browserDocument?.addEventListener('visibilitychange', onVisibilityChange);
|
|
21
|
-
|
|
40
|
+
// A failed batch is already back in the queue, so there is nothing to report.
|
|
41
|
+
function background(urgent = false) {
|
|
42
|
+
flush(urgent).catch(() => { });
|
|
43
|
+
}
|
|
44
|
+
const timer = globalThis.setInterval(() => background(), options.flushInterval ?? 5000);
|
|
22
45
|
function track(name, properties, eventOptions = {}) {
|
|
23
|
-
if (
|
|
46
|
+
if (!consented())
|
|
24
47
|
return;
|
|
25
48
|
const event = {
|
|
26
49
|
name,
|
|
@@ -51,31 +74,62 @@ export function createEngagementClient(options) {
|
|
|
51
74
|
if (properties && Object.keys(properties).length > 0)
|
|
52
75
|
event.properties = properties;
|
|
53
76
|
queue.push(event);
|
|
77
|
+
deliver((destination) => (name === 'page.view' ? destination.page?.(event) : destination.track?.(event)));
|
|
54
78
|
if (queue.length >= batchSize)
|
|
55
|
-
|
|
79
|
+
background();
|
|
56
80
|
}
|
|
57
81
|
function page(properties) {
|
|
58
82
|
track('page.view', properties);
|
|
59
83
|
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
84
|
+
function identify(person) {
|
|
85
|
+
if (!consented())
|
|
86
|
+
return;
|
|
87
|
+
deliver((destination) => destination.identify?.(person));
|
|
88
|
+
}
|
|
89
|
+
function reset() {
|
|
90
|
+
deliver((destination) => destination.reset?.());
|
|
91
|
+
}
|
|
92
|
+
const withText = typeof options.autoLinkClicks !== 'object' || options.autoLinkClicks.text;
|
|
93
|
+
const onClick = (event) => {
|
|
94
|
+
const link = event.target instanceof Element ? event.target.closest('a') : null;
|
|
95
|
+
if (!link?.href)
|
|
96
|
+
return;
|
|
97
|
+
const properties = {
|
|
98
|
+
destination: link.origin + link.pathname,
|
|
99
|
+
outbound: link.origin !== originOf(browserLocation?.href),
|
|
100
|
+
};
|
|
101
|
+
if (withText)
|
|
102
|
+
properties.label = (link.textContent ?? '').trim().slice(0, 200);
|
|
103
|
+
track('link.clicked', properties);
|
|
104
|
+
background(true);
|
|
105
|
+
};
|
|
106
|
+
if (options.autoLinkClicks)
|
|
107
|
+
browserDocument?.addEventListener('click', onClick);
|
|
108
|
+
/** An urgent flush does not wait for a request in flight: the page may be closing. */
|
|
109
|
+
async function flush(urgent = false) {
|
|
110
|
+
if (!options.endpoint) {
|
|
111
|
+
queue.length = 0;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (flushing && !urgent)
|
|
62
115
|
return flushing;
|
|
63
116
|
if (queue.length === 0)
|
|
64
117
|
return;
|
|
118
|
+
const endpoint = options.endpoint;
|
|
65
119
|
const events = queue.splice(0, batchSize);
|
|
66
120
|
const body = JSON.stringify({ site_key: options.siteKey, events });
|
|
67
|
-
if (
|
|
121
|
+
if (urgent && browserNavigator?.sendBeacon?.(endpoint, new Blob([body], { type: 'application/json' }))) {
|
|
68
122
|
return;
|
|
69
123
|
}
|
|
70
124
|
if (!fetcher) {
|
|
71
125
|
queue.unshift(...events);
|
|
72
126
|
throw new Error('No fetch implementation is available');
|
|
73
127
|
}
|
|
74
|
-
|
|
128
|
+
const request = fetcher(endpoint, {
|
|
75
129
|
method: 'POST',
|
|
76
130
|
headers: {
|
|
77
131
|
'Content-Type': 'application/json',
|
|
78
|
-
'X-Engagement-Site-Key': options.siteKey,
|
|
132
|
+
'X-Engagement-Site-Key': options.siteKey ?? '',
|
|
79
133
|
},
|
|
80
134
|
body,
|
|
81
135
|
keepalive: true,
|
|
@@ -86,18 +140,29 @@ export function createEngagementClient(options) {
|
|
|
86
140
|
queue.unshift(...events);
|
|
87
141
|
throw error;
|
|
88
142
|
}).finally(() => {
|
|
89
|
-
flushing
|
|
143
|
+
if (flushing === request)
|
|
144
|
+
flushing = undefined;
|
|
90
145
|
});
|
|
91
|
-
|
|
146
|
+
flushing = request;
|
|
147
|
+
return request;
|
|
92
148
|
}
|
|
93
149
|
async function destroy() {
|
|
94
150
|
globalThis.clearInterval(timer);
|
|
95
151
|
browserDocument?.removeEventListener('visibilitychange', onVisibilityChange);
|
|
152
|
+
browserDocument?.removeEventListener('click', onClick);
|
|
96
153
|
await flush();
|
|
97
154
|
}
|
|
98
155
|
if (options.autoPageViews !== false)
|
|
99
156
|
page();
|
|
100
|
-
return { page, track, flush, destroy };
|
|
157
|
+
return { page, track, identify, reset, flush, destroy };
|
|
158
|
+
}
|
|
159
|
+
function originOf(href) {
|
|
160
|
+
try {
|
|
161
|
+
return href ? new URL(href).origin : undefined;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
101
166
|
}
|
|
102
167
|
function persistentId(storage, key, crypto) {
|
|
103
168
|
const existing = storage?.getItem(key);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createEngagementClient } from './client.js';
|
|
2
|
-
export type { EngagementClient, EngagementClientOptions, EngagementEvent, EngagementProperties, EngagementValue, TrackOptions, } from './types.js';
|
|
2
|
+
export type { DestinationContext, EngagementClient, EngagementDestination, EngagementPerson, EngagementClientOptions, EngagementEvent, EngagementProperties, EngagementValue, TrackOptions, } from './types.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PostHog, PostHogConfig } from 'posthog-js';
|
|
2
|
+
import type { EngagementDestination } from './types.js';
|
|
3
|
+
type PostHogClient = Pick<PostHog, 'init' | 'capture' | 'identify' | 'reset'>;
|
|
4
|
+
export interface PostHogDestinationOptions {
|
|
5
|
+
token: string;
|
|
6
|
+
host: string;
|
|
7
|
+
config?: Partial<PostHogConfig>;
|
|
8
|
+
load?: () => Promise<PostHogClient>;
|
|
9
|
+
}
|
|
10
|
+
/** PostHog's own page views are off: the engagement client sends them. */
|
|
11
|
+
export declare function posthogDestination(options: PostHogDestinationOptions): EngagementDestination;
|
|
12
|
+
export {};
|
package/dist/posthog.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** PostHog's own page views are off: the engagement client sends them. */
|
|
2
|
+
export function posthogDestination(options) {
|
|
3
|
+
let posthog;
|
|
4
|
+
const waiting = [];
|
|
5
|
+
function send(call) {
|
|
6
|
+
if (posthog)
|
|
7
|
+
call(posthog);
|
|
8
|
+
else
|
|
9
|
+
waiting.push(call);
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
name: 'posthog',
|
|
13
|
+
start() {
|
|
14
|
+
const load = options.load ?? (async () => (await import('posthog-js')).posthog);
|
|
15
|
+
void load()
|
|
16
|
+
.then((client) => {
|
|
17
|
+
client.init(options.token, { api_host: options.host, capture_pageview: false, ...options.config });
|
|
18
|
+
posthog = client;
|
|
19
|
+
for (const call of waiting.splice(0))
|
|
20
|
+
call(client);
|
|
21
|
+
})
|
|
22
|
+
.catch(() => {
|
|
23
|
+
waiting.length = 0;
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
page(event) {
|
|
27
|
+
send((client) => client.capture('$pageview', event.properties));
|
|
28
|
+
},
|
|
29
|
+
track(event) {
|
|
30
|
+
send((client) => client.capture(event.name, event.properties));
|
|
31
|
+
},
|
|
32
|
+
identify(person) {
|
|
33
|
+
send((client) => client.identify(person.id, person.properties));
|
|
34
|
+
},
|
|
35
|
+
reset() {
|
|
36
|
+
send((client) => client.reset());
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -20,11 +20,34 @@ export interface TrackOptions {
|
|
|
20
20
|
referrer?: string;
|
|
21
21
|
source?: string;
|
|
22
22
|
}
|
|
23
|
+
export interface EngagementPerson {
|
|
24
|
+
id: string;
|
|
25
|
+
properties?: EngagementProperties;
|
|
26
|
+
}
|
|
27
|
+
export interface DestinationContext {
|
|
28
|
+
visitorId: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
}
|
|
31
|
+
/** A destination that throws never stops the others. */
|
|
32
|
+
export interface EngagementDestination {
|
|
33
|
+
name: string;
|
|
34
|
+
start?(context: DestinationContext): void;
|
|
35
|
+
page?(event: EngagementEvent): void;
|
|
36
|
+
track?(event: EngagementEvent): void;
|
|
37
|
+
identify?(person: EngagementPerson): void;
|
|
38
|
+
reset?(): void;
|
|
39
|
+
}
|
|
23
40
|
export interface EngagementClientOptions {
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
/** Left out, events reach only the destinations. */
|
|
42
|
+
endpoint?: string;
|
|
43
|
+
siteKey?: string;
|
|
26
44
|
consent?: () => boolean;
|
|
45
|
+
destinations?: EngagementDestination[];
|
|
27
46
|
autoPageViews?: boolean;
|
|
47
|
+
/** `{ text: false }` records where a link goes but not what it says. */
|
|
48
|
+
autoLinkClicks?: boolean | {
|
|
49
|
+
text: boolean;
|
|
50
|
+
};
|
|
28
51
|
batchSize?: number;
|
|
29
52
|
flushInterval?: number;
|
|
30
53
|
fetch?: typeof globalThis.fetch;
|
|
@@ -32,12 +55,15 @@ export interface EngagementClientOptions {
|
|
|
32
55
|
sessionStorage?: Pick<Storage, 'getItem' | 'setItem'>;
|
|
33
56
|
location?: Pick<Location, 'href' | 'search'>;
|
|
34
57
|
document?: Pick<Document, 'referrer' | 'visibilityState' | 'addEventListener' | 'removeEventListener'>;
|
|
58
|
+
onDestinationError?: (destination: string, error: unknown) => void;
|
|
35
59
|
navigator?: Pick<Navigator, 'sendBeacon'>;
|
|
36
60
|
crypto?: Pick<Crypto, 'randomUUID' | 'getRandomValues'>;
|
|
37
61
|
}
|
|
38
62
|
export interface EngagementClient {
|
|
39
63
|
page(properties?: EngagementProperties): void;
|
|
40
64
|
track(name: string, properties?: EngagementProperties, options?: TrackOptions): void;
|
|
65
|
+
identify(person: EngagementPerson): void;
|
|
66
|
+
reset(): void;
|
|
41
67
|
flush(useBeacon?: boolean): Promise<void>;
|
|
42
68
|
destroy(): Promise<void>;
|
|
43
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whilesmart/engagement",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Framework-independent browser engagement tracking for WhileSmart applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,18 @@
|
|
|
12
12
|
".": {
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
14
|
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./posthog": {
|
|
17
|
+
"types": "./dist/posthog.d.ts",
|
|
18
|
+
"import": "./dist/posthog.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"posthog-js": "^1.438.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"posthog-js": {
|
|
26
|
+
"optional": true
|
|
15
27
|
}
|
|
16
28
|
},
|
|
17
29
|
"scripts": {
|
|
@@ -19,6 +31,7 @@
|
|
|
19
31
|
"test": "npm run build && node --test tests/*.test.js"
|
|
20
32
|
},
|
|
21
33
|
"devDependencies": {
|
|
34
|
+
"posthog-js": "^1.438.13",
|
|
22
35
|
"typescript": "^5.7.0"
|
|
23
36
|
},
|
|
24
37
|
"repository": {
|