@catdoes/watch 1.3.0 → 2.0.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
@@ -1,182 +1,180 @@
1
1
  # @catdoes/watch
2
2
 
3
- Error tracking and monitoring SDK for React Native and Expo apps.
3
+ CatDoes Watch reports errors from Expo apps to the Watch dashboard. Version 2 captures React errors, global JavaScript errors, and unhandled promise rejections. It writes each event to local storage before attempting a network request, so a native crash can be sent after the app starts again.
4
4
 
5
- **CatDoes Watch** provides real-time error tracking, crash reporting, and debugging tools for your mobile applications built with React Native and Expo.
5
+ ## Requirements
6
6
 
7
- ## Features
7
+ Version 2 supports Expo 57 or newer, React Native 0.86 or newer, and React 19.2 or newer. Expo 54 projects must use `@catdoes/watch@1.3.0`.
8
8
 
9
- - **Automatic Error Capture** - Catches unhandled errors and promise rejections
10
- - **React Error Boundary** - Integrated error boundary component
11
- - **Breadcrumbs** - Track user actions leading up to errors
12
- - **Cross-Platform** - Works on iOS, Android, and Web
13
- - **Offline Support** - Queues events when offline, sends when reconnected
14
- - **Lightweight** - Minimal impact on app performance
15
-
16
- ## Installation
9
+ ## Install
17
10
 
18
11
  ```bash
19
- npm install @catdoes/watch
20
- # or
21
- yarn add @catdoes/watch
12
+ npx expo install @catdoes/watch expo-constants expo-device
22
13
  ```
23
14
 
24
- ### Optional Integrations
25
-
26
- The SDK has no hard dependencies beyond React / React Native:
27
-
28
- - **Offline queue persistence** — pass an AsyncStorage-compatible object via the `storage` option (see below). Without it, queued events are kept in memory only.
29
- - **Device metadata** (model, OS build, app version, locale) — enriched automatically when the app uses Expo and has `expo-device` / `expo-constants` / `expo-localization` installed. Read from Expo's native module registry at runtime; never imported by the SDK.
15
+ Expo installs `expo-file-system` and `expo-modules-core`. Install `expo-router` when the app uses the router integration.
30
16
 
31
- ## Quick Start
17
+ ## Quick start
32
18
 
33
- ### 1. Initialize the SDK
19
+ Initialize Watch before the root layout renders:
34
20
 
35
- In your app's entry point (e.g., `_layout.tsx` for Expo Router):
36
-
37
- ```typescript
38
- import AsyncStorage from "@react-native-async-storage/async-storage";
39
- import { Watch, setupGlobalHandlers } from "@catdoes/watch";
21
+ ```ts
22
+ import { Watch } from "@catdoes/watch";
40
23
 
