@stapel/core 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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/dist/analytics/context.d.ts +9 -0
  4. package/dist/analytics/context.d.ts.map +1 -0
  5. package/dist/analytics/context.js +14 -0
  6. package/dist/analytics/context.js.map +1 -0
  7. package/dist/analytics/createAnalytics.d.ts +10 -0
  8. package/dist/analytics/createAnalytics.d.ts.map +1 -0
  9. package/dist/analytics/createAnalytics.js +273 -0
  10. package/dist/analytics/createAnalytics.js.map +1 -0
  11. package/dist/analytics/flow.d.ts +10 -0
  12. package/dist/analytics/flow.d.ts.map +1 -0
  13. package/dist/analytics/flow.js +10 -0
  14. package/dist/analytics/flow.js.map +1 -0
  15. package/dist/analytics/hash.d.ts +3 -0
  16. package/dist/analytics/hash.d.ts.map +1 -0
  17. package/dist/analytics/hash.js +12 -0
  18. package/dist/analytics/hash.js.map +1 -0
  19. package/dist/analytics/pii.d.ts +9 -0
  20. package/dist/analytics/pii.d.ts.map +1 -0
  21. package/dist/analytics/pii.js +52 -0
  22. package/dist/analytics/pii.js.map +1 -0
  23. package/dist/analytics/providers.d.ts +28 -0
  24. package/dist/analytics/providers.d.ts.map +1 -0
  25. package/dist/analytics/providers.js +82 -0
  26. package/dist/analytics/providers.js.map +1 -0
  27. package/dist/analytics/types.d.ts +94 -0
  28. package/dist/analytics/types.d.ts.map +1 -0
  29. package/dist/analytics/types.js +7 -0
  30. package/dist/analytics/types.js.map +1 -0
  31. package/dist/client.d.ts +49 -0
  32. package/dist/client.d.ts.map +1 -0
  33. package/dist/client.js +135 -0
  34. package/dist/client.js.map +1 -0
  35. package/dist/config.d.ts +32 -0
  36. package/dist/config.d.ts.map +1 -0
  37. package/dist/config.js +28 -0
  38. package/dist/config.js.map +1 -0
  39. package/dist/errors.d.ts +33 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +46 -0
  42. package/dist/errors.js.map +1 -0
  43. package/dist/i18n.d.ts +51 -0
  44. package/dist/i18n.d.ts.map +1 -0
  45. package/dist/i18n.js +90 -0
  46. package/dist/i18n.js.map +1 -0
  47. package/dist/index.d.ts +22 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +19 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/query.d.ts +42 -0
  52. package/dist/query.d.ts.map +1 -0
  53. package/dist/query.js +95 -0
  54. package/dist/query.js.map +1 -0
  55. package/dist/storage.d.ts +16 -0
  56. package/dist/storage.d.ts.map +1 -0
  57. package/dist/storage.js +65 -0
  58. package/dist/storage.js.map +1 -0
  59. package/dist/useBreakpoint.d.ts +8 -0
  60. package/dist/useBreakpoint.d.ts.map +1 -0
  61. package/dist/useBreakpoint.js +22 -0
  62. package/dist/useBreakpoint.js.map +1 -0
  63. package/dist/verification.d.ts +31 -0
  64. package/dist/verification.d.ts.map +1 -0
  65. package/dist/verification.js +20 -0
  66. package/dist/verification.js.map +1 -0
  67. package/package.json +68 -0
  68. package/src/analytics/context.ts +20 -0
  69. package/src/analytics/createAnalytics.ts +310 -0
  70. package/src/analytics/flow.ts +19 -0
  71. package/src/analytics/hash.ts +16 -0
  72. package/src/analytics/pii.ts +66 -0
  73. package/src/analytics/providers.ts +108 -0
  74. package/src/analytics/types.ts +105 -0
  75. package/src/client.ts +206 -0
  76. package/src/config.tsx +62 -0
  77. package/src/errors.ts +70 -0
  78. package/src/i18n.tsx +147 -0
  79. package/src/index.ts +72 -0
  80. package/src/query.ts +151 -0
  81. package/src/storage.ts +76 -0
  82. package/src/useBreakpoint.ts +27 -0
  83. package/src/verification.ts +48 -0
  84. package/tsconfig.json +26 -0
