@baukit/analytics-posthog-native 0.7.1 → 0.7.3
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/package.json +3 -2
- package/src/index.test.ts +285 -0
- package/src/index.ts +303 -0
- package/src/storage.test.ts +114 -0
- package/src/storage.ts +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baukit/analytics-posthog-native",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "PostHog React Native transport for @baukit/analytics-core.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Patrick Koss",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"dist",
|
|
38
|
+
"src",
|
|
38
39
|
"README.md",
|
|
39
40
|
"LICENSE"
|
|
40
41
|
],
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"typescript": "6.0.3"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
|
-
"@baukit/analytics-core": "^0.7.
|
|
55
|
+
"@baukit/analytics-core": "^0.7.3",
|
|
55
56
|
"posthog-react-native": "^4.78.3"
|
|
56
57
|
},
|
|
57
58
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { AnalyticsClient } from '@baukit/analytics-core';
|
|
2
|
+
import type { AnalyticsContext, AnalyticsEnvelope, EventAllowlist } from '@baukit/analytics-core';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createPostHogNativeTransport,
|
|
7
|
+
PostHogNativeTransport,
|
|
8
|
+
type PostHogNativeClient,
|
|
9
|
+
} from './index.js';
|
|
10
|
+
|
|
11
|
+
interface ProductEvent {
|
|
12
|
+
name: 'onboarding_started';
|
|
13
|
+
properties: { source: 'organic' | 'invite'; contact?: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ANONYMOUS_ID = '00000000-0000-4000-8000-000000000001';
|
|
17
|
+
const USER_ID = '10000000-0000-4000-8000-000000000001';
|
|
18
|
+
const CAPTURED_AT = '2026-08-08T10:00:00.000Z';
|
|
19
|
+
|
|
20
|
+
const context: AnalyticsContext = {
|
|
21
|
+
schema_version: 1,
|
|
22
|
+
app: 'example-native',
|
|
23
|
+
app_version: '1.0.0',
|
|
24
|
+
platform: 'native',
|
|
25
|
+
environment: 'test',
|
|
26
|
+
locale: 'en-GB',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface MockClient {
|
|
30
|
+
readonly client: PostHogNativeClient;
|
|
31
|
+
readonly capture: ReturnType<typeof vi.fn>;
|
|
32
|
+
readonly identify: ReturnType<typeof vi.fn>;
|
|
33
|
+
readonly alias: ReturnType<typeof vi.fn>;
|
|
34
|
+
readonly reset: ReturnType<typeof vi.fn>;
|
|
35
|
+
readonly optIn: ReturnType<typeof vi.fn>;
|
|
36
|
+
readonly optOut: ReturnType<typeof vi.fn>;
|
|
37
|
+
readonly setPersistedProperty: ReturnType<typeof vi.fn>;
|
|
38
|
+
readonly queue: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createMockClient(): MockClient {
|
|
42
|
+
const capture = vi.fn();
|
|
43
|
+
const identify = vi.fn();
|
|
44
|
+
const alias = vi.fn();
|
|
45
|
+
const reset = vi.fn();
|
|
46
|
+
const optIn = vi.fn();
|
|
47
|
+
const optOut = vi.fn();
|
|
48
|
+
const setPersistedProperty = vi.fn();
|
|
49
|
+
const queue: string[] = [];
|
|
50
|
+
let optedOut = true;
|
|
51
|
+
const client: PostHogNativeClient = {
|
|
52
|
+
capture(event, properties, options) {
|
|
53
|
+
capture(event, properties, options);
|
|
54
|
+
if (!optedOut) {
|
|
55
|
+
queue.push(event);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
identify,
|
|
59
|
+
alias,
|
|
60
|
+
reset,
|
|
61
|
+
optIn() {
|
|
62
|
+
optIn();
|
|
63
|
+
optedOut = false;
|
|
64
|
+
},
|
|
65
|
+
optOut() {
|
|
66
|
+
optOut();
|
|
67
|
+
optedOut = true;
|
|
68
|
+
},
|
|
69
|
+
setPersistedProperty(key, value) {
|
|
70
|
+
setPersistedProperty(key, value);
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
queue.length = 0;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
client,
|
|
78
|
+
capture,
|
|
79
|
+
identify,
|
|
80
|
+
alias,
|
|
81
|
+
reset,
|
|
82
|
+
optIn,
|
|
83
|
+
optOut,
|
|
84
|
+
setPersistedProperty,
|
|
85
|
+
queue,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function lifecycleEnvelopes(): readonly AnalyticsEnvelope<ProductEvent>[] {
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
type: 'capture',
|
|
93
|
+
captured_at: CAPTURED_AT,
|
|
94
|
+
anonymous_id: ANONYMOUS_ID,
|
|
95
|
+
event: {
|
|
96
|
+
...context,
|
|
97
|
+
name: 'onboarding_started',
|
|
98
|
+
properties: { source: 'organic', contact: '[redacted]' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'identify',
|
|
103
|
+
captured_at: CAPTURED_AT,
|
|
104
|
+
anonymous_id: ANONYMOUS_ID,
|
|
105
|
+
user_id: USER_ID,
|
|
106
|
+
traits: { plan: 'free', email: '[redacted]' },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: 'alias',
|
|
110
|
+
captured_at: CAPTURED_AT,
|
|
111
|
+
anonymous_id: ANONYMOUS_ID,
|
|
112
|
+
user_id: USER_ID,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'reset',
|
|
116
|
+
captured_at: CAPTURED_AT,
|
|
117
|
+
previous_anonymous_id: ANONYMOUS_ID,
|
|
118
|
+
previous_user_id: USER_ID,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe('PostHogNativeTransport mapping', () => {
|
|
124
|
+
it('maps capture, identify, alias, and reset without adding properties', async () => {
|
|
125
|
+
const posthog = createMockClient();
|
|
126
|
+
const transport = new PostHogNativeTransport<ProductEvent>(posthog.client);
|
|
127
|
+
|
|
128
|
+
await transport.send(lifecycleEnvelopes());
|
|
129
|
+
|
|
130
|
+
expect(posthog.capture).toHaveBeenCalledWith(
|
|
131
|
+
'onboarding_started',
|
|
132
|
+
{
|
|
133
|
+
source: 'organic',
|
|
134
|
+
contact: '[redacted]',
|
|
135
|
+
...context,
|
|
136
|
+
},
|
|
137
|
+
{ timestamp: new Date(CAPTURED_AT) },
|
|
138
|
+
);
|
|
139
|
+
expect(posthog.identify).toHaveBeenCalledWith(USER_ID, {
|
|
140
|
+
plan: 'free',
|
|
141
|
+
email: '[redacted]',
|
|
142
|
+
});
|
|
143
|
+
expect(posthog.alias).toHaveBeenCalledWith(USER_ID);
|
|
144
|
+
expect(posthog.reset).toHaveBeenCalledWith();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('contains client and diagnostic failures', async () => {
|
|
148
|
+
const posthog = createMockClient();
|
|
149
|
+
posthog.capture.mockImplementation(() => {
|
|
150
|
+
throw new Error('provider failed');
|
|
151
|
+
});
|
|
152
|
+
const transport = new PostHogNativeTransport<ProductEvent>(posthog.client, () => {
|
|
153
|
+
throw new Error('diagnostic failed');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await expect(transport.send(lifecycleEnvelopes())).resolves.toBeUndefined();
|
|
157
|
+
expect(posthog.identify).toHaveBeenCalledOnce();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('createPostHogNativeTransport', () => {
|
|
162
|
+
it('requires a self-hosted API host and API key', () => {
|
|
163
|
+
expect(() => createPostHogNativeTransport({ apiKey: 'phc_key', apiHost: '' })).toThrow(
|
|
164
|
+
'apiHost must not be empty',
|
|
165
|
+
);
|
|
166
|
+
expect(() =>
|
|
167
|
+
createPostHogNativeTransport({ apiKey: 'phc_key', apiHost: 'posthog.internal' }),
|
|
168
|
+
).toThrow('apiHost must be an absolute http(s) URL');
|
|
169
|
+
expect(() =>
|
|
170
|
+
createPostHogNativeTransport({ apiKey: '', apiHost: 'https://posthog.example.test' }),
|
|
171
|
+
).toThrow('apiKey must not be empty');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('initializes lazily with typed-only privacy defaults and the core anonymous ID', async () => {
|
|
175
|
+
const posthog = createMockClient();
|
|
176
|
+
const initializer = vi.fn(() => posthog.client);
|
|
177
|
+
const transport = createPostHogNativeTransport<ProductEvent>({
|
|
178
|
+
apiKey: ' phc_key ',
|
|
179
|
+
apiHost: 'https://posthog.example.test/',
|
|
180
|
+
initializer,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(initializer).not.toHaveBeenCalled();
|
|
184
|
+
await transport.send(lifecycleEnvelopes().slice(0, 1));
|
|
185
|
+
|
|
186
|
+
expect(initializer).toHaveBeenCalledWith(
|
|
187
|
+
'phc_key',
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
host: 'https://posthog.example.test',
|
|
190
|
+
bootstrap: { distinctId: ANONYMOUS_ID, isIdentifiedId: false },
|
|
191
|
+
captureAppLifecycleEvents: false,
|
|
192
|
+
capturePushNotificationOpened: false,
|
|
193
|
+
capturePushNotificationSubscriptions: false,
|
|
194
|
+
disableSurveys: true,
|
|
195
|
+
enableSessionReplay: false,
|
|
196
|
+
errorTracking: { autocapture: false },
|
|
197
|
+
defaultOptIn: false,
|
|
198
|
+
personProfiles: 'identified_only',
|
|
199
|
+
preloadFeatureFlags: false,
|
|
200
|
+
setDefaultPersonProperties: false,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('native adapter composed with analytics core', () => {
|
|
207
|
+
it('opts out and clears the persisted provider queue when consent is withdrawn', async () => {
|
|
208
|
+
const posthog = createMockClient();
|
|
209
|
+
const transport = new PostHogNativeTransport<ProductEvent>(posthog.client);
|
|
210
|
+
const clearPending = vi.spyOn(transport, 'clearPending');
|
|
211
|
+
const analytics = new AnalyticsClient<ProductEvent>({
|
|
212
|
+
context,
|
|
213
|
+
allowlist: { onboarding_started: ['source'] },
|
|
214
|
+
transport,
|
|
215
|
+
uuidFactory: () => ANONYMOUS_ID,
|
|
216
|
+
flushBatchSize: 100,
|
|
217
|
+
flushIntervalMs: 60_000,
|
|
218
|
+
development: false,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
analytics.setConsent('granted');
|
|
222
|
+
analytics.capture({ name: 'onboarding_started', properties: { source: 'organic' } });
|
|
223
|
+
await analytics.flush();
|
|
224
|
+
expect(posthog.queue).toEqual(['onboarding_started']);
|
|
225
|
+
|
|
226
|
+
analytics.setConsent('denied');
|
|
227
|
+
await vi.waitFor(() => {
|
|
228
|
+
expect(posthog.queue).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(clearPending).toHaveBeenCalledOnce();
|
|
232
|
+
expect(posthog.optOut).toHaveBeenCalledOnce();
|
|
233
|
+
expect(posthog.setPersistedProperty.mock.calls).toEqual([
|
|
234
|
+
['queue', []],
|
|
235
|
+
['ai_queue', []],
|
|
236
|
+
['ai_capture_queue', []],
|
|
237
|
+
['logs_queue', []],
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('drops before consent and transports only allowlisted, scrubbed properties after opt-in', async () => {
|
|
242
|
+
const posthog = createMockClient();
|
|
243
|
+
const allowlist = {
|
|
244
|
+
onboarding_started: ['source', 'contact'],
|
|
245
|
+
} as const satisfies EventAllowlist<ProductEvent>;
|
|
246
|
+
const analytics = new AnalyticsClient<ProductEvent>({
|
|
247
|
+
context,
|
|
248
|
+
allowlist,
|
|
249
|
+
transport: new PostHogNativeTransport(posthog.client),
|
|
250
|
+
uuidFactory: () => ANONYMOUS_ID,
|
|
251
|
+
flushBatchSize: 100,
|
|
252
|
+
flushIntervalMs: 60_000,
|
|
253
|
+
development: false,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
analytics.capture({ name: 'onboarding_started', properties: { source: 'organic' } });
|
|
257
|
+
await analytics.flush();
|
|
258
|
+
analytics.setConsent('denied');
|
|
259
|
+
analytics.capture({ name: 'onboarding_started', properties: { source: 'invite' } });
|
|
260
|
+
await analytics.flush();
|
|
261
|
+
expect(posthog.capture).not.toHaveBeenCalled();
|
|
262
|
+
|
|
263
|
+
analytics.setConsent('granted');
|
|
264
|
+
analytics.capture({
|
|
265
|
+
name: 'onboarding_started',
|
|
266
|
+
properties: {
|
|
267
|
+
source: 'organic',
|
|
268
|
+
contact: 'person@example.com',
|
|
269
|
+
unlisted: 'must not arrive',
|
|
270
|
+
},
|
|
271
|
+
} as ProductEvent);
|
|
272
|
+
await analytics.flush();
|
|
273
|
+
|
|
274
|
+
expect(posthog.capture).toHaveBeenCalledOnce();
|
|
275
|
+
expect(posthog.capture).toHaveBeenCalledWith(
|
|
276
|
+
'onboarding_started',
|
|
277
|
+
{
|
|
278
|
+
source: 'organic',
|
|
279
|
+
contact: '[redacted]',
|
|
280
|
+
...context,
|
|
281
|
+
},
|
|
282
|
+
expect.any(Object),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { PostHogOptions, PostHogPersistedProperty } from 'posthog-react-native';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AnalyticsEnvelope,
|
|
5
|
+
AnalyticsEvent,
|
|
6
|
+
CaptureEnvelope,
|
|
7
|
+
Transport,
|
|
8
|
+
} from '@baukit/analytics-core';
|
|
9
|
+
|
|
10
|
+
/** Structural surface used so tests do not require a React Native runtime. */
|
|
11
|
+
export interface PostHogNativeClient {
|
|
12
|
+
capture(
|
|
13
|
+
event: string,
|
|
14
|
+
properties?: Readonly<Record<string, unknown>>,
|
|
15
|
+
options?: { readonly timestamp?: Date },
|
|
16
|
+
): unknown;
|
|
17
|
+
identify(distinctId: string, traits?: Readonly<Record<string, unknown>>): unknown;
|
|
18
|
+
alias(alias: string): unknown;
|
|
19
|
+
reset(): unknown;
|
|
20
|
+
optIn(): Promise<void> | void;
|
|
21
|
+
optOut(): Promise<void> | void;
|
|
22
|
+
setPersistedProperty(key: `${PostHogPersistedProperty}`, value: unknown): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type GuardedPostHogNativeOption =
|
|
26
|
+
| 'bootstrap'
|
|
27
|
+
| 'captureAppLifecycleEvents'
|
|
28
|
+
| 'capturePushNotificationOpened'
|
|
29
|
+
| 'capturePushNotificationSubscriptions'
|
|
30
|
+
| 'disableSurveys'
|
|
31
|
+
| 'enableSessionReplay'
|
|
32
|
+
| 'errorTracking'
|
|
33
|
+
| 'host'
|
|
34
|
+
| 'personProfiles'
|
|
35
|
+
| 'preloadFeatureFlags'
|
|
36
|
+
| 'setDefaultPersonProperties'
|
|
37
|
+
| 'defaultOptIn';
|
|
38
|
+
|
|
39
|
+
export type PostHogNativeInitOptions = Omit<PostHogOptions, GuardedPostHogNativeOption>;
|
|
40
|
+
|
|
41
|
+
export type PostHogNativeInitializer = (
|
|
42
|
+
apiKey: string,
|
|
43
|
+
options: PostHogOptions,
|
|
44
|
+
) => PostHogNativeClient | Promise<PostHogNativeClient>;
|
|
45
|
+
|
|
46
|
+
export interface PostHogNativeTransportConfig {
|
|
47
|
+
readonly apiKey: string;
|
|
48
|
+
/** Absolute URL of the product's self-hosted PostHog instance. */
|
|
49
|
+
readonly apiHost: string;
|
|
50
|
+
readonly options?: PostHogNativeInitOptions;
|
|
51
|
+
readonly initializer?: PostHogNativeInitializer;
|
|
52
|
+
readonly onError?: (error: unknown) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ClientFactory = (
|
|
56
|
+
firstEnvelope: AnalyticsEnvelope,
|
|
57
|
+
) => PostHogNativeClient | Promise<PostHogNativeClient>;
|
|
58
|
+
|
|
59
|
+
const PENDING_QUEUE_KEYS: readonly `${PostHogPersistedProperty}`[] = [
|
|
60
|
+
'queue',
|
|
61
|
+
'ai_queue',
|
|
62
|
+
'ai_capture_queue',
|
|
63
|
+
'logs_queue',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function requireNonEmpty(name: string, value: string): string {
|
|
67
|
+
const normalized = value.trim();
|
|
68
|
+
if (normalized.length === 0) {
|
|
69
|
+
throw new TypeError(`${name} must not be empty`);
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requireApiHost(value: string): string {
|
|
75
|
+
const apiHost = requireNonEmpty('apiHost', value).replace(/\/+$/, '');
|
|
76
|
+
if (!/^https?:\/\/[^\s/]+(?:\/.*)?$/u.test(apiHost)) {
|
|
77
|
+
throw new TypeError('apiHost must be an absolute http(s) URL');
|
|
78
|
+
}
|
|
79
|
+
return apiHost;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function initialIdentity(envelope: AnalyticsEnvelope): {
|
|
83
|
+
readonly distinctId: string;
|
|
84
|
+
readonly isIdentifiedId: boolean;
|
|
85
|
+
} {
|
|
86
|
+
if (envelope.type === 'capture') {
|
|
87
|
+
return envelope.user_id === undefined
|
|
88
|
+
? { distinctId: envelope.anonymous_id, isIdentifiedId: false }
|
|
89
|
+
: { distinctId: envelope.user_id, isIdentifiedId: true };
|
|
90
|
+
}
|
|
91
|
+
if (envelope.type === 'reset') {
|
|
92
|
+
return envelope.previous_user_id === undefined
|
|
93
|
+
? { distinctId: envelope.previous_anonymous_id, isIdentifiedId: false }
|
|
94
|
+
: { distinctId: envelope.previous_user_id, isIdentifiedId: true };
|
|
95
|
+
}
|
|
96
|
+
return { distinctId: envelope.anonymous_id, isIdentifiedId: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function captureProperties<E extends AnalyticsEvent>(
|
|
100
|
+
envelope: CaptureEnvelope<E>,
|
|
101
|
+
): Readonly<Record<string, unknown>> {
|
|
102
|
+
return {
|
|
103
|
+
...envelope.event.properties,
|
|
104
|
+
schema_version: envelope.event.schema_version,
|
|
105
|
+
app: envelope.event.app,
|
|
106
|
+
app_version: envelope.event.app_version,
|
|
107
|
+
platform: envelope.event.platform,
|
|
108
|
+
environment: envelope.event.environment,
|
|
109
|
+
locale: envelope.event.locale,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function defaultInitializer(
|
|
114
|
+
apiKey: string,
|
|
115
|
+
options: PostHogOptions,
|
|
116
|
+
): Promise<PostHogNativeClient> {
|
|
117
|
+
const posthogModule = await import('posthog-react-native');
|
|
118
|
+
return new posthogModule.PostHog(apiKey, options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Translates privacy-checked core envelopes to a PostHog React Native client. */
|
|
122
|
+
export class PostHogNativeTransport<
|
|
123
|
+
E extends AnalyticsEvent = AnalyticsEvent,
|
|
124
|
+
> implements Transport<E> {
|
|
125
|
+
readonly #clientFactory: ClientFactory;
|
|
126
|
+
readonly #onError: ((error: unknown) => void) | undefined;
|
|
127
|
+
#client: Promise<PostHogNativeClient> | undefined;
|
|
128
|
+
#clearGeneration = 0;
|
|
129
|
+
#clearRequired = false;
|
|
130
|
+
#clearOperation: Promise<void> | undefined;
|
|
131
|
+
#providerOptedOut = true;
|
|
132
|
+
|
|
133
|
+
public constructor(
|
|
134
|
+
client: PostHogNativeClient | ClientFactory,
|
|
135
|
+
onError?: (error: unknown) => void,
|
|
136
|
+
) {
|
|
137
|
+
this.#clientFactory = typeof client === 'function' ? client : () => client;
|
|
138
|
+
this.#onError = onError;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Provider failures are contained and never reject into application code. */
|
|
142
|
+
public async send(envelopes: readonly AnalyticsEnvelope<E>[]): Promise<void> {
|
|
143
|
+
if (envelopes.length === 0) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const generation = this.#clearGeneration;
|
|
148
|
+
let client: PostHogNativeClient;
|
|
149
|
+
try {
|
|
150
|
+
client = await this.#getClient(envelopes[0] as AnalyticsEnvelope);
|
|
151
|
+
} catch (error: unknown) {
|
|
152
|
+
this.#reportError(error);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await this.#clearOperation;
|
|
157
|
+
if (generation !== this.#clearGeneration) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.#clearRequired) {
|
|
161
|
+
await this.#purgeClient(client);
|
|
162
|
+
if (generation !== this.#clearGeneration) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.#clearRequired = false;
|
|
166
|
+
}
|
|
167
|
+
if (!(await this.#optIn(client))) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const envelope of envelopes) {
|
|
172
|
+
if (generation !== this.#clearGeneration) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await this.#dispatch(client, envelope);
|
|
177
|
+
} catch (error: unknown) {
|
|
178
|
+
this.#reportError(error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Opts PostHog out and purges every persisted provider queue. */
|
|
184
|
+
public clearPending(): Promise<void> {
|
|
185
|
+
this.#clearGeneration += 1;
|
|
186
|
+
this.#clearRequired = true;
|
|
187
|
+
this.#providerOptedOut = true;
|
|
188
|
+
const client = this.#client;
|
|
189
|
+
if (client === undefined) {
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const generation = this.#clearGeneration;
|
|
194
|
+
const operation = client
|
|
195
|
+
.then(async (resolvedClient) => {
|
|
196
|
+
await this.#purgeClient(resolvedClient);
|
|
197
|
+
if (generation === this.#clearGeneration) {
|
|
198
|
+
this.#clearRequired = false;
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
.catch((error: unknown) => {
|
|
202
|
+
this.#reportError(error);
|
|
203
|
+
})
|
|
204
|
+
.finally(() => {
|
|
205
|
+
if (this.#clearOperation === operation) {
|
|
206
|
+
this.#clearOperation = undefined;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
this.#clearOperation = operation;
|
|
210
|
+
return operation;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#getClient(firstEnvelope: AnalyticsEnvelope): Promise<PostHogNativeClient> {
|
|
214
|
+
this.#client ??= Promise.resolve(this.#clientFactory(firstEnvelope));
|
|
215
|
+
return this.#client;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async #dispatch(client: PostHogNativeClient, envelope: AnalyticsEnvelope<E>): Promise<void> {
|
|
219
|
+
switch (envelope.type) {
|
|
220
|
+
case 'capture':
|
|
221
|
+
await Promise.resolve(
|
|
222
|
+
client.capture(envelope.event.name, captureProperties(envelope), {
|
|
223
|
+
timestamp: new Date(envelope.captured_at),
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
break;
|
|
227
|
+
case 'identify':
|
|
228
|
+
await Promise.resolve(client.identify(envelope.user_id, envelope.traits));
|
|
229
|
+
break;
|
|
230
|
+
case 'alias':
|
|
231
|
+
await Promise.resolve(client.alias(envelope.user_id));
|
|
232
|
+
break;
|
|
233
|
+
case 'reset':
|
|
234
|
+
await Promise.resolve(client.reset());
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async #optIn(client: PostHogNativeClient): Promise<boolean> {
|
|
240
|
+
if (!this.#providerOptedOut) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await Promise.resolve(client.optIn());
|
|
245
|
+
this.#providerOptedOut = false;
|
|
246
|
+
return true;
|
|
247
|
+
} catch (error: unknown) {
|
|
248
|
+
this.#reportError(error);
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async #purgeClient(client: PostHogNativeClient): Promise<void> {
|
|
254
|
+
try {
|
|
255
|
+
await Promise.resolve(client.optOut());
|
|
256
|
+
} catch (error: unknown) {
|
|
257
|
+
this.#reportError(error);
|
|
258
|
+
}
|
|
259
|
+
for (const key of PENDING_QUEUE_KEYS) {
|
|
260
|
+
try {
|
|
261
|
+
client.setPersistedProperty(key, []);
|
|
262
|
+
} catch (error: unknown) {
|
|
263
|
+
this.#reportError(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#reportError(error: unknown): void {
|
|
269
|
+
try {
|
|
270
|
+
this.#onError?.(error);
|
|
271
|
+
} catch {
|
|
272
|
+
// Provider diagnostics must not affect the application journey.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Creates a lazy, safely configured PostHog React Native transport. */
|
|
278
|
+
export function createPostHogNativeTransport<E extends AnalyticsEvent = AnalyticsEvent>(
|
|
279
|
+
config: PostHogNativeTransportConfig,
|
|
280
|
+
): PostHogNativeTransport<E> {
|
|
281
|
+
const apiKey = requireNonEmpty('apiKey', config.apiKey);
|
|
282
|
+
const apiHost = requireApiHost(config.apiHost);
|
|
283
|
+
const initializer = config.initializer ?? defaultInitializer;
|
|
284
|
+
|
|
285
|
+
return new PostHogNativeTransport<E>((firstEnvelope) => {
|
|
286
|
+
const options: PostHogOptions = {
|
|
287
|
+
...config.options,
|
|
288
|
+
host: apiHost,
|
|
289
|
+
bootstrap: initialIdentity(firstEnvelope),
|
|
290
|
+
captureAppLifecycleEvents: false,
|
|
291
|
+
capturePushNotificationOpened: false,
|
|
292
|
+
capturePushNotificationSubscriptions: false,
|
|
293
|
+
disableSurveys: true,
|
|
294
|
+
enableSessionReplay: false,
|
|
295
|
+
errorTracking: { autocapture: false },
|
|
296
|
+
defaultOptIn: false,
|
|
297
|
+
personProfiles: 'identified_only',
|
|
298
|
+
preloadFeatureFlags: false,
|
|
299
|
+
setDefaultPersonProperties: false,
|
|
300
|
+
};
|
|
301
|
+
return initializer(apiKey, options);
|
|
302
|
+
}, config.onError);
|
|
303
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { AnalyticsClient, analyticsStorageKeys } from '@baukit/analytics-core';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { HydratedAnalyticsStorage, type AsyncKeyValueStorage } from './storage.js';
|
|
5
|
+
|
|
6
|
+
const keys = analyticsStorageKeys('app');
|
|
7
|
+
const persistentKeys = [keys.anonymousId, keys.userId, keys.aliasedUserId];
|
|
8
|
+
const ANONYMOUS_ID = '00000000-0000-4000-8000-000000000001';
|
|
9
|
+
|
|
10
|
+
class MemoryPersistence implements AsyncKeyValueStorage {
|
|
11
|
+
readonly values = new Map<string, string>();
|
|
12
|
+
readonly failingReads = new Set<string>();
|
|
13
|
+
failWrites = false;
|
|
14
|
+
|
|
15
|
+
getItem(key: string): Promise<string | null> {
|
|
16
|
+
if (this.failingReads.has(key)) return Promise.reject(new Error('read failed'));
|
|
17
|
+
return Promise.resolve(this.values.get(key) ?? null);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setItem(key: string, value: string): Promise<void> {
|
|
21
|
+
if (this.failWrites) return Promise.reject(new Error('write failed'));
|
|
22
|
+
this.values.set(key, value);
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
removeItem(key: string): Promise<void> {
|
|
27
|
+
if (this.failWrites) return Promise.reject(new Error('write failed'));
|
|
28
|
+
this.values.delete(key);
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('HydratedAnalyticsStorage', () => {
|
|
34
|
+
it('hydrates only the persistent keys that hold a value', async () => {
|
|
35
|
+
const persistence = new MemoryPersistence();
|
|
36
|
+
persistence.values.set(keys.anonymousId, ANONYMOUS_ID);
|
|
37
|
+
persistence.values.set(keys.consent, 'granted');
|
|
38
|
+
|
|
39
|
+
const storage = await HydratedAnalyticsStorage.load({ persistence, persistentKeys });
|
|
40
|
+
|
|
41
|
+
expect(storage.getItem(keys.anonymousId)).toBe(ANONYMOUS_ID);
|
|
42
|
+
expect(storage.getItem(keys.userId)).toBeUndefined();
|
|
43
|
+
expect(storage.getItem(keys.consent)).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps the keys that load when another key fails to read', async () => {
|
|
47
|
+
const persistence = new MemoryPersistence();
|
|
48
|
+
persistence.values.set(keys.anonymousId, ANONYMOUS_ID);
|
|
49
|
+
persistence.values.set(keys.userId, 'user');
|
|
50
|
+
persistence.failingReads.add(keys.userId);
|
|
51
|
+
|
|
52
|
+
const storage = await HydratedAnalyticsStorage.load({ persistence, persistentKeys });
|
|
53
|
+
|
|
54
|
+
expect(storage.getItem(keys.anonymousId)).toBe(ANONYMOUS_ID);
|
|
55
|
+
expect(storage.getItem(keys.userId)).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('writes persistent keys through and keeps other keys in memory', async () => {
|
|
59
|
+
const persistence = new MemoryPersistence();
|
|
60
|
+
const storage = await HydratedAnalyticsStorage.load({ persistence, persistentKeys });
|
|
61
|
+
|
|
62
|
+
storage.setItem(keys.userId, 'user');
|
|
63
|
+
storage.setItem(keys.consent, 'granted');
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
|
|
66
|
+
expect(storage.getItem(keys.consent)).toBe('granted');
|
|
67
|
+
expect(persistence.values.get(keys.userId)).toBe('user');
|
|
68
|
+
expect(persistence.values.has(keys.consent)).toBe(false);
|
|
69
|
+
|
|
70
|
+
storage.removeItem(keys.userId);
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
expect(storage.getItem(keys.userId)).toBeUndefined();
|
|
73
|
+
expect(persistence.values.has(keys.userId)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps the in-memory value when a write fails or throws', async () => {
|
|
77
|
+
const persistence = new MemoryPersistence();
|
|
78
|
+
const storage = await HydratedAnalyticsStorage.load({ persistence, persistentKeys });
|
|
79
|
+
persistence.failWrites = true;
|
|
80
|
+
|
|
81
|
+
storage.setItem(keys.userId, 'user');
|
|
82
|
+
expect(storage.getItem(keys.userId)).toBe('user');
|
|
83
|
+
|
|
84
|
+
vi.spyOn(persistence, 'removeItem').mockImplementation(() => {
|
|
85
|
+
throw new Error('synchronous failure');
|
|
86
|
+
});
|
|
87
|
+
expect(() => {
|
|
88
|
+
storage.removeItem(keys.userId);
|
|
89
|
+
}).not.toThrow();
|
|
90
|
+
expect(storage.getItem(keys.userId)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('restores the anonymous identity for a new analytics client', async () => {
|
|
94
|
+
const persistence = new MemoryPersistence();
|
|
95
|
+
const first = new AnalyticsClient({
|
|
96
|
+
context: {
|
|
97
|
+
schema_version: 1,
|
|
98
|
+
app: 'example-native',
|
|
99
|
+
app_version: '1.0.0',
|
|
100
|
+
platform: 'native',
|
|
101
|
+
environment: 'test',
|
|
102
|
+
locale: 'en-GB',
|
|
103
|
+
},
|
|
104
|
+
allowlist: {},
|
|
105
|
+
storageKeyPrefix: 'app',
|
|
106
|
+
uuidFactory: () => ANONYMOUS_ID,
|
|
107
|
+
storage: await HydratedAnalyticsStorage.load({ persistence, persistentKeys }),
|
|
108
|
+
});
|
|
109
|
+
await Promise.resolve();
|
|
110
|
+
|
|
111
|
+
const restored = await HydratedAnalyticsStorage.load({ persistence, persistentKeys });
|
|
112
|
+
expect(restored.getItem(keys.anonymousId)).toBe(first.anonymousId);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AnalyticsStorage } from '@baukit/analytics-core';
|
|
2
|
+
|
|
3
|
+
/** Asynchronous key-value store, such as React Native AsyncStorage. */
|
|
4
|
+
export interface AsyncKeyValueStorage {
|
|
5
|
+
getItem(key: string): Promise<string | null>;
|
|
6
|
+
setItem(key: string, value: string): Promise<void>;
|
|
7
|
+
removeItem(key: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HydratedAnalyticsStorageOptions {
|
|
11
|
+
readonly persistence: AsyncKeyValueStorage;
|
|
12
|
+
/** Keys copied from and written through to `persistence`. Other keys stay in memory. */
|
|
13
|
+
readonly persistentKeys: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Synchronous `AnalyticsStorage` over an asynchronous store. `load` reads the persistent keys
|
|
18
|
+
* once, then reads are served from memory and writes go through without being awaited.
|
|
19
|
+
*/
|
|
20
|
+
export class HydratedAnalyticsStorage implements AnalyticsStorage {
|
|
21
|
+
readonly #values: Map<string, string>;
|
|
22
|
+
readonly #persistence: AsyncKeyValueStorage;
|
|
23
|
+
readonly #persistentKeys: ReadonlySet<string>;
|
|
24
|
+
|
|
25
|
+
private constructor(
|
|
26
|
+
options: HydratedAnalyticsStorageOptions,
|
|
27
|
+
entries: readonly (readonly [string, string])[],
|
|
28
|
+
) {
|
|
29
|
+
this.#persistence = options.persistence;
|
|
30
|
+
this.#persistentKeys = new Set(options.persistentKeys);
|
|
31
|
+
this.#values = new Map(entries);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public static async load(
|
|
35
|
+
options: HydratedAnalyticsStorageOptions,
|
|
36
|
+
): Promise<HydratedAnalyticsStorage> {
|
|
37
|
+
const keys = [...new Set(options.persistentKeys)];
|
|
38
|
+
const stored = await Promise.all(keys.map((key) => readOrAbsent(options.persistence, key)));
|
|
39
|
+
const entries = keys.flatMap((key, index) => {
|
|
40
|
+
const value = stored[index];
|
|
41
|
+
return value === undefined ? [] : [[key, value] as const];
|
|
42
|
+
});
|
|
43
|
+
return new HydratedAnalyticsStorage(options, entries);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public getItem(key: string): string | undefined {
|
|
47
|
+
return this.#values.get(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public setItem(key: string, value: string): void {
|
|
51
|
+
this.#values.set(key, value);
|
|
52
|
+
if (!this.#persistentKeys.has(key)) return;
|
|
53
|
+
ignoreFailure(() => this.#persistence.setItem(key, value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public removeItem(key: string): void {
|
|
57
|
+
this.#values.delete(key);
|
|
58
|
+
if (!this.#persistentKeys.has(key)) return;
|
|
59
|
+
ignoreFailure(() => this.#persistence.removeItem(key));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readOrAbsent(
|
|
64
|
+
persistence: AsyncKeyValueStorage,
|
|
65
|
+
key: string,
|
|
66
|
+
): Promise<string | undefined> {
|
|
67
|
+
try {
|
|
68
|
+
return (await persistence.getItem(key)) ?? undefined;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ignoreFailure(write: () => Promise<void>): void {
|
|
75
|
+
try {
|
|
76
|
+
void write().catch(() => undefined);
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|