41
- // Initialize Watch with your API key
42
- const watchClient = Watch.init({
43
- apiKey: process.env.EXPO_PUBLIC_CATDOES_WATCH_KEY || "",
44
- debug: __DEV__, // Enable debug logging in development
45
- storage: AsyncStorage, // Optional: persist the event queue across launches
24
+ Watch.init({
25
+ apiKey: process.env.EXPO_PUBLIC_CATDOES_WATCH_KEY ?? "",
26
+ debug: __DEV__,
46
27
  });
47
-
48
- // Set up global error handlers
49
- if (watchClient) {
50
- setupGlobalHandlers(watchClient);
51
- }
52
28
  ```
53
29
 
54
- ### 2. Wrap Your App with Error Boundary
30
+ Global handlers, queue persistence, session IDs, and device context are automatic. An empty API key disables capture and installs no hooks.
31
+
32
+ ## React error boundary
33
+
34
+ `WatchErrorBoundary` catches errors thrown while React renders its descendants:
55
35
 
56
36
  ```tsx
57
- import { ErrorBoundary } from "@/components/ErrorBoundary";
37
+ import { WatchErrorBoundary } from "@catdoes/watch/react";
58
38
 
59
- export default function RootLayout() {
39
+ export function AppBoundary({ children }: { children: React.ReactNode }) {
60
40
  return (
61
- <ErrorBoundary>
62
- {/* Your app content */}
63
- </ErrorBoundary>
41
+ <WatchErrorBoundary
42
+ fallback={({ error, resetError }) => (
43
+ <ErrorScreen message={error.message} onRetry={resetError} />
44
+ )}
45
+ >
46
+ {children}
47
+ </WatchErrorBoundary>
64
48
  );
65
49
  }
66
50
  ```
67
51
 
68
- ### 3. Capture Errors Manually (Optional)
52
+ The fallback also receives React's `errorInfo`, including its component stack.
69
53
 
70
- ```typescript
71
- import { Watch } from "@catdoes/watch";
72
-
73
- try {
74
- await riskyOperation();
75
- } catch (error) {
76
- Watch.captureError(error as Error, {
77
- context: "riskyOperation",
78
- userId: currentUser.id,
79
- });
80
- }
81
- ```
82
-
83
- ## API Reference
54
+ ## Expo Router
84
55
 
85
- ### `Watch.init(config)`
56
+ Mount `WatchNavigation` once inside the root layout. It records path changes as breadcrumbs and adds the current route to event context.
86
57
 
87
- Initializes the Watch client.
58
+ ```tsx
59
+ import { WatchNavigation } from "@catdoes/watch/expo-router";
88
60
 
89
- ```typescript
90
- interface WatchConfig {
91
- apiKey: string; // Your CatDoes Watch API key
92
- endpoint?: string; // Custom endpoint (default: CatDoes servers)
93
- environment?: "development" | "production";
94
- captureConsoleErrors?: boolean; // Capture console.error (default: false)
95
- maxBreadcrumbs?: number; // Max breadcrumbs to store (default: 20)
96
- debug?: boolean; // Enable debug logging
97
- beforeSend?: (event) => event | null; // Modify or drop events
61
+ export default function RootLayout() {
62
+ return (
63
+ <Providers>
64
+ <WatchNavigation />
65
+ <Stack />
66
+ </Providers>
67
+ );
98
68
  }
99
69
  ```
100
70
 
101
- ### `Watch.captureError(error, extra?)`
71
+ A route can use the built-in boundary:
102
72
 
103
- Manually capture an error.
104
-
105
- ```typescript
106
- Watch.captureError(new Error("Something went wrong"), {
107
- userId: "123",
108
- action: "checkout",
109
- });
110
- ```
111
-
112
- ### `Watch.captureMessage(message, level?)`
113
-
114
- Capture a message as an error.
115
-
116
- ```typescript
117
- Watch.captureMessage("User attempted invalid action", "warning");
73
+ ```ts
74
+ export {
75
+ WatchRouteErrorBoundary as ErrorBoundary,
76
+ } from "@catdoes/watch/expo-router";
118
77
  ```
119
78
 
120
- ### `Watch.addBreadcrumb(breadcrumb)`
79
+ Use the app's own fallback without duplicating capture:
121
80
 
122
- Add a breadcrumb for context.
81
+ ```tsx
82
+ import { createRouteErrorBoundary } from "@catdoes/watch/expo-router";
123
83
 
124
- ```typescript
125
- Watch.addBreadcrumb({
126
- type: "navigation",
127
- message: "User navigated to Settings",
128
- data: { screen: "Settings" },
129
- });
84
+ export const ErrorBoundary = createRouteErrorBoundary(RouteErrorFallback);
130
85
  ```
131
86
 
132
- ### `Watch.setContext(key, value)`
87
+ The fallback receives `{ error, retry }`, matching Expo Router's boundary contract.
133
88
 
134
- Set context that will be attached to all future events.
89
+ ## Manual API
135
90
 
136
- ```typescript
137
- Watch.setContext("appVersion", "1.2.3");
138
- ```
91
+ ```ts
92
+ Watch.captureError(error, { operation: "save" });
93
+ Watch.captureMessage("Import took too long", "warning");
139
94
 
140
- ### `Watch.setUser(user)`
141
-
142
- Set user information for error correlation.
143
-
144
- ```typescript
145
- Watch.setUser({
146
- id: "user123",
147
- email: "user@example.com",
95
+ Watch.addBreadcrumb({
96
+ type: "ui",
97
+ message: "Pressed save",
98
+ data: { screen: "editor" },
148
99
  });
149
- ```
150
-
151
- ### `Watch.flush()`
152
100
 
153
- Force flush all queued events to the server.
101
+ Watch.setContext("document", { id: "doc_123" });
102
+ Watch.clearContext("document");
103
+ Watch.setUser({ id: "user_123" });
154
104
 
155
- ```typescript
156
105
  await Watch.flush();
106
+ const stats = Watch.getStats();
157
107
  ```
158
108
 
159
- ## Breadcrumb Types
160
-
161
- - `navigation` - Route/screen changes
162
- - `ui` - User interactions (taps, swipes)
163
- - `http` - Network requests
164
- - `console` - Console logs
165
- - `custom` - Custom events
166
-
167
- ## Environment Variables
109
+ `captureError` accepts any thrown value. Strings and error-like objects become `Error` instances without losing their supplied message, name, or stack.
110
+
111
+ `flush()` sends batches until the queue is empty or a batch fails. Retryable failures leave events queued and schedule the next attempt. Authentication failures disable the transport.
112
+
113
+ ## Configuration
114
+
115
+ | Option | Default | Purpose |
116
+ | --- | --- | --- |
117
+ | `apiKey` | Required | Watch project key. An empty string disables the SDK. |
118
+ | `endpoint` | `https://app.catdoes.com/api/watch/ingest` | Ingestion endpoint. |
119
+ | `environment` | `development` when `__DEV__`, otherwise `production` | Event environment. |
120
+ | `debug` | `false` | Writes SDK diagnostics to the console. |
121
+ | `installGlobalHandlers` | `true` | Installs native or browser global error hooks. |
122
+ | `captureConsoleErrors` | `false` | Reports eligible `console.error` calls. |
123
+ | `captureHttpBreadcrumbs` | `false` | Wraps `fetch` and records request outcomes. Queries are removed. |
124
+ | `maxBreadcrumbs` | `20` | Number of recent breadcrumbs attached to an event. |
125
+ | `maxBufferSize` | `10` | Events sent in one request. |
126
+ | `flushInterval` | `5000` | Delay before an automatic flush, in milliseconds. |
127
+ | `fatalFlushTimeoutMs` | `2000` | Maximum production wait before handing a native fatal to React Native. Use `0` for no wait. |
128
+ | `beforeSend` | Identity function | Changes an event or returns `null` to drop it. |
129
+ | `initialContext` | `{}` | Context copied into every event. |
130
+ | `dedupWindowMs` | `5000` | Time in which matching errors count as duplicates. |
131
+ | `dedupMaxEntries` | `500` | Maximum recent deduplication keys in memory. |
132
+ | `storage` | Platform default | Synchronous `QueueStore` replacement for persistence. |
133
+
134
+ ## Automatic capture
135
+
136
+ | Source | Native | Web |
137
+ | --- | --- | --- |
138
+ | React error boundary | Yes | Yes |
139
+ | Expo Router error boundary | Yes | Yes |
140
+ | Global JavaScript errors | Yes | Yes |
141
+ | Unhandled promise rejections | Yes, in development and release | Yes |
142
+ | Fatal render errors outside a boundary | Yes | Browser global error event |
143
+ | `console.error` | Opt in | Opt in |
144
+ | HTTP breadcrumbs | Opt in | Opt in |
145
+
146
+ On native production builds, Watch persists a fatal event synchronously, tries to flush for at most `fatalFlushTimeoutMs`, then calls React Native's original fatal handler. The app still crashes as React Native intended. If the request does not finish, Watch sends the stored event on the next launch.
147
+
148
+ ## Persistence
149
+
150
+ Native apps store up to 100 events and 512 KB in Expo's cache directory. Web apps use `localStorage`. The oldest events leave the queue first when either limit is reached. Watch removes an event only after a successful response.
151
+
152
+ Watch writes the first queue change immediately, then combines writes made within the next 500 milliseconds. Fatal errors and app lifecycle handoffs force an immediate write before flushing.
153
+
154
+ You can provide a synchronous store:
155
+
156
+ ```ts
157
+ import type { QueueStore } from "@catdoes/watch";
158
+
159
+ const store: QueueStore = {
160
+ read: () => null,
161
+ write: (value) => saveImmediately(value),
162
+ remove: () => removeImmediately(),
163
+ };
164
+ ```
168
165
 
169
- Set your API key in your environment:
166
+ ## Privacy
170
167
 
171
- ```env
172
- EXPO_PUBLIC_CATDOES_WATCH_KEY=your_api_key_here
173
- ```
168
+ Device context may include hardware model, OS and build identifiers, app metadata, screen dimensions, locale, timezone, JavaScript engine, and the web browser user agent. It does not collect network addresses or AsyncStorage contents. Use `beforeSend` to remove application context your product should not transmit.
174
169
 
175
- ## Support
170
+ ## Migrating from 1.x
176
171
 
177
- - Documentation: https://catdoes.watch/docs
178
- - Support: support@catdoes.com
172
+ - Remove the AsyncStorage import and the `storage: AsyncStorage` option unless you provide a custom synchronous `QueueStore`.
173
+ - Remove manual `setupGlobalHandlers(client)` calls. `Watch.init` now installs handlers. Set `installGlobalHandlers: false` only when another integration owns them.
174
+ - Replace `setupConsoleErrorCapture` with `captureConsoleErrors: true`.
175
+ - Replace `getCachedDeviceInfo()` with `getDeviceInfo()`.
176
+ - Expect the old 1.x AsyncStorage queue to be left behind. Watch does not import AsyncStorage to migrate it.
179
177
 
180
178
  ## License
181
179
 
182
- Proprietary - All rights reserved by CatDoes.
180
+ MIT
@@ -0,0 +1,15 @@
1
+ import { ErrorBoundaryProps } from 'expo-router';
2
+ import React from 'react';
3
+
4
+ declare function useWatchNavigation(): void;
5
+ declare function WatchNavigation(): null;
6
+ type WatchRouteFallbackProps = {
7
+ error: Error;
8
+ retry: () => Promise<void>;
9
+ };
10
+ declare function createRouteErrorBoundary(Fallback: React.ComponentType<WatchRouteFallbackProps>, options?: {
11
+ captureErrors?: boolean;
12
+ }): React.ComponentType<ErrorBoundaryProps>;
13
+ declare const WatchRouteErrorBoundary: React.ComponentType<ErrorBoundaryProps>;
14
+
15
+ export { WatchNavigation, WatchRouteErrorBoundary, type WatchRouteFallbackProps, createRouteErrorBoundary, useWatchNavigation };
@@ -0,0 +1,15 @@
1
+ import { ErrorBoundaryProps } from 'expo-router';
2
+ import React from 'react';
3
+
4
+ declare function useWatchNavigation(): void;
5
+ declare function WatchNavigation(): null;
6
+ type WatchRouteFallbackProps = {
7
+ error: Error;
8
+ retry: () => Promise<void>;
9
+ };
10
+ declare function createRouteErrorBoundary(Fallback: React.ComponentType<WatchRouteFallbackProps>, options?: {
11
+ captureErrors?: boolean;
12
+ }): React.ComponentType<ErrorBoundaryProps>;
13
+ declare const WatchRouteErrorBoundary: React.ComponentType<ErrorBoundaryProps>;
14
+
15
+ export { WatchNavigation, WatchRouteErrorBoundary, type WatchRouteFallbackProps, createRouteErrorBoundary, useWatchNavigation };
@@ -0,0 +1 @@
1
+ "use strict";var e=require("expo-router"),t=require("react"),r=require("react-native"),n=require("react/jsx-runtime"),o=Symbol.for("@catdoes/watch/runtime-state-v2");function i(){const e=globalThis;return e[o]||(e[o]={capturedErrors:new WeakSet,handoffDepth:0,activeClient:null,clientInstance:null}),e[o]}function c(){return i().activeClient}function l(){const r=e.usePathname(),n=`/${e.useSegments().join("/")}`,o=t.useRef(null);t.useEffect(()=>{const e=o.current;if(e===r)return;const t=c();t?.addBreadcrumb({type:"navigation",message:null===e?`Initial route ${r}`:`${e} → ${r}`,data:{from:e,to:r,pattern:n,initial:null===e}}),t?.setContext("route",{pathname:r,pattern:n}),o.current=r},[r,n])}function u(r,o){const l=({error:l,retry:u})=>{const a=e.usePathname();return t.useEffect(()=>{!1===o?.captureErrors||function(e){return("object"==typeof e&&null!==e||"function"==typeof e)&&i().capturedErrors.has(e)}(l)||c()?.captureError(l,{source:"expo-router.ErrorBoundary",route:a})},[l]),n.jsx(r,{error:l,retry:u})};return l.displayName=`WatchRouteErrorBoundary(${r.displayName??r.name??"Fallback"})`,l}var a=u(function({error:e,retry:t}){return n.jsxs(r.View,{style:s.container,children:[n.jsx(r.View,{style:s.icon,children:n.jsx(r.Text,{style:s.iconText,children:"!"})}),n.jsx(r.Text,{style:s.title,children:"Something went wrong"}),n.jsx(r.Text,{style:s.message,children:e.message||"An unexpected error occurred."}),n.jsx(r.Pressable,{accessibilityRole:"button",onPress:()=>{t()},style:({pressed:e})=>[s.button,e&&s.buttonPressed],children:n.jsx(r.Text,{style:s.buttonText,children:"Try again"})})]})}),s=r.StyleSheet.create({container:{flex:1,alignItems:"center",justifyContent:"center",padding:24,backgroundColor:"#ffffff"},icon:{width:48,height:48,borderRadius:24,alignItems:"center",justifyContent:"center",backgroundColor:"#fee2e2",marginBottom:16},iconText:{color:"#b91c1c",fontSize:28,fontWeight:"700"},title:{color:"#111827",fontSize:22,fontWeight:"700"},message:{color:"#6b7280",fontSize:15,lineHeight:22,textAlign:"center",marginTop:8},button:{minHeight:44,justifyContent:"center",borderRadius:12,backgroundColor:"#111827",paddingHorizontal:20,marginTop:24},buttonPressed:{transform:[{scale:.97}],opacity:.9},buttonText:{color:"#ffffff",fontSize:15,fontWeight:"600"}});exports.WatchNavigation=function(){return l(),null},exports.WatchRouteErrorBoundary=a,exports.createRouteErrorBoundary=u,exports.useWatchNavigation=l;
@@ -0,0 +1 @@
1
+ import{usePathname as t,useSegments as e}from"expo-router";import{useRef as r,useEffect as n}from"react";import{StyleSheet as o,View as i,Text as c,Pressable as l}from"react-native";import{jsx as a,jsxs as u}from"react/jsx-runtime";var f=Symbol.for("@catdoes/watch/runtime-state-v2");function s(){const t=globalThis;return t[f]||(t[f]={capturedErrors:new WeakSet,handoffDepth:0,activeClient:null,clientInstance:null}),t[f]}function d(){return s().activeClient}function g(){const o=t(),i=`/${e().join("/")}`,c=r(null);n(()=>{const t=c.current;if(t===o)return;const e=d();e?.addBreadcrumb({type:"navigation",message:null===t?`Initial route ${o}`:`${t} → ${o}`,data:{from:t,to:o,pattern:i,initial:null===t}}),e?.setContext("route",{pathname:o,pattern:i}),c.current=o},[o,i])}function m(){return g(),null}function h(e,r){const o=({error:o,retry:i})=>{const c=t();return n(()=>{!1===r?.captureErrors||function(t){return("object"==typeof t&&null!==t||"function"==typeof t)&&s().capturedErrors.has(t)}(o)||d()?.captureError(o,{source:"expo-router.ErrorBoundary",route:c})},[o]),a(e,{error:o,retry:i})};return o.displayName=`WatchRouteErrorBoundary(${e.displayName??e.name??"Fallback"})`,o}var p=h(function({error:t,retry:e}){return u(i,{style:y.container,children:[a(i,{style:y.icon,children:a(c,{style:y.iconText,children:"!"})}),a(c,{style:y.title,children:"Something went wrong"}),a(c,{style:y.message,children:t.message||"An unexpected error occurred."}),a(l,{accessibilityRole:"button",onPress:()=>{e()},style:({pressed:t})=>[y.button,t&&y.buttonPressed],children:a(c,{style:y.buttonText,children:"Try again"})})]})}),y=o.create({container:{flex:1,alignItems:"center",justifyContent:"center",padding:24,backgroundColor:"#ffffff"},icon:{width:48,height:48,borderRadius:24,alignItems:"center",justifyContent:"center",backgroundColor:"#fee2e2",marginBottom:16},iconText:{color:"#b91c1c",fontSize:28,fontWeight:"700"},title:{color:"#111827",fontSize:22,fontWeight:"700"},message:{color:"#6b7280",fontSize:15,lineHeight:22,textAlign:"center",marginTop:8},button:{minHeight:44,justifyContent:"center",borderRadius:12,backgroundColor:"#111827",paddingHorizontal:20,marginTop:24},buttonPressed:{transform:[{scale:.97}],opacity:.9},buttonText:{color:"#ffffff",fontSize:15,fontWeight:"600"}});export{m as WatchNavigation,p as WatchRouteErrorBoundary,h as createRouteErrorBoundary,g as useWatchNavigation};