@mfeflow/analytics 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanjeev Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @mfeflow/analytics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Analytics for micro-frontends behind one provider interface. The shell starts it once; remotes only send events. Ships a Google Tag Manager provider with Consent Mode v2, a typed event map each remote extends, a tracker that stamps `mfe_name` and `mfe_version`, and React bindings.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @mfeflow/analytics
9
+ ```
10
+
11
+ `react` (19) is an optional peer dependency, needed only for `@mfeflow/analytics/react`. In a project made with mfeflow, `mfeflow add analytics-gtm` does all of this.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ // shell, once
17
+ import { initAnalytics } from '@mfeflow/analytics';
18
+ import { createGtmProvider } from '@mfeflow/analytics/gtm';
19
+
20
+ initAnalytics(createGtmProvider({ containerId: 'GTM-ABC123' }));
21
+ ```
22
+
23
+ ```ts
24
+ // a remote
25
+ import { createTracker } from '@mfeflow/analytics';
26
+
27
+ declare module '@mfeflow/analytics' {
28
+ interface AnalyticsEvents {
29
+ add_to_cart: { sku: string };
30
+ }
31
+ }
32
+
33
+ export const tracker = createTracker('cart', { events: ['add_to_cart'] });
34
+ tracker.track('add_to_cart', { sku: 'A1' });
35
+ ```
36
+
37
+ The GTM provider pushes the four consent signals as `denied` to `dataLayer` before it requests `gtm.js`, and drops parameters that look like personal data.
38
+
39
+ ## Module Federation sharing
40
+
41
+ Bucket: **singleton**. Share `@mfeflow/analytics` and `@mfeflow/analytics/react` (`@mfeflow/rsbuild` does). State also lives on `globalThis`, so a second copy still talks to the one provider. `@mfeflow/analytics/gtm` is imported by the shell only.
42
+
43
+ ## Links
44
+
45
+ - [mfeflow repository](https://github.com/mfeflow/mfeflow) and its README
46
+ - [Documentation](https://mfeflow.github.io/mfeflow/)
47
+
48
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ import { type ConsentUpdate } from './types.js';
2
+ export declare const CONSENT_STORAGE_KEY = "mfeflow:consent";
3
+ export declare const GRANT_ALL: ConsentUpdate;
4
+ export declare const DENY_ALL: ConsentUpdate;
5
+ /** Keeps only the known signals with a valid value; anything else in stored or passed-in data is dropped. */
6
+ export declare function cleanConsent(input: unknown): ConsentUpdate;
7
+ export declare function readStoredConsent(): ConsentUpdate;
8
+ export declare function writeStoredConsent(consent: ConsentUpdate): void;
@@ -0,0 +1,38 @@
1
+ import { CONSENT_KEYS } from "./types.js";
2
+ const CONSENT_STORAGE_KEY = 'mfeflow:consent';
3
+ const GRANT_ALL = {
4
+ ad_storage: 'granted',
5
+ analytics_storage: 'granted',
6
+ ad_user_data: 'granted',
7
+ ad_personalization: 'granted'
8
+ };
9
+ const DENY_ALL = {
10
+ ad_storage: 'denied',
11
+ analytics_storage: 'denied',
12
+ ad_user_data: 'denied',
13
+ ad_personalization: 'denied'
14
+ };
15
+ const isValue = (value)=>'granted' === value || 'denied' === value;
16
+ function cleanConsent(input) {
17
+ const out = {};
18
+ if ('object' != typeof input || null === input) return out;
19
+ for (const key of CONSENT_KEYS){
20
+ const value = Reflect.get(input, key);
21
+ if (isValue(value)) out[key] = value;
22
+ }
23
+ return out;
24
+ }
25
+ function readStoredConsent() {
26
+ try {
27
+ const raw = localStorage.getItem(CONSENT_STORAGE_KEY);
28
+ return null === raw ? {} : cleanConsent(JSON.parse(raw));
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+ function writeStoredConsent(consent) {
34
+ try {
35
+ localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(consent));
36
+ } catch {}
37
+ }
38
+ export { CONSENT_STORAGE_KEY, DENY_ALL, GRANT_ALL, cleanConsent, readStoredConsent, writeStoredConsent };
package/dist/core.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { AnalyticsProvider, ConsentUpdate, EventParams } from './types.js';
2
+ /**
3
+ * Starts analytics with a provider. Only the shell calls this. The first call wins; later calls
4
+ * (a second mount, StrictMode, a remote running on its own) do nothing and return false.
5
+ */
6
+ export declare function initAnalytics(provider: AnalyticsProvider): boolean;
7
+ export declare function isAnalyticsReady(): boolean;
8
+ export declare function providerName(): string;
9
+ export declare function track(event: string, params?: EventParams): void;
10
+ export declare function page(path: string, params?: EventParams): void;
11
+ /** Like `page`, but skips a path that was just sent: navigation and StrictMode can ask twice. */
12
+ export declare function pageOnce(path: string, params?: EventParams): void;
13
+ export declare function identify(userId: string, traits?: EventParams): void;
14
+ /** The visitor's consent choice so far. A new object whenever it changes. */
15
+ export declare function getConsent(): ConsentUpdate;
16
+ /** Whether the visitor has made a choice, on this visit or an earlier one. */
17
+ export declare function hasConsentChoice(): boolean;
18
+ export declare function subscribeConsent(listener: () => void): () => void;
19
+ /** Records the visitor's choice, remembers it for next time and tells the provider. */
20
+ export declare function updateConsent(update: ConsentUpdate): void;
21
+ /** Only for tests: forgets the provider, the queue and the stored choice. */
22
+ export declare function resetAnalyticsForTests(): void;
package/dist/core.js ADDED
@@ -0,0 +1,96 @@
1
+ import { CONSENT_STORAGE_KEY, cleanConsent, readStoredConsent, writeStoredConsent } from "./consent.js";
2
+ import { noopProvider } from "./noop.js";
3
+ const KEY = Symbol.for('mfeflow.analytics');
4
+ const QUEUE_LIMIT = 100;
5
+ function isState(value) {
6
+ return 'object' == typeof value && null !== value && 'queue' in value && 'listeners' in value;
7
+ }
8
+ function state() {
9
+ const existing = Reflect.get(globalThis, KEY);
10
+ if (isState(existing)) return existing;
11
+ const fresh = {
12
+ provider: void 0,
13
+ queue: [],
14
+ consent: readStoredConsent(),
15
+ listeners: new Set(),
16
+ lastPage: void 0
17
+ };
18
+ Reflect.set(globalThis, KEY, fresh);
19
+ return fresh;
20
+ }
21
+ function send(call) {
22
+ const s = state();
23
+ if (s.provider) call(s.provider);
24
+ else if (s.queue.length < QUEUE_LIMIT) s.queue.push(call);
25
+ }
26
+ function initAnalytics(provider) {
27
+ const s = state();
28
+ if (s.provider) return false;
29
+ s.provider = provider;
30
+ provider.init({
31
+ ...s.consent
32
+ });
33
+ const queued = s.queue.splice(0);
34
+ for (const call of queued)call(provider);
35
+ return true;
36
+ }
37
+ function isAnalyticsReady() {
38
+ return void 0 !== state().provider;
39
+ }
40
+ function providerName() {
41
+ return state().provider?.name ?? noopProvider.name;
42
+ }
43
+ function track(event, params = {}) {
44
+ send((p)=>{
45
+ p.track(event, params);
46
+ });
47
+ }
48
+ function page(path, params = {}) {
49
+ send((p)=>{
50
+ p.page(path, params);
51
+ });
52
+ }
53
+ function pageOnce(path, params = {}) {
54
+ const s = state();
55
+ if (s.lastPage === path) return;
56
+ s.lastPage = path;
57
+ page(path, params);
58
+ }
59
+ function identify(userId, traits = {}) {
60
+ send((p)=>{
61
+ p.identify(userId, traits);
62
+ });
63
+ }
64
+ function getConsent() {
65
+ return state().consent;
66
+ }
67
+ function hasConsentChoice() {
68
+ return Object.keys(state().consent).length > 0;
69
+ }
70
+ function subscribeConsent(listener) {
71
+ const { listeners } = state();
72
+ listeners.add(listener);
73
+ return ()=>{
74
+ listeners.delete(listener);
75
+ };
76
+ }
77
+ function updateConsent(update) {
78
+ const s = state();
79
+ const clean = cleanConsent(update);
80
+ s.consent = {
81
+ ...s.consent,
82
+ ...clean
83
+ };
84
+ writeStoredConsent(s.consent);
85
+ send((p)=>{
86
+ p.setConsent(clean);
87
+ });
88
+ for (const listener of s.listeners)listener();
89
+ }
90
+ function resetAnalyticsForTests() {
91
+ Reflect.deleteProperty(globalThis, KEY);
92
+ try {
93
+ localStorage.removeItem(CONSENT_STORAGE_KEY);
94
+ } catch {}
95
+ }
96
+ export { getConsent, hasConsentChoice, identify, initAnalytics, isAnalyticsReady, page, pageOnce, providerName, resetAnalyticsForTests, subscribeConsent, track, updateConsent };
package/dist/gtm.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type AnalyticsProvider, type ConsentUpdate } from './types.js';
2
+ import { scrubPii } from './pii.js';
3
+ export { scrubPii };
4
+ export interface GtmOptions {
5
+ /** The container, e.g. `GTM-ABC123`. From the runtime config, never baked into the bundle. */
6
+ containerId: string;
7
+ /** Default `dataLayer`. */
8
+ dataLayerName?: string | undefined;
9
+ /** Default Google's `gtm.js`. Tests and server-side containers point it elsewhere. */
10
+ scriptUrl?: string | undefined;
11
+ /** Consent before the visitor chooses. Default: every signal `denied`. */
12
+ consentDefaults?: ConsentUpdate | undefined;
13
+ /** How long tags wait for a consent update, in milliseconds. Default 500. */
14
+ waitForUpdate?: number | undefined;
15
+ /** Send `user_id` on `identify`. Off by default; traits are never sent. */
16
+ sendUserId?: boolean | undefined;
17
+ /** Send parameters that look like personal data. Off by default: they are dropped. */
18
+ allowPii?: boolean | undefined;
19
+ }
20
+ export declare const GTM_SCRIPT_URL = "https://www.googletagmanager.com/gtm.js";
21
+ export declare const GTM_SCRIPT_ATTRIBUTE = "data-mfeflow-gtm";
22
+ /** The longest a `page_view` waits for the new route to set `document.title`, in milliseconds. */
23
+ export declare const TITLE_WAIT_MS = 300;
24
+ /**
25
+ * Google Tag Manager with Consent Mode v2. `init` pushes the consent defaults to the data layer
26
+ * before it adds the `gtm.js` script, so tags never run ahead of the visitor's choice. A second
27
+ * `init` for the same container (a second provider, StrictMode) finds the script tag and changes nothing.
28
+ */
29
+ export declare function createGtmProvider(options: GtmOptions): AnalyticsProvider;
package/dist/gtm.js ADDED
@@ -0,0 +1,101 @@
1
+ import { CONSENT_KEYS } from "./types.js";
2
+ import { scrubPii } from "./pii.js";
3
+ import { warnOnce } from "./validate.js";
4
+ const CONTAINER_ID = /^GTM-[A-Z0-9]{4,12}$/;
5
+ const GTM_SCRIPT_URL = 'https://www.googletagmanager.com/gtm.js';
6
+ const GTM_SCRIPT_ATTRIBUTE = 'data-mfeflow-gtm';
7
+ const TITLE_WAIT_MS = 300;
8
+ function settledTitle(send) {
9
+ const before = document.title;
10
+ let observer;
11
+ let timer;
12
+ const finish = ()=>{
13
+ observer?.disconnect();
14
+ clearTimeout(timer);
15
+ send(document.title);
16
+ };
17
+ if ('function' == typeof MutationObserver) {
18
+ observer = new MutationObserver(()=>{
19
+ if (document.title !== before) finish();
20
+ });
21
+ observer.observe(document.head, {
22
+ childList: true,
23
+ subtree: true,
24
+ characterData: true
25
+ });
26
+ }
27
+ timer = setTimeout(finish, TITLE_WAIT_MS);
28
+ }
29
+ function layerOf(name) {
30
+ const existing = Reflect.get(globalThis, name);
31
+ if (Array.isArray(existing)) return existing;
32
+ const fresh = [];
33
+ Reflect.set(globalThis, name, fresh);
34
+ return fresh;
35
+ }
36
+ function createGtmProvider(options) {
37
+ const { containerId } = options;
38
+ if (!CONTAINER_ID.test(containerId)) throw new Error(`"${containerId}" is not a Tag Manager container id (expected GTM-XXXXXXX)`);
39
+ const layerName = options.dataLayerName ?? 'dataLayer';
40
+ const layer = ()=>layerOf(layerName);
41
+ function gtag() {
42
+ layer().push(arguments);
43
+ }
44
+ const defaults = {};
45
+ for (const key of CONSENT_KEYS)defaults[key] = options.consentDefaults?.[key] ?? 'denied';
46
+ defaults['wait_for_update'] = options.waitForUpdate ?? 500;
47
+ return {
48
+ name: 'gtm',
49
+ init (consent) {
50
+ const selector = `script[${GTM_SCRIPT_ATTRIBUTE}="${containerId}"]`;
51
+ if (document.querySelector(selector)) return;
52
+ gtag('consent', 'default', defaults);
53
+ if (Object.keys(consent).length > 0) gtag('consent', 'update', consent);
54
+ layer().push({
55
+ 'gtm.start': Date.now(),
56
+ event: 'gtm.js'
57
+ });
58
+ const script = document.createElement("script");
59
+ script.async = true;
60
+ script.setAttribute(GTM_SCRIPT_ATTRIBUTE, containerId);
61
+ const url = new URL(options.scriptUrl ?? GTM_SCRIPT_URL, location.href);
62
+ url.searchParams.set('id', containerId);
63
+ if ('dataLayer' !== layerName) url.searchParams.set('l', layerName);
64
+ script.src = url.href;
65
+ document.head.append(script);
66
+ },
67
+ track (event, params) {
68
+ const { clean, dropped } = true === options.allowPii ? {
69
+ clean: params,
70
+ dropped: []
71
+ } : scrubPii(params);
72
+ if (dropped.length > 0) warnOnce(`"${event}": dropped ${dropped.join(', ')} because it looks like personal data`);
73
+ layer().push({
74
+ ...clean,
75
+ event
76
+ });
77
+ },
78
+ page (path, params) {
79
+ const pageLocation = location.origin + path;
80
+ settledTitle((title)=>{
81
+ layer().push({
82
+ ...params,
83
+ event: 'page_view',
84
+ page_path: path,
85
+ page_location: pageLocation,
86
+ page_title: title
87
+ });
88
+ });
89
+ },
90
+ identify (userId) {
91
+ if (true === options.sendUserId) layer().push({
92
+ event: 'identify',
93
+ user_id: userId
94
+ });
95
+ },
96
+ setConsent (update) {
97
+ if (Object.keys(update).length > 0) gtag('consent', 'update', update);
98
+ }
99
+ };
100
+ }
101
+ export { GTM_SCRIPT_ATTRIBUTE, GTM_SCRIPT_URL, TITLE_WAIT_MS, createGtmProvider, scrubPii };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Calls `listener` with the new path whenever the page navigates without a reload: `pushState`,
3
+ * `replaceState`, back/forward and hash changes. The path carries the query string and a hash
4
+ * route, with personal data removed (see `currentPath`). Works with any router. The first call
5
+ * patches `history` once.
6
+ */
7
+ export declare function onNavigate(listener: (path: string) => void): () => void;
@@ -0,0 +1,32 @@
1
+ import { currentPath } from "./url.js";
2
+ const EVENT = 'mfeflow:navigate';
3
+ const PATCHED = Symbol.for('mfeflow.history-patched');
4
+ function patchHistory() {
5
+ if (true === Reflect.get(history, PATCHED)) return;
6
+ Reflect.set(history, PATCHED, true);
7
+ for (const method of [
8
+ 'pushState',
9
+ 'replaceState'
10
+ ]){
11
+ const original = history[method].bind(history);
12
+ history[method] = (...args)=>{
13
+ original(...args);
14
+ dispatchEvent(new Event(EVENT));
15
+ };
16
+ }
17
+ }
18
+ function onNavigate(listener) {
19
+ patchHistory();
20
+ const handle = ()=>{
21
+ listener(currentPath());
22
+ };
23
+ addEventListener(EVENT, handle);
24
+ addEventListener('popstate', handle);
25
+ addEventListener('hashchange', handle);
26
+ return ()=>{
27
+ removeEventListener(EVENT, handle);
28
+ removeEventListener('popstate', handle);
29
+ removeEventListener('hashchange', handle);
30
+ };
31
+ }
32
+ export { onNavigate };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Every event the platform knows, with its parameters. Empty here: each remote adds its own
3
+ * by declaration merging, so `tracker.track('add_to_cart', { sku })` is checked end to end.
4
+ *
5
+ * declare module '@mfeflow/analytics' {
6
+ * interface AnalyticsEvents {
7
+ * add_to_cart: { sku: string };
8
+ * cart_viewed: undefined;
9
+ * }
10
+ * }
11
+ */
12
+ export interface AnalyticsEvents {
13
+ }
14
+ export { getConsent, hasConsentChoice, identify, initAnalytics, isAnalyticsReady, page, pageOnce, providerName, resetAnalyticsForTests, subscribeConsent, track, updateConsent, } from './core.js';
15
+ export { CONSENT_STORAGE_KEY, DENY_ALL, GRANT_ALL, cleanConsent } from './consent.js';
16
+ export { createRecordingProvider, noopProvider, type RecordedCall } from './noop.js';
17
+ export { createTracker, type EventName, type ParamsArg, type Tracker, type TrackerOptions, } from './tracker.js';
18
+ export { CONSENT_KEYS, type AnalyticsProvider, type ConsentKey, type ConsentUpdate, type ConsentValue, type EventParams, } from './types.js';
19
+ export { resetWarningsForTests } from './validate.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { getConsent, hasConsentChoice, identify, initAnalytics, isAnalyticsReady, page, pageOnce, providerName, resetAnalyticsForTests, subscribeConsent, track, updateConsent } from "./core.js";
2
+ export { CONSENT_STORAGE_KEY, DENY_ALL, GRANT_ALL, cleanConsent } from "./consent.js";
3
+ export { createRecordingProvider, noopProvider } from "./noop.js";
4
+ export { createTracker } from "./tracker.js";
5
+ export { CONSENT_KEYS } from "./types.js";
6
+ export { resetWarningsForTests } from "./validate.js";
package/dist/noop.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { AnalyticsProvider, ConsentUpdate, EventParams } from './types.js';
2
+ export declare const noopProvider: AnalyticsProvider;
3
+ export type RecordedCall = {
4
+ type: 'init';
5
+ consent: ConsentUpdate;
6
+ } | {
7
+ type: 'track';
8
+ event: string;
9
+ params: EventParams;
10
+ } | {
11
+ type: 'page';
12
+ path: string;
13
+ params: EventParams;
14
+ } | {
15
+ type: 'identify';
16
+ userId: string;
17
+ traits: EventParams;
18
+ } | {
19
+ type: 'consent';
20
+ update: ConsentUpdate;
21
+ };
22
+ /** A provider for tests: it sends nothing and remembers every call in order. */
23
+ export declare function createRecordingProvider(): AnalyticsProvider & {
24
+ calls: RecordedCall[];
25
+ };
package/dist/noop.js ADDED
@@ -0,0 +1,49 @@
1
+ const noopProvider = {
2
+ name: 'noop',
3
+ init () {},
4
+ track () {},
5
+ page () {},
6
+ identify () {},
7
+ setConsent () {}
8
+ };
9
+ function createRecordingProvider() {
10
+ const calls = [];
11
+ return {
12
+ name: 'recording',
13
+ calls,
14
+ init (consent) {
15
+ calls.push({
16
+ type: 'init',
17
+ consent
18
+ });
19
+ },
20
+ track (event, params) {
21
+ calls.push({
22
+ type: 'track',
23
+ event,
24
+ params
25
+ });
26
+ },
27
+ page (path, params) {
28
+ calls.push({
29
+ type: 'page',
30
+ path,
31
+ params
32
+ });
33
+ },
34
+ identify (userId, traits) {
35
+ calls.push({
36
+ type: 'identify',
37
+ userId,
38
+ traits
39
+ });
40
+ },
41
+ setConsent (update) {
42
+ calls.push({
43
+ type: 'consent',
44
+ update
45
+ });
46
+ }
47
+ };
48
+ }
49
+ export { createRecordingProvider, noopProvider };
package/dist/pii.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { EventParams } from './types.js';
2
+ /** Drops personal data from event parameters, and says what it dropped. */
3
+ export declare function scrubPii(params: EventParams): {
4
+ clean: EventParams;
5
+ dropped: string[];
6
+ };
package/dist/pii.js ADDED
@@ -0,0 +1,13 @@
1
+ const PII_KEY = /^(e-?mail|phone|mobile|first_?name|last_?name|full_?name|name|address|ssn|dob|birth_?date|password|token)$/i;
2
+ const EMAIL = /[^\s@]+@[^\s@]+\.[^\s@]+/;
3
+ function scrubPii(params) {
4
+ const clean = {};
5
+ const dropped = [];
6
+ for (const [key, value] of Object.entries(params))if (PII_KEY.test(key) || 'string' == typeof value && EMAIL.test(value)) dropped.push(key);
7
+ else clean[key] = value;
8
+ return {
9
+ clean,
10
+ dropped
11
+ };
12
+ }
13
+ export { scrubPii };
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type EventName, type ParamsArg, type Tracker, type TrackerOptions } from './tracker.js';
3
+ import type { ConsentUpdate } from './types.js';
4
+ export interface AnalyticsProviderProps {
5
+ children: ReactNode;
6
+ /** Send a `page_view` for the first page and for every navigation. Default true. */
7
+ pageViews?: boolean;
8
+ }
9
+ /**
10
+ * The shell wraps its app in this once. It sends `page_view` on route changes, once per path
11
+ * even when React runs effects twice, and lets `useTracker` tell a missing shell from a ready one.
12
+ */
13
+ export declare function AnalyticsProvider({ children, pageViews }: AnalyticsProviderProps): import("react").JSX.Element;
14
+ /** The tracker for a remote. The same tracker comes back on every render. */
15
+ export declare function useTracker(mfeName: string, options?: TrackerOptions): Tracker;
16
+ /** Sends an event once when the component mounts. Safe under StrictMode, which runs effects twice. */
17
+ export declare function useTrackOnMount<E extends EventName>(tracker: Tracker, event: E, ...params: ParamsArg<E>): void;
18
+ export interface ConsentApi {
19
+ consent: ConsentUpdate;
20
+ /** Whether the visitor has chosen yet. Show the banner while this is false. */
21
+ decided: boolean;
22
+ update: (update: ConsentUpdate) => void;
23
+ acceptAll: () => void;
24
+ rejectAll: () => void;
25
+ }
26
+ /** Headless consent state for your own banner. */
27
+ export declare function useConsent(): ConsentApi;
package/dist/react.js ADDED
@@ -0,0 +1,63 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
+ import { DENY_ALL, GRANT_ALL } from "./consent.js";
4
+ import { getConsent, pageOnce, subscribeConsent, updateConsent } from "./core.js";
5
+ import { onNavigate } from "./history.js";
6
+ import { currentPath } from "./url.js";
7
+ import { createTracker } from "./tracker.js";
8
+ import { warnOnce } from "./validate.js";
9
+ const Mounted = /*#__PURE__*/ createContext(false);
10
+ function sendPageView(path) {
11
+ pageOnce(path, {
12
+ mfe_name: 'shell',
13
+ mfe_version: null
14
+ });
15
+ }
16
+ function AnalyticsProvider({ children, pageViews = true }) {
17
+ useEffect(()=>{
18
+ if (!pageViews) return;
19
+ sendPageView(currentPath());
20
+ return onNavigate(sendPageView);
21
+ }, [
22
+ pageViews
23
+ ]);
24
+ return /*#__PURE__*/ jsx(Mounted, {
25
+ value: true,
26
+ children: children
27
+ });
28
+ }
29
+ function useTracker(mfeName, options) {
30
+ const mounted = useContext(Mounted);
31
+ useEffect(()=>{
32
+ if (!mounted) warnOnce(`${mfeName}: no <AnalyticsProvider> above this component. Running outside the shell? Events wait for analytics to start and are dropped if it never does.`);
33
+ }, [
34
+ mounted,
35
+ mfeName
36
+ ]);
37
+ return useMemo(()=>createTracker(mfeName, options), [
38
+ mfeName
39
+ ]);
40
+ }
41
+ function useTrackOnMount(tracker, event, ...params) {
42
+ const sent = useRef(false);
43
+ useEffect(()=>{
44
+ if (sent.current) return;
45
+ sent.current = true;
46
+ tracker.track(event, ...params);
47
+ }, []);
48
+ }
49
+ function useConsent() {
50
+ const consent = useSyncExternalStore(subscribeConsent, getConsent, getConsent);
51
+ return {
52
+ consent,
53
+ decided: Object.keys(consent).length > 0,
54
+ update: updateConsent,
55
+ acceptAll: ()=>{
56
+ updateConsent(GRANT_ALL);
57
+ },
58
+ rejectAll: ()=>{
59
+ updateConsent(DENY_ALL);
60
+ }
61
+ };
62
+ }
63
+ export { AnalyticsProvider, useConsent, useTrackOnMount, useTracker };
@@ -0,0 +1,22 @@
1
+ import type { AnalyticsEvents } from './index.js';
2
+ export type EventName = Extract<keyof AnalyticsEvents, string>;
3
+ /** Events that take no parameters are called as `track('name')`; the rest as `track('name', { … })`. */
4
+ export type ParamsArg<E extends EventName> = AnalyticsEvents[E] extends undefined | void ? [params?: undefined] : [params: AnalyticsEvents[E]];
5
+ export interface TrackerOptions {
6
+ /** Version of the remote, sent as `mfe_version`. */
7
+ mfeVersion?: string | undefined;
8
+ /** Every event this remote sends. In development, any other name logs a warning once. */
9
+ events?: readonly EventName[] | undefined;
10
+ }
11
+ export interface Tracker {
12
+ /** The micro-frontend this tracker belongs to. */
13
+ readonly mfe: string;
14
+ track<E extends EventName>(event: E, ...params: ParamsArg<E>): void;
15
+ page(path: string): void;
16
+ }
17
+ /**
18
+ * A tracker for one micro-frontend. Every event it sends carries `mfe_name` and `mfe_version`
19
+ * (`null` when unknown). They are flat and always both set: the data layer merges nested objects,
20
+ * and an object or a missing key would leave the previous remote's value on this remote's event.
21
+ */
22
+ export declare function createTracker(mfeName: string, options?: TrackerOptions): Tracker;
@@ -0,0 +1,26 @@
1
+ import { page, track } from "./core.js";
2
+ import { eventProblem, isDev, warnOnce } from "./validate.js";
3
+ const isParams = (value)=>'object' == typeof value && null !== value;
4
+ function createTracker(mfeName, options = {}) {
5
+ const known = options.events ? new Set(options.events) : void 0;
6
+ const stamp = (params)=>({
7
+ ...params,
8
+ mfe_name: mfeName,
9
+ mfe_version: options.mfeVersion ?? null
10
+ });
11
+ return {
12
+ mfe: mfeName,
13
+ track (event, ...rest) {
14
+ if (isDev()) {
15
+ const problem = eventProblem(mfeName, event, known);
16
+ if (problem) warnOnce(problem);
17
+ }
18
+ const params = rest[0];
19
+ track(event, stamp(isParams(params) ? params : void 0));
20
+ },
21
+ page (path) {
22
+ page(path, stamp());
23
+ }
24
+ };
25
+ }
26
+ export { createTracker };
@@ -0,0 +1,15 @@
1
+ export type EventParams = Record<string, unknown>;
2
+ /** The four Consent Mode v2 signals. */
3
+ export type ConsentKey = 'ad_storage' | 'analytics_storage' | 'ad_user_data' | 'ad_personalization';
4
+ export type ConsentValue = 'granted' | 'denied';
5
+ export type ConsentUpdate = Partial<Record<ConsentKey, ConsentValue>>;
6
+ export declare const CONSENT_KEYS: readonly ConsentKey[];
7
+ export interface AnalyticsProvider {
8
+ readonly name: string;
9
+ /** Called once. `consent` is the choice the visitor made on an earlier visit, if any. */
10
+ init(consent: ConsentUpdate): void;
11
+ track(event: string, params: EventParams): void;
12
+ page(path: string, params: EventParams): void;
13
+ identify(userId: string, traits: EventParams): void;
14
+ setConsent(update: ConsentUpdate): void;
15
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ const CONSENT_KEYS = [
2
+ 'ad_storage',
3
+ 'analytics_storage',
4
+ 'ad_user_data',
5
+ 'ad_personalization'
6
+ ];
7
+ export { CONSENT_KEYS };
package/dist/url.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** A query string with personal data and secrets removed. Returns `''` or a string starting with `?`. */
2
+ export declare function scrubQuery(query: string): string;
3
+ /**
4
+ * Where the visitor is, as a path for `page_view`: the pathname, the query without personal data,
5
+ * and for hash routers (`/#/catalog?x=1`) the hash route the same way. Never anything else of the hash.
6
+ */
7
+ export declare function currentPath(): string;
package/dist/url.js ADDED
@@ -0,0 +1,34 @@
1
+ import { scrubPii } from "./pii.js";
2
+ const KEEP = /^(utm_[a-z_]+|gclid|fbclid)$/i;
3
+ const SECRET = /token|secret|passw(or)?d|auth|session|api_?key|^key$|^code$/i;
4
+ function scrubQuery(query) {
5
+ const kept = new URLSearchParams();
6
+ for (const [key, value] of new URLSearchParams(query)){
7
+ const pii = scrubPii({
8
+ [key]: value
9
+ }).dropped.length > 0;
10
+ if (KEEP.test(key) ? !pii : !(pii || SECRET.test(key))) kept.append(key, value);
11
+ }
12
+ const text = kept.toString();
13
+ return '' === text ? '' : `?${text}`;
14
+ }
15
+ const split = (text)=>{
16
+ const at = text.indexOf('?');
17
+ return -1 === at ? [
18
+ text,
19
+ ''
20
+ ] : [
21
+ text.slice(0, at),
22
+ text.slice(at)
23
+ ];
24
+ };
25
+ function currentPath() {
26
+ const { pathname, search, hash } = location;
27
+ let path = pathname + scrubQuery(search);
28
+ if (hash.startsWith('#/')) {
29
+ const [route, query] = split(hash.slice(1));
30
+ path += `#${route}${scrubQuery(query)}`;
31
+ }
32
+ return path;
33
+ }
34
+ export { currentPath, scrubQuery };
@@ -0,0 +1,7 @@
1
+ /** Dev builds warn about mistakes; production builds stay quiet. Bundlers replace `process.env.NODE_ENV`. */
2
+ export declare const isDev: () => boolean;
3
+ export declare function warnOnce(message: string): void;
4
+ /** What is wrong with an event, or undefined. `known` is the tracker's declared list, if it has one. */
5
+ export declare function eventProblem(mfe: string, event: string, known: ReadonlySet<string> | undefined): string | undefined;
6
+ /** Only for tests. */
7
+ export declare function resetWarningsForTests(): void;
@@ -0,0 +1,18 @@
1
+ const EVENT_NAME = /^[a-z][a-z0-9_]{0,39}$/i;
2
+ const warned = new Set();
3
+ const isDev = ()=>'production' !== process.env.NODE_ENV;
4
+ function warnOnce(message) {
5
+ if (!isDev() || warned.has(message)) return;
6
+ warned.add(message);
7
+ console.warn(`[mfeflow/analytics] ${message}`);
8
+ }
9
+ function eventProblem(mfe, event, known) {
10
+ if (!EVENT_NAME.test(event)) return `${mfe}: "${event}" is not a valid event name (letters, digits and underscores, starting with a letter, at most 40 characters)`;
11
+ if (known && !known.has(event)) return `${mfe}: "${event}" is not one of the events this tracker declares (${[
12
+ ...known
13
+ ].join(', ') || 'none'}); add it to analytics.ts`;
14
+ }
15
+ function resetWarningsForTests() {
16
+ warned.clear();
17
+ }
18
+ export { eventProblem, isDev, resetWarningsForTests, warnOnce };
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "@mfeflow/analytics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Analytics for micro-frontends behind one provider interface: typed events, a tracker that stamps mfe_name, Consent Mode v2, and a Google Tag Manager provider.",
5
+ "keywords": [
6
+ "micro-frontend",
7
+ "mfe",
8
+ "module-federation",
9
+ "analytics",
10
+ "google-tag-manager",
11
+ "consent-mode"
12
+ ],
13
+ "homepage": "https://mfeflow.github.io/mfeflow/",
14
+ "bugs": "https://github.com/mfeflow/mfeflow/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/mfeflow/mfeflow.git",
18
+ "directory": "packages/analytics"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Sanjeev Sharma <speedsharma.ai@gmail.com>",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./gtm": {
31
+ "types": "./dist/gtm.d.ts",
32
+ "import": "./dist/gtm.js",
33
+ "default": "./dist/gtm.js"
34
+ },
35
+ "./react": {
36
+ "types": "./dist/react.d.ts",
37
+ "import": "./dist/react.js",
38
+ "default": "./dist/react.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ],
44
+ "engines": {
45
+ "node": ">=22.12"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "provenance": true
50
+ },
51
+ "peerDependencies": {
52
+ "react": "^19.0.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "react": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@rsbuild/plugin-react": "2.1.1",
61
+ "@rslib/core": "1.0.5",
62
+ "@testing-library/dom": "10.4.2",
63
+ "@testing-library/react": "16.3.3",
64
+ "@types/node": "26.6.6",
65
+ "@types/react": "19.3.0",
66
+ "@types/react-dom": "19.3.0",
67
+ "jsdom": "30.1.3",
68
+ "react": "19.3.1",
69
+ "react-dom": "19.3.1",
70
+ "typescript": "7.0.2"
71
+ },
72
+ "mfeflow": {
73
+ "bucket": "singleton"
74
+ },
75
+ "scripts": {
76
+ "build": "rslib build",
77
+ "typecheck": "tsc --noEmit"
78
+ }
6
79
  }