diff --git a/.agents/skills/exceptionless-javascript/SKILL.md b/.agents/skills/exceptionless-javascript/SKILL.md index 7f0f87d8..b578d037 100644 --- a/.agents/skills/exceptionless-javascript/SKILL.md +++ b/.agents/skills/exceptionless-javascript/SKILL.md @@ -1,6 +1,6 @@ --- name: exceptionless-javascript -description: Use this skill when a developer wants to install, configure, troubleshoot, or integrate Exceptionless JavaScript clients for browser, Node.js, React, Vue, AngularJS, Express, Next.js, SvelteKit, or custom runtimes. Use it for API keys, startup, self-hosting, sending errors/logs/feature usage/404/custom events, indexed event properties, sessions, heartbeats, user identity, PII/data exclusions, plugins, runtime client configuration values, queues, and production setup even if they only ask "how do I wire up Exceptionless?" +description: Use this skill when a developer wants to install, configure, troubleshoot, or integrate Exceptionless JavaScript clients for browser, Node.js, React, React Native, Expo, Vue, AngularJS, Express, Next.js, SvelteKit, or custom runtimes. Use it for API keys, startup, self-hosting, sending errors/logs/feature usage/404/custom events, indexed event properties, sessions, heartbeats, user identity, PII/data exclusions, plugins, runtime client configuration values, queues, native crash reporting, and production setup even if they only ask "how do I wire up Exceptionless?" --- # Exceptionless JavaScript SDK @@ -38,6 +38,7 @@ Read only the reference that matches the user's runtime, then add shared referen - `@exceptionless/browser`: [references/client-browser.md](references/client-browser.md) - `@exceptionless/node`: [references/client-node.md](references/client-node.md) - `@exceptionless/react`: [references/client-react.md](references/client-react.md) +- `@exceptionless/react-native`: [references/client-react-native.md](references/client-react-native.md) - `@exceptionless/vue`: [references/client-vue.md](references/client-vue.md) - `@exceptionless/angularjs`: [references/client-angularjs.md](references/client-angularjs.md) - Sending events: [references/sending-events.md](references/sending-events.md) @@ -52,6 +53,8 @@ Read only the reference that matches the user's runtime, then add shared referen - Use `Exceptionless.startup(...)` once during app startup. `startup()` with no args is used later by lifecycle plugins to resume timers/queue processing. - Use the singleton from the platform package when automatic capture matters. Create `ExceptionlessClient` manually only for custom pipelines or tests. +- For React Native or Expo apps, use `@exceptionless/react-native`; do not substitute `@exceptionless/browser` or `@exceptionless/react`. +- In Expo, add `@exceptionless/react-native/expo-plugin` when native iOS crash reporting is expected. Expo Go can report JavaScript errors but cannot load the native crash reporter. - `submitException` and `createException` take an `Error`. For unknown caught values, use exported `toError(value)` when available. - `markAsCritical()` marks the event critical; `markAsCritical(false)` leaves tags unchanged. - `config.serverUrl` also sets `configServerUrl` and `heartbeatServerUrl`; assign custom endpoint overrides after setting `serverUrl`. @@ -80,4 +83,7 @@ Verify behavior in: - `packages/core/src/submission/DefaultSubmissionClient.ts` - `packages/browser/src/BrowserExceptionlessClient.ts` - `packages/node/src/NodeExceptionlessClient.ts` +- `packages/react-native/src/ReactNativeExceptionlessClient.ts` +- `packages/react-native/src/plugins/ReactNativeErrorPlugin.ts` +- `packages/react-native/src/plugins/NativeCrashPlugin.ts` - Package READMEs and `example/` apps. diff --git a/.agents/skills/exceptionless-javascript/references/client-react-native.md b/.agents/skills/exceptionless-javascript/references/client-react-native.md new file mode 100644 index 00000000..a87ddf5a --- /dev/null +++ b/.agents/skills/exceptionless-javascript/references/client-react-native.md @@ -0,0 +1,124 @@ +# @exceptionless/react-native + +Use for React Native and Expo apps. This package adds a React Native client, AsyncStorage-backed persistence, React Native/Hermes stack parsing, lifecycle handling, global JavaScript error capture, an error boundary, and iOS native crash reporting. + +## Install + +Expo: + +```bash +npx expo install @exceptionless/react-native @react-native-async-storage/async-storage +``` + +React Native CLI: + +```bash +npm install @exceptionless/react-native @react-native-async-storage/async-storage +cd ios && pod install +``` + +For Expo development or standalone builds, add the config plugin: + +```json +{ + "expo": { + "plugins": ["@exceptionless/react-native/expo-plugin"] + } +} +``` + +Native iOS crash reporting requires an Expo development build, a standalone build, or a bare React Native app. Expo Go can capture JavaScript errors but cannot load the native crash reporter. + +## Configure + +Call `startup` once during app initialization: + +```tsx +import { Exceptionless } from "@exceptionless/react-native"; + +await Exceptionless.startup((config) => { + config.apiKey = "API_KEY_HERE"; + config.setUserIdentity("12345678", "Blake"); + config.defaultTags.push("React Native"); +}); +``` + +For self-hosted Exceptionless: + +```tsx +await Exceptionless.startup((config) => { + config.apiKey = "API_KEY_HERE"; + config.serverUrl = "https://exceptionless.example.com"; +}); +``` + +For local simulator development, prefer `http://localhost:` when the app is running in the iOS simulator. Start Expo with `--localhost` or set `REACT_NATIVE_PACKAGER_HOSTNAME=localhost` so Metro bundle URLs in stack traces also use localhost. Use a LAN IP only when a physical device must reach a server on the development machine. + +## Error Boundary + +Wrap rendering surfaces to capture React render errors and attach the React component stack to `@error.data["@component_stack"]`: + +```tsx +import { Text } from "react-native"; +import { ExceptionlessErrorBoundary } from "@exceptionless/react-native"; + +export function App() { + return ( + Something went wrong.}> + + + ); +} +``` + +React error boundaries do not catch event handlers, async failures, or manually swallowed errors. Submit those explicitly. + +## Send + +```tsx +import { Exceptionless, toError } from "@exceptionless/react-native"; + +try { + await saveProfile(); +} catch (error) { + await Exceptionless.submitException(toError(error)); +} + +await Exceptionless.submitLog("mobile", "Profile opened", "info"); +await Exceptionless.submitFeatureUsage("Profile Editor"); + +await Exceptionless.createException(new Error("Checkout failed")) + .addTags("checkout", "mobile") + .setProperty("orderId", "12345") + .markAsCritical(true) + .submit(); +``` + +## Captured Behavior + +- Unhandled JavaScript errors and unhandled promise rejections are captured automatically after startup. +- React Native/Hermes stack frames are parsed into structured Exceptionless stack frames. +- iOS native crashes are persisted by PLCrashReporter and submitted on the next launch. +- Device, OS, locale, React Native version, sessions, and lifecycle state are captured when available. +- Event queue storage uses `@react-native-async-storage/async-storage`. + +## Troubleshooting + +- If native crashes do not appear in Expo, verify the app is not running in Expo Go and that the config plugin is present before rebuilding the native app. +- If simulator submissions cannot reach a local Exceptionless server, use `http://localhost:` for iOS Simulator and make sure Metro is not running in Expo's default LAN mode. Physical devices need a reachable LAN host. +- For malformed or unexpected stacks, verify behavior in `ReactNativeErrorPlugin` tests before changing parser logic. +- For native crash report loss concerns, verify `NativeCrashPlugin` only clears pending reports after at least one report is retrieved and submitted. + +## Source Anchors + +- `packages/react-native/README.md` +- `packages/react-native/src/ReactNativeExceptionlessClient.ts` +- `packages/react-native/src/ExceptionlessErrorBoundary.tsx` +- `packages/react-native/src/plugins/ReactNativeErrorPlugin.ts` +- `packages/react-native/src/plugins/ReactNativeGlobalHandlerPlugin.ts` +- `packages/react-native/src/plugins/ReactNativeLifeCyclePlugin.ts` +- `packages/react-native/src/plugins/NativeCrashPlugin.ts` +- `packages/react-native/src/storage/AsyncStorageProvider.ts` +- `packages/react-native/exceptionless-react-native.podspec` +- `packages/react-native/expo-plugin/withExceptionless.cjs` +- `example/expo/` diff --git a/.prettierignore b/.prettierignore index dbdb46f6..6d447abe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,9 @@ node_modules minver example/svelte-kit/.svelte-kit +example/expo/.expo +example/expo/android +example/expo/ios # Ignore files for PNPM, NPM and YARN package-lock.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 356f770f..52bdddc6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,17 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Expo iOS Example", + "request": "launch", + "type": "node", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "ios", "--workspace=example/expo"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${workspaceRoot}", + "skipFiles": ["/**"] + }, { "name": "Express", "program": "${workspaceRoot}/example/express/app.js", diff --git a/README.md b/README.md index 62819aa3..0dbf5938 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The definition of the word exceptionless is: to be without exception. Exceptionl You can install the npm package via `npm install @exceptionless/browser --save` or via cdn [`https://unpkg.com/@exceptionless/browser`](https://unpkg.com/@exceptionless/browser). -Next, you just need to call startup during your apps startup to automatically +Next, you just need to call startup during your app's startup to automatically capture unhandled errors. ```js @@ -38,7 +38,7 @@ try { ## Node You can install the npm package via `npm install @exceptionless/node --save`. -Next, you just need to call startup during your apps startup to automatically +Next, you just need to call startup during your app's startup to automatically capture unhandled errors. ```js @@ -63,6 +63,29 @@ try { } ``` +## React Native / Expo + +You can install the npm package via +`npm install @exceptionless/react-native @react-native-async-storage/async-storage`. +Next, you just need to call startup during your apps startup to automatically +capture unhandled errors, promise rejections, and native iOS crashes. + +```tsx +import { Exceptionless, toError } from "@exceptionless/react-native"; + +await Exceptionless.startup((c) => { + c.apiKey = "API_KEY_HERE"; + c.setUserIdentity("12345678", "Blake"); + c.defaultTags.push("Example", "React Native"); +}); + +try { + throw new Error("test"); +} catch (error) { + await Exceptionless.submitException(toError(error)); +} +``` + ## Using Exceptionless ### Installation @@ -223,7 +246,7 @@ instance. This is configured by setting the `serverUrl` on the default ```js await Exceptionless.startup((c) => { c.apiKey = "API_KEY_HERE"; - c.serverUrl = "https://localhost:5100"; + c.serverUrl = "https://ex.dev.localhost:7111"; }); ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 050591c9..9513ed0d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import vitest from "@vitest/eslint-plugin"; import tseslint from "typescript-eslint"; export default defineConfig( - { ignores: ["**/dist/", "**/node_modules/", ".agents/", "example/"] }, + { ignores: ["**/dist/", "**/node_modules/", ".agents/", "example/", "**/expo-plugin/", "**/react-native.config.*"] }, eslint.configs.recommended, { extends: tseslint.configs.recommendedTypeChecked, diff --git a/example/browser/index.js b/example/browser/index.js index df524fbb..c7d7b798 100644 --- a/example/browser/index.js +++ b/example/browser/index.js @@ -9,7 +9,7 @@ await Exceptionless.startup((c) => { c.services.log = new TextAreaLogger("logs", c.services.log); c.apiKey = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; - c.serverUrl = "https://localhost:5100"; + c.serverUrl = "https://ex.dev.localhost:7111"; c.updateSettingsWhenIdleInterval = 15000; c.usePersistedQueueStorage = true; c.setUserIdentity("12345678", "Blake"); diff --git a/example/expo/.gitignore b/example/expo/.gitignore new file mode 100644 index 00000000..aa32dca7 --- /dev/null +++ b/example/expo/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.expo/ +dist/ +ios/ +android/ +*.xcworkspace +Pods/ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/example/expo/App.tsx b/example/expo/App.tsx new file mode 100644 index 00000000..92b026d4 --- /dev/null +++ b/example/expo/App.tsx @@ -0,0 +1,240 @@ +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { NavigationContainer } from "@react-navigation/native"; +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import { GlassView } from "expo-glass-effect"; +import * as SplashScreen from "expo-splash-screen"; +import { StatusBar } from "expo-status-bar"; +import { useEffect, useMemo, useState } from "react"; +import { Platform, StyleSheet, Text, View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { Exceptionless } from "@exceptionless/react-native"; + +import { callbackLog, getLogEntries, subscribeToLogs } from "./logging"; +import ErrorsScreen from "./screens/ErrorsScreen"; +import EventsScreen from "./screens/EventsScreen"; +import LogsScreen from "./screens/LogsScreen"; + +type TabParamList = { + Errors: undefined; + Events: undefined; + Logs: undefined; +}; + +const Tab = createBottomTabNavigator(); + +const serverUrl = getServerUrl(); + +function TabIcon({ label, focused }: { label: string; focused: boolean }) { + return {label}; +} + +/** + * Resolves the dev server URL based on the current platform. + * - Web and iOS Simulator: localhost reaches the host Mac. + * - Android Emulator: 10.0.2.2 reaches the host machine. + * - Real Android devices: need the dev machine's IP, extracted from Expo's hostUri. + */ +function getServerUrl(): string { + if (!__DEV__ || Platform.OS === "web" || Platform.OS === "ios") { + return "http://localhost:7110"; + } + + if (!Device.isDevice) { + return "http://10.0.2.2:7110"; + } + + const hostUri = Constants.expoConfig?.hostUri; + if (hostUri) { + try { + const hostname = new URL(`http://${hostUri}`).hostname; + return `http://${hostname}:7110`; + } catch { + // Fall through to default + } + } + + return "http://localhost:7110"; +} + +function TopDiagnostics() { + const [logs, setLogs] = useState(() => getLogEntries()); + const latestLog = logs.at(-1); + const errorCount = useMemo(() => logs.filter((entry) => entry.level === "error").length, [logs]); + + useEffect(() => subscribeToLogs(() => setLogs(getLogEntries())), []); + + return ( + + + + Exceptionless Expo + SDK 56 + + + {serverUrl} + + + {logs.length} logs + {errorCount} errors + sessions on + + + {latestLog ? `[${latestLog.level.toUpperCase()}] ${latestLog.message}` : "Waiting for Exceptionless startup logs..."} + + + + ); +} + +export default function App() { + useEffect(() => { + void Exceptionless.startup((config) => { + config.apiKey = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + config.serverUrl = serverUrl; + config.services.log = callbackLog; + config.defaultTags.push("Example", "Expo"); + config.useSessions(true, 60000, true); + }); + void SplashScreen.hideAsync(); + }, []); + + return ( + + + + + + }} + > + + }} + /> + + }} + /> + + }} + /> + + + + + + ); +} + +const styles = StyleSheet.create({ + appShell: { + flex: 1, + backgroundColor: "#fff" + }, + diagnosticsSafeArea: { + backgroundColor: "#fff" + }, + diagnostics: { + borderBottomColor: "#e5e7eb", + borderBottomWidth: StyleSheet.hairlineWidth, + paddingBottom: 10, + paddingHorizontal: 16, + paddingTop: 8 + }, + diagnosticsTitleRow: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between" + }, + diagnosticsTitle: { + color: "#111827", + fontSize: 17, + fontWeight: "700" + }, + diagnosticsPill: { + backgroundColor: "#eef2ff", + borderRadius: 999, + color: "#3730a3", + fontSize: 12, + fontWeight: "700", + overflow: "hidden", + paddingHorizontal: 10, + paddingVertical: 4 + }, + diagnosticsServer: { + color: "#475569", + fontSize: 12, + marginTop: 4 + }, + diagnosticsMetaRow: { + flexDirection: "row", + gap: 8, + marginTop: 8 + }, + diagnosticsMeta: { + backgroundColor: "#f8fafc", + borderColor: "#e2e8f0", + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + color: "#334155", + fontSize: 11, + fontWeight: "600", + overflow: "hidden", + paddingHorizontal: 8, + paddingVertical: 3 + }, + latestLog: { + color: "#111827", + fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }), + fontSize: 11, + lineHeight: 15, + marginTop: 8 + }, + tabBar: { + backgroundColor: "rgba(255,255,255,0.5)", + borderTopColor: "rgba(148,163,184,0.22)", + borderTopWidth: StyleSheet.hairlineWidth, + elevation: 0, + height: 78, + paddingBottom: 14, + paddingTop: 8, + position: "absolute", + shadowColor: "#0f172a", + shadowOffset: { height: -4, width: 0 }, + shadowOpacity: 0.08, + shadowRadius: 18 + }, + tabIcon: { + color: "#64748b", + fontSize: 18, + fontWeight: "800", + lineHeight: 20 + }, + tabIconFocused: { + color: "#0f172a" + }, + tabLabel: { + fontSize: 12, + fontWeight: "700" + } +}); diff --git a/example/expo/README.md b/example/expo/README.md new file mode 100644 index 00000000..4cc2848d --- /dev/null +++ b/example/expo/README.md @@ -0,0 +1,65 @@ +# Exceptionless Expo Example + +This example exercises `@exceptionless/react-native` from an Expo app. It covers JavaScript errors, promise rejections, manual events, logs, sessions, user identity, the React error boundary, and native iOS crash submission. + +Native iOS crash reporting uses the package's custom native module, so it requires an Expo development build or a standalone app. Expo Go can run JavaScript reporting paths only; it cannot load the native crash reporter. + +This app tracks Expo SDK 56. + +## Prerequisites + +- Install dependencies from the repository root with `npm install`. +- Run an Exceptionless server on `http://localhost:7110`. The example uses localhost for web/iOS, `10.0.2.2` for Android Emulator, and Expo's `hostUri` for physical Android devices. +- Use a development build for native iOS crash reporting. + +## Run + +From the repository root: + +```bash +npm install +npm run ios --workspace=example/expo +``` + +`npm run ios` runs `expo run:ios`, which prebuilds native files when needed, installs the development build, and starts Metro. The script sets `REACT_NATIVE_PACKAGER_HOSTNAME=localhost` so iOS Simulator stack traces use localhost bundle URLs instead of LAN IPs. + +For the checked-in VS Code launch profile and iPad dogfooding, use: + +```bash +npm run ios:ipad --workspace=example/expo +``` + +`ios:ipad` launches the `iPad Air 11-inch (M3)` simulator on Metro port `8082`, which avoids colliding with another React Native app already using the default `8081` port. It also forces the Metro hostname to localhost. + +If the development build is already installed, start Metro directly: + +```bash +npm run start --workspace=example/expo +``` + +Use the web build for JavaScript event flows: + +```bash +npm run start:web --workspace=example/expo +``` + +The web build does not include native crash reporting. + +## Verify Reporting + +With an Exceptionless server listening on `http://localhost:7110`, use the sample tabs to submit: + +- caught errors and unhandled errors +- unhandled promise rejections +- logs and feature usage events +- session start, heartbeat, and end events + +The in-app Logs tab should show events being enqueued and sent to the configured server. Native iOS crash reports are captured by the development build and submitted on the next launch. + +## Notes + +- The example points at `http://localhost:7110` by default and only derives the host IP for Android physical devices when Expo provides `hostUri`. +- For iOS Simulator stack traces, run Metro with `--localhost` or `REACT_NATIVE_PACKAGER_HOSTNAME=localhost`; otherwise Expo's default LAN mode can put a `10.x.x.x` URL into stack-frame file names. +- Native iOS crashes are written by the native module and submitted on the next launch. +- Android currently exercises JavaScript events only; Android native crash reporting is not implemented yet. +- Generated native folders are intentionally ignored by `example/expo/.gitignore`; run `npm run prebuild --workspace=example/expo` or `npm run ios --workspace=example/expo` to recreate them locally. diff --git a/example/expo/app.json b/example/expo/app.json new file mode 100644 index 00000000..e9c353b8 --- /dev/null +++ b/example/expo/app.json @@ -0,0 +1,49 @@ +{ + "expo": { + "name": "Exceptionless Expo Example", + "slug": "exceptionless-expo-example", + "version": "3.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "scheme": "exceptionless-expo", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.exceptionless.expo.example", + "icon": "./assets/icon.png" + }, + "android": { + "package": "com.exceptionless.expo.example", + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "monochromeImage": "./assets/adaptive-icon-monochrome.png", + "backgroundColor": "#ffffff" + } + }, + "web": { + "bundler": "metro", + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "@exceptionless/react-native/expo-plugin", + "expo-status-bar", + [ + "expo-splash-screen", + { + "backgroundColor": "#ffffff", + "image": "./assets/splash-icon.png", + "imageWidth": 190, + "resizeMode": "contain" + } + ] + ], + "experiments": { + "tsconfigPaths": true + } + } +} diff --git a/example/expo/assets/adaptive-icon-monochrome.png b/example/expo/assets/adaptive-icon-monochrome.png new file mode 100644 index 00000000..60dfcbb7 Binary files /dev/null and b/example/expo/assets/adaptive-icon-monochrome.png differ diff --git a/example/expo/assets/adaptive-icon.png b/example/expo/assets/adaptive-icon.png new file mode 100644 index 00000000..0f4c7a21 Binary files /dev/null and b/example/expo/assets/adaptive-icon.png differ diff --git a/example/expo/assets/favicon.png b/example/expo/assets/favicon.png new file mode 100644 index 00000000..c43d5f99 Binary files /dev/null and b/example/expo/assets/favicon.png differ diff --git a/example/expo/assets/icon.png b/example/expo/assets/icon.png new file mode 100644 index 00000000..2ecc853c Binary files /dev/null and b/example/expo/assets/icon.png differ diff --git a/example/expo/assets/splash-background.png b/example/expo/assets/splash-background.png new file mode 100644 index 00000000..335ce0bd Binary files /dev/null and b/example/expo/assets/splash-background.png differ diff --git a/example/expo/assets/splash-icon.png b/example/expo/assets/splash-icon.png new file mode 100644 index 00000000..424027d5 Binary files /dev/null and b/example/expo/assets/splash-icon.png differ diff --git a/example/expo/assets/splash.png b/example/expo/assets/splash.png new file mode 100644 index 00000000..05dadd68 Binary files /dev/null and b/example/expo/assets/splash.png differ diff --git a/example/expo/index.js b/example/expo/index.js new file mode 100644 index 00000000..e5802d26 --- /dev/null +++ b/example/expo/index.js @@ -0,0 +1,5 @@ +import { registerRootComponent } from "expo"; + +import App from "./App"; + +registerRootComponent(App); diff --git a/example/expo/logging/index.ts b/example/expo/logging/index.ts new file mode 100644 index 00000000..3ffc7ade --- /dev/null +++ b/example/expo/logging/index.ts @@ -0,0 +1,46 @@ +import { CallbackLog, ConsoleLog } from "@exceptionless/react-native"; + +import type { LogEntry } from "@exceptionless/react-native"; + +const MAX_LOG_ENTRIES = 200; + +/** Module-level log entry storage that persists across screen navigations. */ +let _entries: LogEntry[] = []; +let _listeners: Array<() => void> = []; + +function notifyListeners(): void { + for (const listener of _listeners) { + listener(); + } +} + +/** Shared CallbackLog instance that wraps ConsoleLog. Use in config.services.log. */ +export const callbackLog = new CallbackLog(new ConsoleLog()); + +// Automatically capture all log entries into the module-level store +callbackLog.subscribe((entry: LogEntry) => { + _entries = [..._entries, entry]; + if (_entries.length > MAX_LOG_ENTRIES) { + _entries = _entries.slice(_entries.length - MAX_LOG_ENTRIES); + } + notifyListeners(); +}); + +/** Get the current log entries snapshot. */ +export function getLogEntries(): LogEntry[] { + return _entries; +} + +/** Clear all stored log entries. */ +export function clearLogEntries(): void { + _entries = []; + notifyListeners(); +} + +/** Subscribe to log entry changes. Returns an unsubscribe function. */ +export function subscribeToLogs(listener: () => void): () => void { + _listeners.push(listener); + return () => { + _listeners = _listeners.filter((l) => l !== listener); + }; +} diff --git a/example/expo/metro.config.js b/example/expo/metro.config.js new file mode 100644 index 00000000..c05928ef --- /dev/null +++ b/example/expo/metro.config.js @@ -0,0 +1,15 @@ +const { getDefaultConfig } = require("expo/metro-config"); +const path = require("path"); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, "../.."); + +const config = getDefaultConfig(projectRoot); + +// Watch the monorepo packages for local SDK changes. +config.watchFolders = [monorepoRoot]; + +// Resolve packages from the example first, then the monorepo root. +config.resolver.nodeModulesPaths = [path.resolve(projectRoot, "node_modules"), path.resolve(monorepoRoot, "node_modules")]; + +module.exports = config; diff --git a/example/expo/package.json b/example/expo/package.json new file mode 100644 index 00000000..31a12aad --- /dev/null +++ b/example/expo/package.json @@ -0,0 +1,45 @@ +{ + "name": "expo-example", + "private": true, + "version": "3.0.0-dev", + "main": "./index.js", + "scripts": { + "start": "expo start --dev-client --localhost", + "start:ios": "expo start --dev-client --ios --localhost", + "start:web": "expo start --web", + "ios": "REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:ios", + "web": "expo export --platform web", + "prebuild": "expo prebuild --clean --no-install", + "android": "expo run:android" + }, + "dependencies": { + "@exceptionless/react-native": "3.0.0-dev", + "@expo/metro-runtime": "~56.0.13", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-navigation/bottom-tabs": "^7.15.9", + "@react-navigation/native": "^7.1.8", + "expo": "~56.0.8", + "expo-application": "~56.0.3", + "expo-constants": "~56.0.16", + "expo-dev-client": "~56.0.18", + "expo-device": "~56.0.4", + "expo-glass-effect": "~56.0.4", + "expo-splash-screen": "~56.0.10", + "expo-status-bar": "~56.0.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-web": "^0.21.0" + }, + "devDependencies": { + "@babel/core": "^7.26.0", + "@react-native/js-polyfills": "^0.79.6", + "@types/react": "~19.2.10", + "typescript": "~6.0.3" + }, + "publishConfig": { + "access": "restricted" + } +} diff --git a/example/expo/screens/ErrorsScreen.tsx b/example/expo/screens/ErrorsScreen.tsx new file mode 100644 index 00000000..9a069a24 --- /dev/null +++ b/example/expo/screens/ErrorsScreen.tsx @@ -0,0 +1,165 @@ +import { useRef, useState } from "react"; +import type { ReactElement } from "react"; +import { Button, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Exceptionless, ExceptionlessErrorBoundary } from "@exceptionless/react-native"; + +function CrashyComponent(): ReactElement { + throw new Error("Component crashed inside error boundary!"); +} + +export default function ErrorsScreen() { + const [boundaryKey, setBoundaryKey] = useState(0); + const [showCrashy, setShowCrashy] = useState(false); + const [status, setStatus] = useState(""); + const stressRunning = useRef(false); + + const throwUnhandledError = () => { + setStatus("Throwing unhandled error..."); + throw new Error("Unhandled error from button press"); + }; + + const throwPromiseRejection = () => { + setStatus("Triggering unhandled promise rejection..."); + void Promise.reject(new Error("Unhandled promise rejection")); + }; + + const submitCaughtError = async () => { + try { + throw new Error("This error was caught in try/catch"); + } catch (error) { + if (error instanceof Error) { + await Exceptionless.submitException(error); + setStatus(`Submitted: ${error.message}`); + } + } + }; + + const triggerErrorBoundary = () => { + setShowCrashy(true); + }; + + const resetErrorBoundary = () => { + setShowCrashy(false); + setBoundaryKey((key) => key + 1); + setStatus("Error boundary reset"); + }; + + const stressTest = () => { + if (stressRunning.current) return; + stressRunning.current = true; + setStatus("🔥 Stress test: submitting 50 errors rapidly..."); + const start = Date.now(); + let count = 0; + + for (let i = 0; i < 50; i++) { + try { + throw new Error(`Stress test error #${i + 1}`); + } catch (error) { + if (error instanceof Error) { + void Exceptionless.submitException(error); + count++; + } + } + } + + const elapsed = Date.now() - start; + setStatus(`✅ Submitted ${count} errors in ${elapsed}ms. UI should remain responsive.`); + stressRunning.current = false; + }; + + return ( + + Test various error scenarios to verify Exceptionless captures them. + + + Handled Errors +