@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 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
- void flush(true);
37
+ background(true);
19
38
  };
20
39
  browserDocument?.addEventListener('visibilitychange', onVisibilityChange);
21
- const timer = globalThis.setInterval(() => void flush(), options.flushInterval ?? 5000);
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 (options.consent && !options.consent())
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
- void flush();
79
+ background();
56
80
  }
57
81
  function page(properties) {
58
82
  track('page.view', properties);
59
83
  }
60
- async function flush(useBeacon = false) {
61
- if (flushing)
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 (useBeacon && browserNavigator?.sendBeacon?.(options.endpoint, new Blob([body], { type: 'application/json' }))) {
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
- flushing = fetcher(options.endpoint, {
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 = undefined;
143
+ if (flushing === request)
144
+ flushing = undefined;
90
145
  });
91
- return flushing;
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 {};
@@ -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
- endpoint: string;
25
- siteKey: string;
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.1.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": {