@mushi-mushi/react-native 0.21.0 → 0.22.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
@@ -182,4 +182,4 @@ MIT
182
182
  <!-- mushi-readme-stats-footer -->
183
183
  ---
184
184
 
185
- <sub>Monorepo scale (July 2026): 51 edge functions · 323 SQL migrations · 13 outbound plugins · 11 inbound adapters · 19 pipeline agents. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
185
+ <sub>Monorepo scale (July 2026): 59 edge functions · 374 SQL migrations · 13 outbound plugins · 11 inbound adapters · 19 pipeline agents. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
package/dist/index.cjs CHANGED
@@ -49,7 +49,8 @@ module.exports = __toCommonJS(index_exports);
49
49
 
50
50
  // src/provider.tsx
51
51
  var import_react4 = require("react");
52
- var import_core6 = require("@mushi-mushi/core");
52
+ var import_react_native5 = require("react-native");
53
+ var import_core8 = require("@mushi-mushi/core");
53
54
 
54
55
  // src/capture/console-capture.ts
55
56
  var import_core = require("@mushi-mushi/core");
@@ -1479,13 +1480,336 @@ var s3 = import_react_native4.StyleSheet.create({
1479
1480
  }
1480
1481
  });
1481
1482
 
1483
+ // src/analytics/event-tracker.ts
1484
+ var import_core6 = require("@mushi-mushi/core");
1485
+ var CONSENT_BUFFER_MAX = 50;
1486
+ var SPILL_MAX = 200;
1487
+ var SPILL_TTL_MS = 24 * 60 * 60 * 1e3;
1488
+ var SURFACE = "mobile";
1489
+ async function defaultStorage() {
1490
+ try {
1491
+ const mod = await import("@react-native-async-storage/async-storage");
1492
+ return mod.default;
1493
+ } catch {
1494
+ return null;
1495
+ }
1496
+ }
1497
+ function createRNEventTracker(opts) {
1498
+ const cfg = opts.config ?? {};
1499
+ const enabled = cfg.enabled !== false;
1500
+ const consentMode = cfg.consent ?? "implied";
1501
+ const allowlist = cfg.propertyAllowlist ?? [];
1502
+ const spillKey = `@mushi:events_spill_${opts.projectId}`;
1503
+ const consentKey = `@mushi:analytics_consent_${opts.projectId}`;
1504
+ let buffer = [];
1505
+ let consentBuffer = [];
1506
+ let consent = consentMode === "required" ? "pending" : "granted";
1507
+ let userId = null;
1508
+ let userTraits = null;
1509
+ let pendingIdentify = false;
1510
+ let flushing = false;
1511
+ let destroyed = false;
1512
+ let timer = null;
1513
+ let storage = null;
1514
+ const consentListeners = /* @__PURE__ */ new Set();
1515
+ const loadStorage = opts.storage ?? defaultStorage;
1516
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1517
+ async function readSpill() {
1518
+ if (!storage) return [];
1519
+ try {
1520
+ const raw = await storage.getItem(spillKey);
1521
+ if (!raw) return [];
1522
+ const parsed = JSON.parse(raw);
1523
+ if (!parsed || Date.now() - parsed.at > SPILL_TTL_MS) {
1524
+ await storage.removeItem(spillKey);
1525
+ return [];
1526
+ }
1527
+ return Array.isArray(parsed.events) ? parsed.events.slice(0, SPILL_MAX) : [];
1528
+ } catch {
1529
+ return [];
1530
+ }
1531
+ }
1532
+ async function writeSpill(events) {
1533
+ if (!storage) return;
1534
+ try {
1535
+ if (events.length === 0) await storage.removeItem(spillKey);
1536
+ else await storage.setItem(spillKey, JSON.stringify({ at: Date.now(), events: events.slice(-SPILL_MAX) }));
1537
+ } catch {
1538
+ }
1539
+ }
1540
+ async function flushNow() {
1541
+ if (destroyed && buffer.length === 0) return;
1542
+ await ready;
1543
+ if (flushing || consent !== "granted") return;
1544
+ if (buffer.length === 0 && !pendingIdentify) return;
1545
+ const client = resolveClient();
1546
+ if (!client) return;
1547
+ flushing = true;
1548
+ const batch = buffer.splice(0, import_core6.EVENT_PROPERTY_LIMITS.maxServerBatch);
1549
+ if (pendingIdentify) {
1550
+ batch.unshift({ name: "identify", ts: (/* @__PURE__ */ new Date()).toISOString(), properties: {} });
1551
+ pendingIdentify = false;
1552
+ }
1553
+ try {
1554
+ const anonId = await Promise.resolve(opts.getAnonId());
1555
+ const payload = {
1556
+ anon_id: anonId,
1557
+ user_id: userId ?? opts.getUserId?.() ?? null,
1558
+ user_traits: userTraits ?? opts.getUserTraits?.() ?? null,
1559
+ session_id: null,
1560
+ sdk_version: opts.sdkVersion ?? null,
1561
+ surface: SURFACE,
1562
+ events: batch
1563
+ };
1564
+ const res = await client.postProductEvents(payload);
1565
+ if (!res.ok) await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1566
+ else await writeSpill([]);
1567
+ } catch {
1568
+ await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1569
+ } finally {
1570
+ flushing = false;
1571
+ }
1572
+ if (buffer.length >= import_core6.EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1573
+ }
1574
+ function enqueue(ev) {
1575
+ if (consent === "denied") return;
1576
+ if (consent === "pending") {
1577
+ if (consentBuffer.length < CONSENT_BUFFER_MAX) consentBuffer.push(ev);
1578
+ return;
1579
+ }
1580
+ buffer.push(ev);
1581
+ if (buffer.length >= import_core6.EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1582
+ }
1583
+ const ready = enabled ? (async () => {
1584
+ storage = await loadStorage();
1585
+ if (destroyed) return;
1586
+ let stored = null;
1587
+ try {
1588
+ stored = storage ? await storage.getItem(consentKey) : null;
1589
+ } catch {
1590
+ stored = null;
1591
+ }
1592
+ if (stored === "denied") {
1593
+ consent = "denied";
1594
+ buffer = [];
1595
+ consentBuffer = [];
1596
+ pendingIdentify = false;
1597
+ await writeSpill([]);
1598
+ return;
1599
+ }
1600
+ if (stored === "granted" && consent === "pending") {
1601
+ consent = "granted";
1602
+ buffer.push(...consentBuffer);
1603
+ consentBuffer = [];
1604
+ }
1605
+ const spill = await readSpill();
1606
+ if (spill.length > 0) {
1607
+ buffer.unshift(...spill);
1608
+ await writeSpill([]);
1609
+ }
1610
+ })().catch(() => void 0) : Promise.resolve();
1611
+ if (enabled) {
1612
+ const interval = Math.max(1e3, cfg.flushIntervalMs ?? 5e3);
1613
+ timer = setInterval(() => {
1614
+ void flushNow();
1615
+ }, interval);
1616
+ }
1617
+ return {
1618
+ ready,
1619
+ track(name, properties, trackOpts) {
1620
+ if (!enabled || destroyed) return false;
1621
+ if (!(0, import_core6.isValidEventName)(name)) return false;
1622
+ const { properties: props } = (0, import_core6.sanitizeEventProperties)(properties, { allowlist, scrub: opts.scrub });
1623
+ let merged = props;
1624
+ if (trackOpts?.reserved) {
1625
+ const { properties: res } = (0, import_core6.sanitizeEventProperties)(trackOpts.reserved, { allowReserved: true, scrub: opts.scrub });
1626
+ merged = { ...props, ...res };
1627
+ }
1628
+ merged.$surface = SURFACE;
1629
+ const route = opts.getRoute?.();
1630
+ if (route && merged.$route === void 0) merged.$route = route;
1631
+ if (!(0, import_core6.propertiesWithinByteLimit)(merged)) return false;
1632
+ enqueue({
1633
+ name,
1634
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1635
+ properties: merged,
1636
+ ...trackOpts?.dedupKey ? { dedup_key: trackOpts.dedupKey } : {}
1637
+ });
1638
+ return true;
1639
+ },
1640
+ setConsent(state) {
1641
+ void ready.then(() => storage?.setItem(consentKey, state)).catch(() => void 0);
1642
+ if (state === "denied") {
1643
+ consent = "denied";
1644
+ buffer = [];
1645
+ consentBuffer = [];
1646
+ pendingIdentify = false;
1647
+ void writeSpill([]);
1648
+ } else {
1649
+ const wasPending = consent === "pending";
1650
+ consent = "granted";
1651
+ if (wasPending && consentBuffer.length > 0) {
1652
+ buffer.push(...consentBuffer);
1653
+ consentBuffer = [];
1654
+ }
1655
+ if (buffer.length > 0 || pendingIdentify) void flushNow();
1656
+ }
1657
+ for (const listener of [...consentListeners]) {
1658
+ try {
1659
+ listener(state);
1660
+ } catch {
1661
+ }
1662
+ }
1663
+ },
1664
+ setIdentity(id, traits) {
1665
+ userId = id;
1666
+ userTraits = traits ?? null;
1667
+ if (id && enabled && consent !== "denied") {
1668
+ pendingIdentify = true;
1669
+ void flushNow();
1670
+ }
1671
+ },
1672
+ consentState() {
1673
+ return enabled ? consent : "denied";
1674
+ },
1675
+ onConsentChange(listener) {
1676
+ consentListeners.add(listener);
1677
+ return () => {
1678
+ consentListeners.delete(listener);
1679
+ };
1680
+ },
1681
+ async flush() {
1682
+ await ready;
1683
+ await flushNow();
1684
+ },
1685
+ async destroy() {
1686
+ if (timer != null) {
1687
+ clearInterval(timer);
1688
+ timer = null;
1689
+ }
1690
+ await flushNow().catch(() => void 0);
1691
+ destroyed = true;
1692
+ buffer = [];
1693
+ consentBuffer = [];
1694
+ pendingIdentify = false;
1695
+ }
1696
+ };
1697
+ }
1698
+
1699
+ // src/analytics/session-tracker.ts
1700
+ var import_core7 = require("@mushi-mushi/core");
1701
+ var HEARTBEAT_INTERVAL_MS = 6e4;
1702
+ var RESUME_WINDOW_MS = 30 * 6e4;
1703
+ function createRNSessionTracker(opts) {
1704
+ const clock = opts.now ?? Date.now;
1705
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1706
+ let sessionId = opts.sessionId ?? `sess_${(0, import_core7.newUuid)()}`;
1707
+ let pageViewCount = 0;
1708
+ let started = false;
1709
+ let allowed = false;
1710
+ let foreground = true;
1711
+ let destroyed = false;
1712
+ let backgroundedAt = null;
1713
+ let heartbeat = null;
1714
+ function route() {
1715
+ return opts.getRoute?.() ?? null;
1716
+ }
1717
+ function send(kind, extra) {
1718
+ const client = resolveClient();
1719
+ if (!client || !allowed || destroyed) return;
1720
+ const base = {
1721
+ kind,
1722
+ session_id: sessionId,
1723
+ ts: new Date(clock()).toISOString(),
1724
+ page_view_count: pageViewCount,
1725
+ user_agent: opts.userAgent ?? null,
1726
+ sdk_version: opts.sdkVersion
1727
+ };
1728
+ void Promise.resolve(opts.getReporterToken()).then((token) => client.postSessionEvent({ ...base, reporter_token_hash: token, ...extra })).catch(() => void 0);
1729
+ }
1730
+ function stopHeartbeat() {
1731
+ if (heartbeat != null) {
1732
+ clearInterval(heartbeat);
1733
+ heartbeat = null;
1734
+ }
1735
+ }
1736
+ function startHeartbeat() {
1737
+ stopHeartbeat();
1738
+ heartbeat = setInterval(() => send("session_heartbeat", { route: route() }), HEARTBEAT_INTERVAL_MS);
1739
+ }
1740
+ function startSession() {
1741
+ started = true;
1742
+ pageViewCount = 1;
1743
+ send("session_start", { route: route() });
1744
+ startHeartbeat();
1745
+ }
1746
+ function activate() {
1747
+ if (allowed || destroyed) return;
1748
+ allowed = true;
1749
+ if (!foreground) return;
1750
+ if (!started) startSession();
1751
+ else startHeartbeat();
1752
+ }
1753
+ function deactivate() {
1754
+ allowed = false;
1755
+ stopHeartbeat();
1756
+ }
1757
+ const unsubscribe = opts.consent.onConsentChange(() => {
1758
+ if (opts.consent.consentState() === "granted") activate();
1759
+ else deactivate();
1760
+ });
1761
+ void opts.consent.ready.then(() => {
1762
+ if (opts.consent.consentState() === "granted") activate();
1763
+ }).catch(() => void 0);
1764
+ return {
1765
+ onAppStateChange(state) {
1766
+ if (destroyed) return;
1767
+ if (state === "active") {
1768
+ if (foreground) return;
1769
+ foreground = true;
1770
+ const away = backgroundedAt == null ? 0 : clock() - backgroundedAt;
1771
+ backgroundedAt = null;
1772
+ if (!allowed) return;
1773
+ if (!started || away >= RESUME_WINDOW_MS) {
1774
+ sessionId = `sess_${(0, import_core7.newUuid)()}`;
1775
+ startSession();
1776
+ } else {
1777
+ send("session_heartbeat", { route: route() });
1778
+ startHeartbeat();
1779
+ }
1780
+ return;
1781
+ }
1782
+ if (state !== "background" || !foreground) return;
1783
+ foreground = false;
1784
+ backgroundedAt = clock();
1785
+ stopHeartbeat();
1786
+ if (started) send("session_end", { route: route() });
1787
+ },
1788
+ pageView(next) {
1789
+ if (!allowed || !started || !foreground) return;
1790
+ pageViewCount += 1;
1791
+ send("page_view", { route: next });
1792
+ },
1793
+ destroy() {
1794
+ destroyed = true;
1795
+ deactivate();
1796
+ unsubscribe();
1797
+ }
1798
+ };
1799
+ }
1800
+
1482
1801
  // src/version.ts
1483
1802
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1484
- var MUSHI_SDK_VERSION = "0.21.0" ? "0.21.0" : "0.17.0";
1803
+ var MUSHI_SDK_VERSION = "0.22.0" ? "0.22.0" : "0.17.0";
1485
1804
 
1486
1805
  // src/provider.tsx
1487
1806
  var import_jsx_runtime4 = require("react/jsx-runtime");
1488
1807
  var MushiContext = (0, import_react4.createContext)(null);
1808
+ function nativeUserAgent() {
1809
+ const os = import_react_native5.Platform?.OS ?? "native";
1810
+ const version = import_react_native5.Platform?.Version;
1811
+ return `${MUSHI_SDK_PACKAGE}/${MUSHI_SDK_VERSION} (${version != null ? `${os} ${String(version)}` : os})`;
1812
+ }
1489
1813
  var DEFAULT_SCREENSHOT_HINT = "Check the preview \u2014 remove it if any private info (balances, personal details) is visible.";
1490
1814
  function resolveScreenshotHint(v) {
1491
1815
  if (v === false) return null;
@@ -1528,9 +1852,9 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1528
1852
  const networkRef = (0, import_react4.useRef)(null);
1529
1853
  const queueRef = (0, import_react4.useRef)(null);
1530
1854
  const apiClientRef = (0, import_react4.useRef)(null);
1531
- const sessionIdRef = (0, import_react4.useRef)(`sess_${(0, import_core6.newUuid)()}`);
1855
+ const sessionIdRef = (0, import_react4.useRef)(`sess_${(0, import_core8.newUuid)()}`);
1532
1856
  const breadcrumbsRef = (0, import_react4.useRef)(
1533
- (0, import_core6.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1857
+ (0, import_core8.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1534
1858
  );
1535
1859
  const [sheetVisible, setSheetVisible] = (0, import_react4.useState)(false);
1536
1860
  const [sheetPreferredTab, setSheetPreferredTab] = (0, import_react4.useState)("report");
@@ -1555,7 +1879,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1555
1879
  if (config.endpoint && !config.apiEndpoint && __DEV__) {
1556
1880
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
1557
1881
  }
1558
- const apiEndpoint = rawEndpoint ?? import_core6.DEFAULT_API_ENDPOINT;
1882
+ const apiEndpoint = rawEndpoint ?? import_core8.DEFAULT_API_ENDPOINT;
1559
1883
  (0, import_react4.useEffect)(() => {
1560
1884
  if (config.capture?.console !== false) {
1561
1885
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
@@ -1563,7 +1887,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1563
1887
  if (config.capture?.network !== false) {
1564
1888
  networkRef.current = setupNetworkCapture(config.capture?.maxNetworkEntries, apiEndpoint);
1565
1889
  }
1566
- apiClientRef.current = (0, import_core6.createApiClient)({
1890
+ apiClientRef.current = (0, import_core8.createApiClient)({
1567
1891
  projectId: config.projectId,
1568
1892
  apiKey: config.apiKey,
1569
1893
  apiEndpoint,
@@ -1600,14 +1924,14 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1600
1924
  if (existing && existing.startsWith("mushi_")) {
1601
1925
  reporterTokenRef.current = existing;
1602
1926
  } else {
1603
- const fresh = `mushi_${(0, import_core6.newUuid)()}`;
1927
+ const fresh = `mushi_${(0, import_core8.newUuid)()}`;
1604
1928
  reporterTokenRef.current = fresh;
1605
1929
  await saveReporterToken(fresh, secureStorage);
1606
1930
  }
1607
1931
  resolveReporterTokenReady();
1608
1932
  } catch {
1609
1933
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1610
- reporterTokenRef.current = `mushi_${(0, import_core6.newUuid)()}`;
1934
+ reporterTokenRef.current = `mushi_${(0, import_core8.newUuid)()}`;
1611
1935
  }
1612
1936
  resolveReporterTokenReady();
1613
1937
  }
@@ -1727,7 +2051,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1727
2051
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1728
2052
  const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1729
2053
  ...c,
1730
- message: (0, import_core6.scrubPii)(c.message)
2054
+ message: (0, import_core8.scrubPii)(c.message)
1731
2055
  }));
1732
2056
  const timeline = breadcrumbs.map((c) => ({
1733
2057
  ts: c.timestamp,
@@ -1736,10 +2060,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1736
2060
  }));
1737
2061
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1738
2062
  const report = {
1739
- id: (0, import_core6.newUuid)(),
2063
+ id: (0, import_core8.newUuid)(),
1740
2064
  projectId: config.projectId,
1741
2065
  category: data.category,
1742
- description: (0, import_core6.scrubPii)(data.description),
2066
+ description: (0, import_core8.scrubPii)(data.description),
1743
2067
  environment: {
1744
2068
  userAgent: deviceInfo.systemName ?? "ReactNative",
1745
2069
  platform: deviceInfo.platform ?? "mobile",
@@ -1752,7 +2076,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1752
2076
  },
1753
2077
  consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1754
2078
  ...entry,
1755
- message: (0, import_core6.scrubPii)(entry.message)
2079
+ message: (0, import_core8.scrubPii)(entry.message)
1756
2080
  })),
1757
2081
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1758
2082
  ...entry,
@@ -1782,7 +2106,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1782
2106
  const code = result.error?.code ?? "";
1783
2107
  const isCredentialError = code === "HTTP_401" || code === "HTTP_403" || code.includes("UNAUTHORIZED") || code.includes("FORBIDDEN") || code.includes("INVALID_API_KEY");
1784
2108
  if (isCredentialError) {
1785
- const where = apiEndpoint.replace(/\/$/, "") === import_core6.DEFAULT_API_ENDPOINT ? "Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects" : "Get the correct values from your Mushi console's Projects page.";
2109
+ const where = apiEndpoint.replace(/\/$/, "") === import_core8.DEFAULT_API_ENDPOINT ? "Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects" : "Get the correct values from your Mushi console's Projects page.";
1786
2110
  console.error(
1787
2111
  '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). ' + where
1788
2112
  );
@@ -1828,6 +2152,63 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1828
2152
  if (rewardsFlushRef.current) clearInterval(rewardsFlushRef.current);
1829
2153
  };
1830
2154
  }, [config.rewards?.enabled, config.rewards?.trackActivity, config.rewards?.flushIntervalMs]);
2155
+ const eventTrackerRef = (0, import_react4.useRef)(null);
2156
+ const sessionTrackerRef = (0, import_react4.useRef)(null);
2157
+ (0, import_react4.useEffect)(() => {
2158
+ const getRoute = () => screenRef.current?.route ?? screenRef.current?.name ?? null;
2159
+ const tracker = createRNEventTracker({
2160
+ projectId: config.projectId,
2161
+ client: () => apiClientRef.current,
2162
+ getAnonId: async () => {
2163
+ await reporterTokenReadyRef.current;
2164
+ return reporterTokenRef.current;
2165
+ },
2166
+ getUserId: () => userRef.current?.id ?? null,
2167
+ getRoute,
2168
+ sdkVersion: MUSHI_SDK_VERSION,
2169
+ config: config.analytics,
2170
+ scrub: import_core8.scrubPii
2171
+ });
2172
+ eventTrackerRef.current = tracker;
2173
+ const sessions = config.trackSessions === false ? null : createRNSessionTracker({
2174
+ client: () => apiClientRef.current,
2175
+ getReporterToken: async () => {
2176
+ await reporterTokenReadyRef.current;
2177
+ return reporterTokenRef.current;
2178
+ },
2179
+ getRoute,
2180
+ consent: tracker,
2181
+ sessionId: sessionIdRef.current,
2182
+ sdkVersion: MUSHI_SDK_VERSION,
2183
+ userAgent: nativeUserAgent()
2184
+ });
2185
+ sessionTrackerRef.current = sessions;
2186
+ let sub;
2187
+ try {
2188
+ if (typeof import_react_native5.AppState?.addEventListener === "function") {
2189
+ sub = import_react_native5.AppState.addEventListener("change", (state) => {
2190
+ sessions?.onAppStateChange(state);
2191
+ if (state !== "active") tracker.flush().catch(() => {
2192
+ });
2193
+ });
2194
+ }
2195
+ } catch {
2196
+ }
2197
+ return () => {
2198
+ sub?.remove();
2199
+ sessions?.destroy();
2200
+ sessionTrackerRef.current = null;
2201
+ eventTrackerRef.current = null;
2202
+ tracker.destroy().catch(() => {
2203
+ });
2204
+ };
2205
+ }, [
2206
+ config.projectId,
2207
+ config.analytics?.enabled,
2208
+ config.analytics?.consent,
2209
+ config.analytics?.flushIntervalMs,
2210
+ config.trackSessions
2211
+ ]);
1831
2212
  const instance = (0, import_react4.useMemo)(
1832
2213
  () => ({
1833
2214
  open,
@@ -1845,6 +2226,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1845
2226
  name: traits?.name,
1846
2227
  provider: traits?.provider
1847
2228
  };
2229
+ eventTrackerRef.current?.setIdentity(userId, traits ?? null);
1848
2230
  },
1849
2231
  setUser(user) {
1850
2232
  userRef.current = {
@@ -1853,18 +2235,22 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1853
2235
  name: user.name,
1854
2236
  provider: user.provider
1855
2237
  };
2238
+ eventTrackerRef.current?.setIdentity(user.id, { email: user.email, name: user.name, provider: user.provider });
1856
2239
  },
1857
2240
  identifyWithToken(token) {
1858
2241
  userTokenRef.current = token && typeof token === "string" ? token : null;
1859
2242
  if (userTokenRef.current) {
1860
- const claims = (0, import_core6.parseIdentityToken)(userTokenRef.current);
2243
+ const claims = (0, import_core8.parseIdentityToken)(userTokenRef.current);
1861
2244
  if (claims?.sub) {
1862
2245
  userRef.current = {
1863
2246
  id: claims.sub,
1864
2247
  ...claims.email ? { email: claims.email } : {},
1865
2248
  ...claims.name ? { name: claims.name } : {}
1866
2249
  };
2250
+ eventTrackerRef.current?.setIdentity(claims.sub, null);
1867
2251
  }
2252
+ } else {
2253
+ eventTrackerRef.current?.setIdentity(null, null);
1868
2254
  }
1869
2255
  },
1870
2256
  publishPageContext(context) {
@@ -1877,6 +2263,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1877
2263
  const prev = screenRef.current?.name;
1878
2264
  screenRef.current = screen;
1879
2265
  if (screen.name && screen.name !== prev) {
2266
+ sessionTrackerRef.current?.pageView(screen.route ?? screen.name);
1880
2267
  breadcrumbsRef.current.add({
1881
2268
  category: "navigation",
1882
2269
  level: "info",
@@ -1938,6 +2325,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1938
2325
  if (!config.rewards?.enabled) return;
1939
2326
  activityQueueRef.current.push({ action, metadata });
1940
2327
  },
2328
+ // Product analytics (Users & Funnels)
2329
+ track(event, properties) {
2330
+ return eventTrackerRef.current?.track(event, properties) ?? false;
2331
+ },
2332
+ setConsent(state) {
2333
+ eventTrackerRef.current?.setConsent(state);
2334
+ },
1941
2335
  async getTier() {
1942
2336
  const userId = userRef.current?.id;
1943
2337
  const client = apiClientRef.current;
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, FC, ReactElement } from 'react';
3
- import { MushiRewardsConfig, MushiPageContext, MushiBreadcrumb, MushiReporterReport, MushiReporterComment, MushiHallOfFameEntry, MushiTierResult, MushiReputationResult, MushiAssistantReply } from '@mushi-mushi/core';
3
+ import { MushiAnalyticsConfig, MushiRewardsConfig, MushiPageContext, MushiBreadcrumb, MushiReporterReport, MushiReporterComment, MushiHallOfFameEntry, MushiPropertyValue, MushiTierResult, MushiReputationResult, MushiAssistantReply } from '@mushi-mushi/core';
4
4
  export { MushiHallOfFameEntry, MushiReporterComment, MushiReporterReport } from '@mushi-mushi/core';
5
5
 
6
6
  interface ConsoleEntry {
@@ -42,6 +42,38 @@ interface DeviceInfo {
42
42
  }
43
43
  declare function getDeviceInfo(): DeviceInfo;
44
44
 
45
+ /**
46
+ * FILE: packages/react-native/src/analytics/event-tracker.ts
47
+ * PURPOSE: Product-analytics batcher behind `useMushi().track()` on React Native.
48
+ *
49
+ * OVERVIEW:
50
+ * - The core tracker (`@mushi-mushi/core` event-tracker.ts) returns early
51
+ * without `window`, so RN gets this minimal port: in-memory batch, interval
52
+ * flush, explicit `flush()` for app-background, AsyncStorage spill for
53
+ * unsent batches (cap 200 / 24 h) and persisted consent.
54
+ * - Validation and PII rules are the shared ones from core
55
+ * (`isValidEventName`, `sanitizeEventProperties`, `propertiesWithinByteLimit`).
56
+ * - Transport is `MushiApiClient.postProductEvents()` — the same
57
+ * POST /v1/sdk/events the web SDK uses; `surface` is `'mobile'`.
58
+ *
59
+ * DEPENDENCIES:
60
+ * - @mushi-mushi/core — validation helpers + wire types.
61
+ * - @react-native-async-storage/async-storage — optional peer; without it the
62
+ * batch is memory-only and consent is per-launch.
63
+ *
64
+ * USAGE:
65
+ * - Created by MushiProvider; hosts call `useMushi().track(name, props)`.
66
+ * - No React import here so the module is unit-testable with fake timers.
67
+ *
68
+ * NOTES:
69
+ * - No DNT signal exists on native; `analytics.enabled:false` or
70
+ * `analytics.consent:'required'` + `setConsent('denied')` are the off switches.
71
+ * App Tracking Transparency prompts remain the host's responsibility.
72
+ */
73
+
74
+ /** The subset of the web `analytics` block that applies on native. */
75
+ type RNAnalyticsConfig = Pick<MushiAnalyticsConfig, 'enabled' | 'consent' | 'flushIntervalMs' | 'propertyAllowlist'>;
76
+
45
77
  interface MushiRNConfig {
46
78
  projectId: string;
47
79
  apiKey: string;
@@ -115,6 +147,22 @@ interface MushiRNConfig {
115
147
  secureStorage?: boolean;
116
148
  };
117
149
  rewards?: MushiRewardsConfig;
150
+ /**
151
+ * Product analytics (`useMushi().track()`). Events batch to
152
+ * POST /v1/sdk/events every 5 s, on app background and at 20 queued events;
153
+ * unsent batches spill to AsyncStorage (optional peer) and replay on the
154
+ * next launch. `consent: 'required'` buffers until `setConsent('granted')`.
155
+ * There is no DNT signal on native — `enabled: false` is the off switch.
156
+ */
157
+ analytics?: RNAnalyticsConfig;
158
+ /**
159
+ * Session lifecycle tracking for the console's Activity and Users views:
160
+ * session start / end on foreground and background, a heartbeat every
161
+ * minute, and a page view per `setScreen()` change. Default true. Follows
162
+ * the `analytics` switch and consent: nothing is sent while analytics is
163
+ * disabled or consent is not granted.
164
+ */
165
+ trackSessions?: boolean;
118
166
  }
119
167
 
120
168
  interface MushiRNInstance {
@@ -190,6 +238,15 @@ interface MushiRNInstance {
190
238
  getHallOfFame(limit?: number): Promise<MushiHallOfFameEntry[]>;
191
239
  /** Manually record a host-defined activity event. */
192
240
  recordActivity(action: string, metadata?: Record<string, unknown>): void;
241
+ /**
242
+ * Record a product-analytics event, e.g. `track('checkout_started', { plan: 'pro' })`.
243
+ * Names must match `^[a-z][a-z0-9_]{1,63}$`; properties are flat, PII-looking
244
+ * keys are dropped and string values are scrubbed. Batched to
245
+ * POST /v1/sdk/events with `$surface: 'mobile'`. Returns true when queued.
246
+ */
247
+ track(event: string, properties?: Record<string, MushiPropertyValue>): boolean;
248
+ /** Grant or deny analytics consent (persisted in AsyncStorage when available). */
249
+ setConsent(state: 'granted' | 'denied'): void;
193
250
  /** Returns the current user's tier. */
194
251
  getTier(): Promise<MushiTierResult | null>;
195
252
  /** Returns the current user's reputation + point totals. */
@@ -390,4 +447,4 @@ declare class AsyncStorageQueue {
390
447
  private getAsyncStorage;
391
448
  }
392
449
 
393
- export { AsyncStorageQueue, type ConsoleEntry, type DeviceInfo, MUSHI_BANNER_DEFAULT_HEIGHT, MushiBanner, type MushiBannerProps, MushiBottomSheet, type MushiBottomSheetProps, MushiFloatingButton, type MushiFloatingButtonProps, MushiProvider, type MushiRNConfig, type MushiRNInstance, MushiTrigger, type MushiTriggerProps, type NavigationEntry, type NetworkEntry, getDeviceInfo, setupConsoleCapture, setupNetworkCapture, useMushi, useMushiReport, useMushiWidget, useNavigationCapture };
450
+ export { AsyncStorageQueue, type ConsoleEntry, type DeviceInfo, MUSHI_BANNER_DEFAULT_HEIGHT, MushiBanner, type MushiBannerProps, MushiBottomSheet, type MushiBottomSheetProps, MushiFloatingButton, type MushiFloatingButtonProps, MushiProvider, type MushiRNConfig, type MushiRNInstance, MushiTrigger, type MushiTriggerProps, type NavigationEntry, type NetworkEntry, type RNAnalyticsConfig, getDeviceInfo, setupConsoleCapture, setupNetworkCapture, useMushi, useMushiReport, useMushiWidget, useNavigationCapture };