@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/dist/index.d.ts 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 };
package/dist/index.js CHANGED
@@ -15,11 +15,12 @@ import {
15
15
  useCallback as useCallback4,
16
16
  useMemo
17
17
  } from "react";
18
+ import { AppState, Platform as Platform5 } from "react-native";
18
19
  import {
19
20
  createApiClient,
20
21
  createBreadcrumbBuffer,
21
22
  DEFAULT_API_ENDPOINT,
22
- newUuid,
23
+ newUuid as newUuid2,
23
24
  parseIdentityToken,
24
25
  scrubPii as scrubPii3
25
26
  } from "@mushi-mushi/core";
@@ -1499,13 +1500,341 @@ var s3 = StyleSheet3.create({
1499
1500
  }
1500
1501
  });
1501
1502
 
1503
+ // src/analytics/event-tracker.ts
1504
+ import {
1505
+ EVENT_PROPERTY_LIMITS,
1506
+ isValidEventName,
1507
+ propertiesWithinByteLimit,
1508
+ sanitizeEventProperties
1509
+ } from "@mushi-mushi/core";
1510
+ var CONSENT_BUFFER_MAX = 50;
1511
+ var SPILL_MAX = 200;
1512
+ var SPILL_TTL_MS = 24 * 60 * 60 * 1e3;
1513
+ var SURFACE = "mobile";
1514
+ async function defaultStorage() {
1515
+ try {
1516
+ const mod = await import("@react-native-async-storage/async-storage");
1517
+ return mod.default;
1518
+ } catch {
1519
+ return null;
1520
+ }
1521
+ }
1522
+ function createRNEventTracker(opts) {
1523
+ const cfg = opts.config ?? {};
1524
+ const enabled = cfg.enabled !== false;
1525
+ const consentMode = cfg.consent ?? "implied";
1526
+ const allowlist = cfg.propertyAllowlist ?? [];
1527
+ const spillKey = `@mushi:events_spill_${opts.projectId}`;
1528
+ const consentKey = `@mushi:analytics_consent_${opts.projectId}`;
1529
+ let buffer = [];
1530
+ let consentBuffer = [];
1531
+ let consent = consentMode === "required" ? "pending" : "granted";
1532
+ let userId = null;
1533
+ let userTraits = null;
1534
+ let pendingIdentify = false;
1535
+ let flushing = false;
1536
+ let destroyed = false;
1537
+ let timer = null;
1538
+ let storage = null;
1539
+ const consentListeners = /* @__PURE__ */ new Set();
1540
+ const loadStorage = opts.storage ?? defaultStorage;
1541
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1542
+ async function readSpill() {
1543
+ if (!storage) return [];
1544
+ try {
1545
+ const raw = await storage.getItem(spillKey);
1546
+ if (!raw) return [];
1547
+ const parsed = JSON.parse(raw);
1548
+ if (!parsed || Date.now() - parsed.at > SPILL_TTL_MS) {
1549
+ await storage.removeItem(spillKey);
1550
+ return [];
1551
+ }
1552
+ return Array.isArray(parsed.events) ? parsed.events.slice(0, SPILL_MAX) : [];
1553
+ } catch {
1554
+ return [];
1555
+ }
1556
+ }
1557
+ async function writeSpill(events) {
1558
+ if (!storage) return;
1559
+ try {
1560
+ if (events.length === 0) await storage.removeItem(spillKey);
1561
+ else await storage.setItem(spillKey, JSON.stringify({ at: Date.now(), events: events.slice(-SPILL_MAX) }));
1562
+ } catch {
1563
+ }
1564
+ }
1565
+ async function flushNow() {
1566
+ if (destroyed && buffer.length === 0) return;
1567
+ await ready;
1568
+ if (flushing || consent !== "granted") return;
1569
+ if (buffer.length === 0 && !pendingIdentify) return;
1570
+ const client = resolveClient();
1571
+ if (!client) return;
1572
+ flushing = true;
1573
+ const batch = buffer.splice(0, EVENT_PROPERTY_LIMITS.maxServerBatch);
1574
+ if (pendingIdentify) {
1575
+ batch.unshift({ name: "identify", ts: (/* @__PURE__ */ new Date()).toISOString(), properties: {} });
1576
+ pendingIdentify = false;
1577
+ }
1578
+ try {
1579
+ const anonId = await Promise.resolve(opts.getAnonId());
1580
+ const payload = {
1581
+ anon_id: anonId,
1582
+ user_id: userId ?? opts.getUserId?.() ?? null,
1583
+ user_traits: userTraits ?? opts.getUserTraits?.() ?? null,
1584
+ session_id: null,
1585
+ sdk_version: opts.sdkVersion ?? null,
1586
+ surface: SURFACE,
1587
+ events: batch
1588
+ };
1589
+ const res = await client.postProductEvents(payload);
1590
+ if (!res.ok) await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1591
+ else await writeSpill([]);
1592
+ } catch {
1593
+ await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1594
+ } finally {
1595
+ flushing = false;
1596
+ }
1597
+ if (buffer.length >= EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1598
+ }
1599
+ function enqueue(ev) {
1600
+ if (consent === "denied") return;
1601
+ if (consent === "pending") {
1602
+ if (consentBuffer.length < CONSENT_BUFFER_MAX) consentBuffer.push(ev);
1603
+ return;
1604
+ }
1605
+ buffer.push(ev);
1606
+ if (buffer.length >= EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1607
+ }
1608
+ const ready = enabled ? (async () => {
1609
+ storage = await loadStorage();
1610
+ if (destroyed) return;
1611
+ let stored = null;
1612
+ try {
1613
+ stored = storage ? await storage.getItem(consentKey) : null;
1614
+ } catch {
1615
+ stored = null;
1616
+ }
1617
+ if (stored === "denied") {
1618
+ consent = "denied";
1619
+ buffer = [];
1620
+ consentBuffer = [];
1621
+ pendingIdentify = false;
1622
+ await writeSpill([]);
1623
+ return;
1624
+ }
1625
+ if (stored === "granted" && consent === "pending") {
1626
+ consent = "granted";
1627
+ buffer.push(...consentBuffer);
1628
+ consentBuffer = [];
1629
+ }
1630
+ const spill = await readSpill();
1631
+ if (spill.length > 0) {
1632
+ buffer.unshift(...spill);
1633
+ await writeSpill([]);
1634
+ }
1635
+ })().catch(() => void 0) : Promise.resolve();
1636
+ if (enabled) {
1637
+ const interval = Math.max(1e3, cfg.flushIntervalMs ?? 5e3);
1638
+ timer = setInterval(() => {
1639
+ void flushNow();
1640
+ }, interval);
1641
+ }
1642
+ return {
1643
+ ready,
1644
+ track(name, properties, trackOpts) {
1645
+ if (!enabled || destroyed) return false;
1646
+ if (!isValidEventName(name)) return false;
1647
+ const { properties: props } = sanitizeEventProperties(properties, { allowlist, scrub: opts.scrub });
1648
+ let merged = props;
1649
+ if (trackOpts?.reserved) {
1650
+ const { properties: res } = sanitizeEventProperties(trackOpts.reserved, { allowReserved: true, scrub: opts.scrub });
1651
+ merged = { ...props, ...res };
1652
+ }
1653
+ merged.$surface = SURFACE;
1654
+ const route = opts.getRoute?.();
1655
+ if (route && merged.$route === void 0) merged.$route = route;
1656
+ if (!propertiesWithinByteLimit(merged)) return false;
1657
+ enqueue({
1658
+ name,
1659
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1660
+ properties: merged,
1661
+ ...trackOpts?.dedupKey ? { dedup_key: trackOpts.dedupKey } : {}
1662
+ });
1663
+ return true;
1664
+ },
1665
+ setConsent(state) {
1666
+ void ready.then(() => storage?.setItem(consentKey, state)).catch(() => void 0);
1667
+ if (state === "denied") {
1668
+ consent = "denied";
1669
+ buffer = [];
1670
+ consentBuffer = [];
1671
+ pendingIdentify = false;
1672
+ void writeSpill([]);
1673
+ } else {
1674
+ const wasPending = consent === "pending";
1675
+ consent = "granted";
1676
+ if (wasPending && consentBuffer.length > 0) {
1677
+ buffer.push(...consentBuffer);
1678
+ consentBuffer = [];
1679
+ }
1680
+ if (buffer.length > 0 || pendingIdentify) void flushNow();
1681
+ }
1682
+ for (const listener of [...consentListeners]) {
1683
+ try {
1684
+ listener(state);
1685
+ } catch {
1686
+ }
1687
+ }
1688
+ },
1689
+ setIdentity(id, traits) {
1690
+ userId = id;
1691
+ userTraits = traits ?? null;
1692
+ if (id && enabled && consent !== "denied") {
1693
+ pendingIdentify = true;
1694
+ void flushNow();
1695
+ }
1696
+ },
1697
+ consentState() {
1698
+ return enabled ? consent : "denied";
1699
+ },
1700
+ onConsentChange(listener) {
1701
+ consentListeners.add(listener);
1702
+ return () => {
1703
+ consentListeners.delete(listener);
1704
+ };
1705
+ },
1706
+ async flush() {
1707
+ await ready;
1708
+ await flushNow();
1709
+ },
1710
+ async destroy() {
1711
+ if (timer != null) {
1712
+ clearInterval(timer);
1713
+ timer = null;
1714
+ }
1715
+ await flushNow().catch(() => void 0);
1716
+ destroyed = true;
1717
+ buffer = [];
1718
+ consentBuffer = [];
1719
+ pendingIdentify = false;
1720
+ }
1721
+ };
1722
+ }
1723
+
1724
+ // src/analytics/session-tracker.ts
1725
+ import { newUuid } from "@mushi-mushi/core";
1726
+ var HEARTBEAT_INTERVAL_MS = 6e4;
1727
+ var RESUME_WINDOW_MS = 30 * 6e4;
1728
+ function createRNSessionTracker(opts) {
1729
+ const clock = opts.now ?? Date.now;
1730
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1731
+ let sessionId = opts.sessionId ?? `sess_${newUuid()}`;
1732
+ let pageViewCount = 0;
1733
+ let started = false;
1734
+ let allowed = false;
1735
+ let foreground = true;
1736
+ let destroyed = false;
1737
+ let backgroundedAt = null;
1738
+ let heartbeat = null;
1739
+ function route() {
1740
+ return opts.getRoute?.() ?? null;
1741
+ }
1742
+ function send(kind, extra) {
1743
+ const client = resolveClient();
1744
+ if (!client || !allowed || destroyed) return;
1745
+ const base = {
1746
+ kind,
1747
+ session_id: sessionId,
1748
+ ts: new Date(clock()).toISOString(),
1749
+ page_view_count: pageViewCount,
1750
+ user_agent: opts.userAgent ?? null,
1751
+ sdk_version: opts.sdkVersion
1752
+ };
1753
+ void Promise.resolve(opts.getReporterToken()).then((token) => client.postSessionEvent({ ...base, reporter_token_hash: token, ...extra })).catch(() => void 0);
1754
+ }
1755
+ function stopHeartbeat() {
1756
+ if (heartbeat != null) {
1757
+ clearInterval(heartbeat);
1758
+ heartbeat = null;
1759
+ }
1760
+ }
1761
+ function startHeartbeat() {
1762
+ stopHeartbeat();
1763
+ heartbeat = setInterval(() => send("session_heartbeat", { route: route() }), HEARTBEAT_INTERVAL_MS);
1764
+ }
1765
+ function startSession() {
1766
+ started = true;
1767
+ pageViewCount = 1;
1768
+ send("session_start", { route: route() });
1769
+ startHeartbeat();
1770
+ }
1771
+ function activate() {
1772
+ if (allowed || destroyed) return;
1773
+ allowed = true;
1774
+ if (!foreground) return;
1775
+ if (!started) startSession();
1776
+ else startHeartbeat();
1777
+ }
1778
+ function deactivate() {
1779
+ allowed = false;
1780
+ stopHeartbeat();
1781
+ }
1782
+ const unsubscribe = opts.consent.onConsentChange(() => {
1783
+ if (opts.consent.consentState() === "granted") activate();
1784
+ else deactivate();
1785
+ });
1786
+ void opts.consent.ready.then(() => {
1787
+ if (opts.consent.consentState() === "granted") activate();
1788
+ }).catch(() => void 0);
1789
+ return {
1790
+ onAppStateChange(state) {
1791
+ if (destroyed) return;
1792
+ if (state === "active") {
1793
+ if (foreground) return;
1794
+ foreground = true;
1795
+ const away = backgroundedAt == null ? 0 : clock() - backgroundedAt;
1796
+ backgroundedAt = null;
1797
+ if (!allowed) return;
1798
+ if (!started || away >= RESUME_WINDOW_MS) {
1799
+ sessionId = `sess_${newUuid()}`;
1800
+ startSession();
1801
+ } else {
1802
+ send("session_heartbeat", { route: route() });
1803
+ startHeartbeat();
1804
+ }
1805
+ return;
1806
+ }
1807
+ if (state !== "background" || !foreground) return;
1808
+ foreground = false;
1809
+ backgroundedAt = clock();
1810
+ stopHeartbeat();
1811
+ if (started) send("session_end", { route: route() });
1812
+ },
1813
+ pageView(next) {
1814
+ if (!allowed || !started || !foreground) return;
1815
+ pageViewCount += 1;
1816
+ send("page_view", { route: next });
1817
+ },
1818
+ destroy() {
1819
+ destroyed = true;
1820
+ deactivate();
1821
+ unsubscribe();
1822
+ }
1823
+ };
1824
+ }
1825
+
1502
1826
  // src/version.ts
1503
1827
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1504
- var MUSHI_SDK_VERSION = "0.21.0" ? "0.21.0" : "0.17.0";
1828
+ var MUSHI_SDK_VERSION = "0.22.0" ? "0.22.0" : "0.17.0";
1505
1829
 
1506
1830
  // src/provider.tsx
1507
1831
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1508
1832
  var MushiContext = createContext(null);
1833
+ function nativeUserAgent() {
1834
+ const os = Platform5?.OS ?? "native";
1835
+ const version = Platform5?.Version;
1836
+ return `${MUSHI_SDK_PACKAGE}/${MUSHI_SDK_VERSION} (${version != null ? `${os} ${String(version)}` : os})`;
1837
+ }
1509
1838
  var DEFAULT_SCREENSHOT_HINT = "Check the preview \u2014 remove it if any private info (balances, personal details) is visible.";
1510
1839
  function resolveScreenshotHint(v) {
1511
1840
  if (v === false) return null;
@@ -1548,7 +1877,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1548
1877
  const networkRef = useRef3(null);
1549
1878
  const queueRef = useRef3(null);
1550
1879
  const apiClientRef = useRef3(null);
1551
- const sessionIdRef = useRef3(`sess_${newUuid()}`);
1880
+ const sessionIdRef = useRef3(`sess_${newUuid2()}`);
1552
1881
  const breadcrumbsRef = useRef3(
1553
1882
  createBreadcrumbBuffer({ max: config.capture?.maxBreadcrumbs ?? 50 })
1554
1883
  );
@@ -1620,14 +1949,14 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1620
1949
  if (existing && existing.startsWith("mushi_")) {
1621
1950
  reporterTokenRef.current = existing;
1622
1951
  } else {
1623
- const fresh = `mushi_${newUuid()}`;
1952
+ const fresh = `mushi_${newUuid2()}`;
1624
1953
  reporterTokenRef.current = fresh;
1625
1954
  await saveReporterToken(fresh, secureStorage);
1626
1955
  }
1627
1956
  resolveReporterTokenReady();
1628
1957
  } catch {
1629
1958
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1630
- reporterTokenRef.current = `mushi_${newUuid()}`;
1959
+ reporterTokenRef.current = `mushi_${newUuid2()}`;
1631
1960
  }
1632
1961
  resolveReporterTokenReady();
1633
1962
  }
@@ -1756,7 +2085,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1756
2085
  }));