@@ -0,0 +1,310 @@
1
+ import { defaultPersistStorage } from "../storage.js";
2
+ import type { PersistStorage } from "../storage.js";
3
+ import { guardPii } from "./pii.js";
4
+ import { sha256Hex } from "./hash.js";
5
+ import type {
6
+ Analytics,
7
+ AnalyticsEvent,
8
+ AnalyticsEventKind,
9
+ AnalyticsOptions,
10
+ AnalyticsProvider,
11
+ ConsentState,
12
+ } from "./types.js";
13
+
14
+ /** Exponential backoff delay for a batch delivery attempt (1-based). */
15
+ export function backoffDelay(attempt: number, baseMs: number): number {
16
+ return Math.min(30_000, baseMs * 2 ** Math.max(0, attempt - 1));
17
+ }
18
+
19
+ interface InflightBatch {
20
+ events: AnalyticsEvent[];
21
+ attempts: number;
22
+ /** Provider names that already accepted this batch (never re-called). */
23
+ delivered: Set<string>;
24
+ }
25
+
26
+ async function deliverToProvider(
27
+ provider: AnalyticsProvider,
28
+ events: readonly AnalyticsEvent[]
29
+ ): Promise<void> {
30
+ for (const event of events) {
31
+ if (event.kind === "identify" && provider.identify) {
32
+ await provider.identify(event.userHash ?? "", event.props);
33
+ } else if (event.kind === "page" && provider.page) {
34
+ await provider.page(event.name, event.props);
35
+ } else {
36
+ await provider.track(event);
37
+ }
38
+ }
39
+ await provider.flush?.();
40
+ }
41
+
42
+ /**
43
+ * The analytics facade (analytics-standard §2): fan-out to N providers,
44
+ * consent gate, offline queue on the core persist layer, batched delivery
45
+ * with retry/backoff, PII guard, hashed identify.
46
+ */
47
+ export function createAnalytics(options: AnalyticsOptions = {}): Analytics {
48
+ const providers = new Map<string, AnalyticsProvider>(
49
+ Object.entries(options.providers ?? {})
50
+ );
51
+ const registry =
52
+ options.registry !== undefined ? new Set(options.registry) : null;
53
+ const piiMode = options.piiGuard ?? "strip";
54
+ const persistKey = options.persistKey ?? "stapel-analytics";
55
+ const storage: PersistStorage = options.storage ?? defaultPersistStorage();
56
+ const maxSize = options.batch?.maxSize ?? 20;
57
+ const flushIntervalMs = options.batch?.flushIntervalMs ?? 10_000;
58
+ const maxAttempts = options.batch?.maxAttempts ?? 5;
59
+ const backoffBaseMs = options.batch?.backoffBaseMs ?? 500;
60
+
61
+ const eventsKey = `${persistKey}:events`;
62
+ const consentKey = `${persistKey}:consent`;
63
+
64
+ let consent: ConsentState = options.consent ?? "pending";
65
+ let queue: AnalyticsEvent[] = [];
66
+ let batch: InflightBatch | null = null;
67
+ let flushing = false;
68
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
69
+ let seq = 0;
70
+ const warnedPii = new Set<string>();
71
+ const warnedRegistry = new Set<string>();
72
+ /** In-flight async work (identify hashing, persist writes) awaited by flush. */
73
+ let pendingOps: Promise<unknown>[] = [];
74
+ /** Serializes identify hashing so enqueue order matches call order. */
75
+ let identifyChain: Promise<void> = Promise.resolve();
76
+
77
+ const ready: Promise<void> = (async () => {
78
+ try {
79
+ const storedConsent = await storage.get(consentKey);
80
+ if (
81
+ storedConsent === "granted" ||
82
+ storedConsent === "denied" ||
83
+ storedConsent === "pending"
84
+ ) {
85
+ consent = storedConsent;
86
+ }
87
+ const storedEvents = await storage.get(eventsKey);
88
+ if (Array.isArray(storedEvents)) {
89
+ // Restored events precede anything captured before init finished.
90
+ queue = [...(storedEvents as AnalyticsEvent[]), ...queue];
91
+ }
92
+ } catch {
93
+ // Storage unavailable — degrade to in-memory only.
94
+ }
95
+ })();
96
+
97
+ async function persistQueue(): Promise<void> {
98
+ try {
99
+ if (consent === "denied") {
100
+ await storage.del(eventsKey);
101
+ return;
102
+ }
103
+ const pending = batch ? [...batch.events, ...queue] : queue;
104
+ await storage.set(eventsKey, pending);
105
+ } catch {
106
+ // Best-effort.
107
+ }
108
+ }
109
+
110
+ function schedulePersist(): void {
111
+ pendingOps.push(ready.then(persistQueue));
112
+ }
113
+
114
+ function clearRetryTimer(): void {
115
+ if (retryTimer !== null) {
116
+ clearTimeout(retryTimer);
117
+ retryTimer = null;
118
+ }
119
+ }
120
+
121
+ function scheduleRetry(delayMs: number): void {
122
+ if (retryTimer !== null) return;
123
+ retryTimer = setTimeout(() => {
124
+ retryTimer = null;
125
+ void flush();
126
+ }, delayMs);
127
+ (retryTimer as { unref?: () => void }).unref?.();
128
+ }
129
+
130
+ function enqueue(
131
+ kind: AnalyticsEventKind,
132
+ name: string,
133
+ props: Record<string, unknown>,
134
+ userHash?: string
135
+ ): void {
136
+ seq += 1;
137
+ const event: AnalyticsEvent = {
138
+ id: `${String(Date.now())}-${String(seq)}`,
139
+ kind,
140
+ name,
141
+ props,
142
+ ...(userHash !== undefined ? { userHash } : {}),
143
+ ts: Date.now(),
144
+ };
145
+ queue.push(event);
146
+ schedulePersist();
147
+ if (consent === "granted" && queue.length >= maxSize) {
148
+ void flush();
149
+ }
150
+ }
151
+
152
+ /** One delivery attempt for the current batch. True = batch settled. */
153
+ async function attemptBatch(): Promise<boolean> {
154
+ const current = batch;
155
+ if (current === null) return true;
156
+ current.attempts += 1;
157
+ await Promise.all(
158
+ [...providers.entries()].map(async ([name, provider]) => {
159
+ if (current.delivered.has(name)) return;
160
+ try {
161
+ await deliverToProvider(provider, current.events);
162
+ current.delivered.add(name);
163
+ } catch {
164
+ // Undelivered for this provider; batch will be retried.
165
+ }
166
+ })
167
+ );
168
+ const undelivered = [...providers.keys()].filter(
169
+ (name) => !current.delivered.has(name)
170
+ );
171
+ if (undelivered.length === 0) {
172
+ batch = null;
173
+ clearRetryTimer();
174
+ return true;
175
+ }
176
+ if (current.attempts >= maxAttempts) {
177
+ console.warn(
178
+ `[stapel analytics] dropping a batch of ${String(current.events.length)} event(s) ` +
179
+ `after ${String(current.attempts)} attempts; undelivered to: ${undelivered.join(", ")}`
180
+ );
181
+ batch = null;
182
+ clearRetryTimer();
183
+ return true;
184
+ }
185
+ scheduleRetry(backoffDelay(current.attempts, backoffBaseMs));
186
+ return false;
187
+ }
188
+
189
+ async function flush(): Promise<void> {
190
+ await ready;
191
+ const ops = pendingOps;
192
+ pendingOps = [];
193
+ await Promise.all(ops);
194
+ if (flushing) return;
195
+ flushing = true;
196
+ try {
197
+ if (consent !== "granted") return;
198
+ for (;;) {
199
+ if (batch === null) {
200
+ if (queue.length === 0 || providers.size === 0) break;
201
+ batch = {
202
+ events: queue.splice(0, maxSize),
203
+ attempts: 0,
204
+ delivered: new Set<string>(),
205
+ };
206
+ }
207
+ const settled = await attemptBatch();
208
+ if (!settled) break;
209
+ }
210
+ } finally {
211
+ flushing = false;
212
+ await persistQueue();
213
+ }
214
+ }
215
+
216
+ async function setConsent(state: ConsentState): Promise<void> {
217
+ await ready;
218
+ consent = state;
219
+ try {
220
+ await storage.set(consentKey, state);
221
+ } catch {
222
+ // Best-effort.
223
+ }
224
+ if (state === "denied") {
225
+ queue = [];
226
+ batch = null;
227
+ clearRetryTimer();
228
+ try {
229
+ await storage.del(eventsKey);
230
+ } catch {
231
+ // Best-effort.
232
+ }
233
+ return;
234
+ }
235
+ if (state === "granted") {
236
+ await flush();
237
+ }
238
+ }
239
+
240
+ function track(event: string, props?: Record<string, unknown>): void {
241
+ if (consent === "denied") return;
242
+ if (registry && !registry.has(event) && !warnedRegistry.has(event)) {
243
+ warnedRegistry.add(event);
244
+ console.warn(
245
+ `[stapel analytics] event "${event}" is not in the registry ` +
246
+ "(analytics-standard §1.1) — delivered anyway; declare it in events.json."
247
+ );
248
+ }
249
+ enqueue("track", event, guardPii(event, props ?? {}, piiMode, warnedPii));
250
+ }
251
+
252
+ function page(name: string, props?: Record<string, unknown>): void {
253
+ if (consent === "denied") return;
254
+ enqueue("page", name, guardPii(name, props ?? {}, piiMode, warnedPii));
255
+ }
256
+
257
+ function identify(userId: string, traits?: Record<string, unknown>): void {
258
+ if (consent === "denied") return;
259
+ const guarded = guardPii("identify", traits ?? {}, piiMode, warnedPii);
260
+ identifyChain = identifyChain
261
+ .then(() => sha256Hex(userId))
262
+ .then((userHash) => {
263
+ enqueue("identify", "identify", guarded, userHash);
264
+ });
265
+ pendingOps.push(identifyChain);
266
+ }
267
+
268
+ function finalFlush(): void {
269
+ // Best-effort teardown flush; batching providers use sendBeacon here.
270
+ void flush();
271
+ for (const provider of providers.values()) {
272
+ try {
273
+ void provider.flush?.();
274
+ } catch {
275
+ // Never break page teardown.
276
+ }
277
+ }
278
+ }
279
+
280
+ if (typeof window !== "undefined") {
281
+ window.addEventListener("pagehide", finalFlush);
282
+ }
283
+ if (typeof document !== "undefined") {
284
+ document.addEventListener("visibilitychange", () => {
285
+ if (document.visibilityState === "hidden") finalFlush();
286
+ });
287
+ }
288
+
289
+ const interval = setInterval(() => {
290
+ if (consent === "granted" && (queue.length > 0 || batch !== null)) {
291
+ void flush();
292
+ }
293
+ }, flushIntervalMs);
294
+ (interval as { unref?: () => void }).unref?.();
295
+
296
+ return {
297
+ track,
298
+ identify,
299
+ page,
300
+ flush,
301
+ setConsent,
302
+ getConsent: () => consent,
303
+ register: (name, provider) => {
304
+ providers.set(name, provider);
305
+ },
306
+ unregister: (name) => {
307
+ providers.delete(name);
308
+ },
309
+ };
310
+ }
@@ -0,0 +1,19 @@
1
+ import type { Analytics } from "./types.js";
2
+
3
+ export type FlowStepPhase = "started" | "completed" | "failed";
4
+
5
+ /**
6
+ * Auto-instrumentation seam for flow machines (analytics-standard §1.2):
7
+ * emits `flow.<flowId>.<stepId>` with `{phase, ...props}`. Funnel = flow —
8
+ * the `@stapel/<module>-react` machines call this on every transition, so
9
+ * funnels exist without hand-written instrumentation.
10
+ */
11
+ export function trackFlowStep(
12
+ analytics: Analytics,
13
+ flowId: string,
14
+ stepId: string,
15
+ phase: FlowStepPhase,
16
+ props?: Record<string, unknown>
17
+ ): void {
18
+ analytics.track(`flow.${flowId}.${stepId}`, { phase, ...props });
19
+ }
@@ -0,0 +1,16 @@
1
+ /** SHA-256 hex via WebCrypto — user ids are hashed before any provider sees them. */
2
+ export async function sha256Hex(input: string): Promise<string> {
3
+ const subtle = globalThis.crypto?.subtle;
4
+ if (!subtle) {
5
+ throw new Error(
6
+ "[stapel analytics] crypto.subtle is unavailable; cannot hash user ids"
7
+ );
8
+ }
9
+ const digest = await subtle.digest(
10
+ "SHA-256",
11
+ new TextEncoder().encode(input)
12
+ );
13
+ return [...new Uint8Array(digest)]
14
+ .map((byte) => byte.toString(16).padStart(2, "0"))
15
+ .join("");
16
+ }
@@ -0,0 +1,66 @@
1
+ import type { PiiGuardMode } from "./types.js";
2
+
3
+ /**
4
+ * PII guard heuristics (analytics-standard §1.4): prop VALUES that look
5
+ * like emails or phone numbers are redacted ("strip"), kept with a warning
6
+ * ("warn"), or passed through ("off"). Applies to track/page props and
7
+ * identify traits. Keys are not judged — only values.
8
+ */
9
+
10
+ const EMAIL_RE = /[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}/;
11
+ const PHONE_SHAPE_RE = /^\+?[\d\s\-().]{7,}$/;
12
+
13
+ export function looksLikePii(value: string): boolean {
14
+ if (EMAIL_RE.test(value)) return true;
15
+ const trimmed = value.trim();
16
+ if (!PHONE_SHAPE_RE.test(trimmed)) return false;
17
+ return trimmed.replace(/\D/g, "").length >= 7;
18
+ }
19
+
20
+ export const PII_REDACTED = "[redacted]";
21
+
22
+ function sanitizeValue(
23
+ value: unknown,
24
+ mode: PiiGuardMode,
25
+ hit: { found: boolean }
26
+ ): unknown {
27
+ if (typeof value === "string" && looksLikePii(value)) {
28
+ hit.found = true;
29
+ return mode === "strip" ? PII_REDACTED : value;
30
+ }
31
+ if (Array.isArray(value)) {
32
+ return value.map((item) => sanitizeValue(item, mode, hit));
33
+ }
34
+ if (typeof value === "object" && value !== null) {
35
+ const result: Record<string, unknown> = {};
36
+ for (const [key, nested] of Object.entries(value)) {
37
+ result[key] = sanitizeValue(nested, mode, hit);
38
+ }
39
+ return result;
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /**
45
+ * Guard a props/traits object. Warns once per event name (per facade
46
+ * instance) via the caller-owned `warned` set.
47
+ */
48
+ export function guardPii(
49
+ eventName: string,
50
+ props: Record<string, unknown>,
51
+ mode: PiiGuardMode,
52
+ warned: Set<string>
53
+ ): Record<string, unknown> {
54
+ if (mode === "off") return props;
55
+ const hit = { found: false };
56
+ const guarded = sanitizeValue(props, mode, hit) as Record<string, unknown>;
57
+ if (hit.found && !warned.has(eventName)) {
58
+ warned.add(eventName);
59
+ console.warn(
60
+ `[stapel analytics] PII-like value in props of "${eventName}" — ` +
61
+ (mode === "strip" ? "redacted" : "kept (piiGuard: warn)") +
62
+ ". PII in analytics props is banned (analytics-standard §1.4)."
63
+ );
64
+ }
65
+ return guarded;
66
+ }
@@ -0,0 +1,108 @@
1
+ import type { StapelClient } from "../client.js";
2
+ import type { AnalyticsEvent, AnalyticsProvider } from "./types.js";
3
+
4
+ /** Dev provider: logs every event via console.debug. Never throws. */
5
+ export function consoleProvider(): AnalyticsProvider {
6
+ const log = (...args: unknown[]): void => {
7
+ try {
8
+ console.debug("[stapel analytics]", ...args);
9
+ } catch {
10
+ // Never throws — a broken console must not fail a batch.
11
+ }
12
+ };
13
+ return {
14
+ track: (event) => {
15
+ log(event.kind, event.name, event.props);
16
+ },
17
+ identify: (userHash, traits) => {
18
+ log("identify", userHash, traits ?? {});
19
+ },
20
+ page: (name, props) => {
21
+ log("page", name, props ?? {});
22
+ },
23
+ };
24
+ }
25
+
26
+ export interface StapelCollectorOptions {
27
+ /** Collector origin, e.g. `https://api.example.com`. */
28
+ readonly baseUrl?: string;
29
+ /**
30
+ * Alternatively reuse a `StapelClient` (its base URL, auth headers and
31
+ * error envelope handling).
32
+ */
33
+ readonly client?: StapelClient;
34
+ /** Source write key (analytics-standard §3); sent in the batch body. */
35
+ readonly writeKey?: string;
36
+ /** Injectable fetch (tests). */
37
+ readonly fetch?: typeof globalThis.fetch;
38
+ }
39
+
40
+ const COLLECTOR_PATH = "/analytics/api/events";
41
+
42
+ /**
43
+ * Built-in provider for the stapel-analytics ingest endpoint: buffers
44
+ * events handed over by the facade and POSTs `{events: [...]}` to
45
+ * `{base}/analytics/api/events` on flush (one request per facade batch).
46
+ * During page teardown (document hidden) it prefers `navigator.sendBeacon`
47
+ * so the final batch survives navigation; otherwise fetch with keepalive.
48
+ * On a failed send the buffer is surrendered back to the facade's retry
49
+ * (the facade re-delivers the batch, repopulating the buffer).
50
+ */
51
+ export function stapelCollectorProvider(
52
+ options: StapelCollectorOptions
53
+ ): AnalyticsProvider {
54
+ const baseUrl = options.baseUrl ?? options.client?.baseUrl;
55
+ if (baseUrl === undefined) {
56
+ throw new Error("stapelCollectorProvider requires a baseUrl or a client");
57
+ }
58
+ const endpoint = `${baseUrl.replace(/\/+$/, "")}${COLLECTOR_PATH}`;
59
+ let buffer: AnalyticsEvent[] = [];
60
+
61
+ async function send(events: readonly AnalyticsEvent[]): Promise<void> {
62
+ const body: Record<string, unknown> = { events };
63
+ if (options.writeKey !== undefined) body["write_key"] = options.writeKey;
64
+
65
+ const teardown =
66
+ typeof document !== "undefined" &&
67
+ document.visibilityState === "hidden" &&
68
+ typeof navigator !== "undefined" &&
69
+ typeof navigator.sendBeacon === "function";
70
+ if (teardown) {
71
+ const accepted = navigator.sendBeacon(
72
+ endpoint,
73
+ new Blob([JSON.stringify(body)], { type: "application/json" })
74
+ );
75
+ if (accepted) return;
76
+ // Beacon rejected (payload too large / unsupported) — fall through.
77
+ }
78
+
79
+ if (options.client) {
80
+ await options.client.post(COLLECTOR_PATH, body);
81
+ return;
82
+ }
83
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
84
+ const response = await fetchImpl(endpoint, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify(body),
88
+ keepalive: true,
89
+ });
90
+ if (!response.ok) {
91
+ throw new Error(
92
+ `[stapel analytics] collector responded ${String(response.status)}`
93
+ );
94
+ }
95
+ }
96
+
97
+ return {
98
+ track: (event) => {
99
+ buffer.push(event);
100
+ },
101
+ flush: async () => {
102
+ if (buffer.length === 0) return;
103
+ const events = buffer;
104
+ buffer = [];
105
+ await send(events);
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Analytics facade types (analytics-standard.md §1–2). Packages and hosts
3
+ * talk to the facade only — never to providers directly
4
+ * (frontend-standard §4.7).
5
+ */
6
+
7
+ import type { PersistStorage } from "../storage.js";
8
+
9
+ export type AnalyticsEventKind = "track" | "page" | "identify";
10
+
11
+ export type ConsentState = "granted" | "denied" | "pending";
12
+
13
+ export type PiiGuardMode = "strip" | "warn" | "off";
14
+
15
+ /** A captured event as providers receive it (PII-guarded, user id hashed). */
16
+ export interface AnalyticsEvent {
17
+ /** Facade-assigned id, unique per instance lifetime (dedupe aid). */
18
+ readonly id: string;
19
+ readonly kind: AnalyticsEventKind;
20
+ /** track: registry event name; page: page name; identify: `"identify"`. */
21
+ readonly name: string;
22
+ /** track/page props or identify traits, after the PII guard. */
23
+ readonly props: Record<string, unknown>;
24
+ /** SHA-256 hex of the user id (identify events only). */
25
+ readonly userHash?: string;
26
+ /** Epoch ms at capture time. */
27
+ readonly ts: number;
28
+ }
29
+
30
+ /**
31
+ * A fan-out target. Only `track` is mandatory: events of kind `page` /
32
+ * `identify` fall back to `track(event)` when the dedicated method is
33
+ * absent (the event carries its `kind`). `flush` is called after each
34
+ * delivered batch and on page teardown — batching providers (like the
35
+ * Stapel collector) send there. A rejected/thrown delivery marks the whole
36
+ * batch as undelivered for this provider; it will be retried.
37
+ */
38
+ export interface AnalyticsProvider {
39
+ track(event: AnalyticsEvent): void | Promise<void>;
40
+ identify?(
41
+ userHash: string,
42
+ traits?: Record<string, unknown>
43
+ ): void | Promise<void>;
44
+ page?(name: string, props?: Record<string, unknown>): void | Promise<void>;
45
+ flush?(): void | Promise<void>;
46
+ }
47
+
48
+ export interface AnalyticsBatchOptions {
49
+ /** Queue length that triggers an automatic flush. Default 20. */
50
+ readonly maxSize?: number;
51
+ /** Periodic flush interval, ms. Default 10000. */
52
+ readonly flushIntervalMs?: number;
53
+ /** Delivery attempts per batch before it is dropped. Default 5. */
54
+ readonly maxAttempts?: number;
55
+ /** Base of the exponential retry backoff, ms. Default 500. */
56
+ readonly backoffBaseMs?: number;
57
+ }
58
+
59
+ export interface AnalyticsOptions {
60
+ /** Initial named providers (more via `register`/`unregister`). */
61
+ readonly providers?: Record<string, AnalyticsProvider>;
62
+ /**
63
+ * Event registry (analytics-standard §1.1): `track` with a name outside
64
+ * it logs a dev console.warn but still delivers — the hard gate is the
65
+ * eslint rule against the project's events.json.
66
+ */
67
+ readonly registry?: readonly string[];
68
+ /**
69
+ * Initial consent when none has been persisted yet. Default `"pending"`.
70
+ * A state persisted by `setConsent` takes precedence on recreation.
71
+ */
72
+ readonly consent?: ConsentState;
73
+ /** PII guard mode for prop/trait values. Default `"strip"`. */
74
+ readonly piiGuard?: PiiGuardMode;
75
+ readonly batch?: AnalyticsBatchOptions;
76
+ /** Storage key prefix for the offline queue + consent. Default `"stapel-analytics"`. */
77
+ readonly persistKey?: string;
78
+ /** Storage override (tests, custom stores). Default: IndexedDB → localStorage → memory. */
79
+ readonly storage?: PersistStorage;
80
+ }
81
+
82
+ export interface Analytics {
83
+ /** Queue a registry event. No-op while consent is `"denied"`. */
84
+ track(event: string, props?: Record<string, unknown>): void;
85
+ /**
86
+ * Queue an identify. The raw `userId` never leaves the facade: providers
87
+ * see its SHA-256 hex. Traits pass the PII guard.
88
+ */
89
+ identify(userId: string, traits?: Record<string, unknown>): void;
90
+ /** Queue a page view. */
91
+ page(name: string, props?: Record<string, unknown>): void;
92
+ /** Deliver everything queued (one delivery attempt per pending batch). */
93
+ flush(): Promise<void>;
94
+ /**
95
+ * Consent gate (analytics-standard §1.4): `"pending"` buffers,
96
+ * `"granted"` flushes the buffer, `"denied"` drops it (memory +
97
+ * persisted) and turns subsequent calls into no-ops. Persisted.
98
+ */
99
+ setConsent(state: ConsentState): Promise<void>;
100
+ /** Current consent state (after async restore; see `flush`). */
101
+ getConsent(): ConsentState;
102
+ /** Register a provider at runtime (merge semantics). */
103
+ register(name: string, provider: AnalyticsProvider): void;
104
+ unregister(name: string): void;
105
+ }