1757
2086
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1758
2087
  const report = {
1759
- id: newUuid(),
2088
+ id: newUuid2(),
1760
2089
  projectId: config.projectId,
1761
2090
  category: data.category,
1762
2091
  description: scrubPii3(data.description),
@@ -1848,6 +2177,63 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1848
2177
  if (rewardsFlushRef.current) clearInterval(rewardsFlushRef.current);
1849
2178
  };
1850
2179
  }, [config.rewards?.enabled, config.rewards?.trackActivity, config.rewards?.flushIntervalMs]);
2180
+ const eventTrackerRef = useRef3(null);
2181
+ const sessionTrackerRef = useRef3(null);
2182
+ useEffect3(() => {
2183
+ const getRoute = () => screenRef.current?.route ?? screenRef.current?.name ?? null;
2184
+ const tracker = createRNEventTracker({
2185
+ projectId: config.projectId,
2186
+ client: () => apiClientRef.current,
2187
+ getAnonId: async () => {
2188
+ await reporterTokenReadyRef.current;
2189
+ return reporterTokenRef.current;
2190
+ },
2191
+ getUserId: () => userRef.current?.id ?? null,
2192
+ getRoute,
2193
+ sdkVersion: MUSHI_SDK_VERSION,
2194
+ config: config.analytics,
2195
+ scrub: scrubPii3
2196
+ });
2197
+ eventTrackerRef.current = tracker;
2198
+ const sessions = config.trackSessions === false ? null : createRNSessionTracker({
2199
+ client: () => apiClientRef.current,
2200
+ getReporterToken: async () => {
2201
+ await reporterTokenReadyRef.current;
2202
+ return reporterTokenRef.current;
2203
+ },
2204
+ getRoute,
2205
+ consent: tracker,
2206
+ sessionId: sessionIdRef.current,
2207
+ sdkVersion: MUSHI_SDK_VERSION,
2208
+ userAgent: nativeUserAgent()
2209
+ });
2210
+ sessionTrackerRef.current = sessions;
2211
+ let sub;
2212
+ try {
2213
+ if (typeof AppState?.addEventListener === "function") {
2214
+ sub = AppState.addEventListener("change", (state) => {
2215
+ sessions?.onAppStateChange(state);
2216
+ if (state !== "active") tracker.flush().catch(() => {
2217
+ });
2218
+ });
2219
+ }
2220
+ } catch {
2221
+ }
2222
+ return () => {
2223
+ sub?.remove();
2224
+ sessions?.destroy();
2225
+ sessionTrackerRef.current = null;
2226
+ eventTrackerRef.current = null;
2227
+ tracker.destroy().catch(() => {
2228
+ });
2229
+ };
2230
+ }, [
2231
+ config.projectId,
2232
+ config.analytics?.enabled,
2233
+ config.analytics?.consent,
2234
+ config.analytics?.flushIntervalMs,
2235
+ config.trackSessions
2236
+ ]);
1851
2237
  const instance = useMemo(
1852
2238
  () => ({
1853
2239
  open,
@@ -1865,6 +2251,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1865
2251
  name: traits?.name,
1866
2252
  provider: traits?.provider
1867
2253
  };
2254
+ eventTrackerRef.current?.setIdentity(userId, traits ?? null);
1868
2255
  },
1869
2256
  setUser(user) {
1870
2257
  userRef.current = {
@@ -1873,6 +2260,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1873
2260
  name: user.name,
1874
2261
  provider: user.provider
1875
2262
  };
2263
+ eventTrackerRef.current?.setIdentity(user.id, { email: user.email, name: user.name, provider: user.provider });
1876
2264
  },
1877
2265
  identifyWithToken(token) {
1878
2266
  userTokenRef.current = token && typeof token === "string" ? token : null;
@@ -1884,7 +2272,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1884
2272
  ...claims.email ? { email: claims.email } : {},
1885
2273
  ...claims.name ? { name: claims.name } : {}
1886
2274
  };
2275
+ eventTrackerRef.current?.setIdentity(claims.sub, null);
1887
2276
  }
2277
+ } else {
2278
+ eventTrackerRef.current?.setIdentity(null, null);
1888
2279
  }
1889
2280
  },
1890
2281
  publishPageContext(context) {
@@ -1897,6 +2288,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1897
2288
  const prev = screenRef.current?.name;
1898
2289
  screenRef.current = screen;
1899
2290
  if (screen.name && screen.name !== prev) {
2291
+ sessionTrackerRef.current?.pageView(screen.route ?? screen.name);
1900
2292
  breadcrumbsRef.current.add({
1901
2293
  category: "navigation",
1902
2294
  level: "info",
@@ -1958,6 +2350,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1958
2350
  if (!config.rewards?.enabled) return;
1959
2351
  activityQueueRef.current.push({ action, metadata });
1960
2352
  },
2353
+ // Product analytics (Users & Funnels)
2354
+ track(event, properties) {
2355
+ return eventTrackerRef.current?.track(event, properties) ?? false;
2356
+ },
2357
+ setConsent(state) {
2358
+ eventTrackerRef.current?.setConsent(state);
2359
+ },
1961
2360
  async getTier() {
1962
2361
  const userId = userRef.current?.id;
1963
2362
  const client = apiClientRef.current;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@mushi-mushi/react-native",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT",
5
- "description": "React Native SDK for Mushi Mushi — shake-to-report, bottom sheet widget, full context capture",
5
+ "description": "Shake-to-report widget for React Native and Expo. The bug mediator for AI-built apps: plain-English diagnosis + a ready fix, in your editor.",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
@@ -43,8 +43,8 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@mushi-mushi/core": "^1.26.0",
47
- "@mushi-mushi/web": "^1.26.0"
46
+ "@mushi-mushi/core": "^1.29.0",
47
+ "@mushi-mushi/web": "^1.29.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@size-limit/file": "^12.1.0",
@@ -57,9 +57,9 @@
57
57
  "typescript": "^6.0.3",
58
58
  "vitest": "^4.1.9",
59
59
  "@mushi-mushi/eslint-config": "0.0.0",
60
- "eslint-plugin-mushi-mushi": "0.3.1"
60
+ "eslint-plugin-mushi-mushi": "0.3.4"
61
61
  },
62
- "author": "Kenji Sakuramoto (https://bsky.app/profile/mushimushi.dev)",
62
+ "author": "Kenji Sakuramoto <kensaurus@gmail.com> (https://github.com/kensaurus)",
63
63
  "repository": {
64
64
  "type": "git",
65
65
  "url": "https://github.com/kensaurus/mushi-mushi.git",
@@ -76,15 +76,12 @@
76
76
  "files": [
77
77
  "dist",
78
78
  "README.md",
79
- "CHANGELOG.md",
80
- "LICENSE",
81
- "CONTRIBUTING.md",
82
- "CODE_OF_CONDUCT.md",
83
- "SECURITY.md"
79
+ "LICENSE"
84
80
  ],
85
81
  "sideEffects": false,
86
82
  "keywords": [
87
83
  "mushi-mushi",
84
+ "bug-report-widget",
88
85
  "bug-reporting",
89
86
  "user-feedback",
90
87
  "user-report",
@@ -97,7 +94,6 @@
97
94
  "session-replay",
98
95
  "offline-queue",
99
96
  "sentry-companion",
100
- "sentry-alternative",
101
97
  "error-tracking",
102
98
  "ai-triage",
103
99
  "auto-fix",
@@ -119,11 +115,7 @@
119
115
  },
120
116
  "type": "module",
121
117
  "engines": {
122
- "node": ">=20"
123
- },
124
- "funding": {
125
- "type": "github",
126
- "url": "https://github.com/sponsors/kensaurus"
118
+ "node": ">=20.19.0"
127
119
  },
128
120
  "scripts": {
129
121
  "build": "tsup",