@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 +126 -128
- package/dist/expo-router.d.mts +15 -0
- package/dist/expo-router.d.ts +15 -0
- package/dist/expo-router.js +1 -0
- package/dist/expo-router.mjs +1 -0
- package/dist/index.d.mts +69 -315
- package/dist/index.d.ts +69 -315
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/react.d.mts +2 -30
- package/dist/react.d.ts +2 -30
- package/dist/react.js +1 -1
- package/dist/react.mjs +1 -1
- package/package.json +48 -13
package/README.md
CHANGED
|
@@ -1,182 +1,180 @@
|
|
|
1
1
|
# @catdoes/watch
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
## Requirements
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
# or
|
|
21
|
-
yarn add @catdoes/watch
|
|
12
|
+
npx expo install @catdoes/watch expo-constants expo-device
|
|
22
13
|
```
|
|
23
14
|
|
|
24
|
-
|
|
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
|
|
17
|
+
## Quick start
|
|
32
18
|
|
|
33
|
-
|
|
19
|
+
Initialize Watch before the root layout renders:
|
|
34
20
|
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 {
|
|
37
|
+
import { WatchErrorBoundary } from "@catdoes/watch/react";
|
|
58
38
|
|
|
59
|
-
export
|
|
39
|
+
export function AppBoundary({ children }: { children: React.ReactNode }) {
|
|
60
40
|
return (
|
|
61
|
-
<
|
|
62
|
-
{
|
|
63
|
-
|
|
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
|
-
|
|
52
|
+
The fallback also receives React's `errorInfo`, including its component stack.
|
|
69
53
|
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
```tsx
|
|
59
|
+
import { WatchNavigation } from "@catdoes/watch/expo-router";
|
|
88
60
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
71
|
+
A route can use the built-in boundary:
|
|
102
72
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
79
|
+
Use the app's own fallback without duplicating capture:
|
|
121
80
|
|
|
122
|
-
|
|
81
|
+
```tsx
|
|
82
|
+
import { createRouteErrorBoundary } from "@catdoes/watch/expo-router";
|
|
123
83
|
|
|
124
|
-
|
|
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
|
-
|
|
87
|
+
The fallback receives `{ error, retry }`, matching Expo Router's boundary contract.
|
|
133
88
|
|
|
134
|
-
|
|
89
|
+
## Manual API
|
|
135
90
|
|
|
136
|
-
```
|
|
137
|
-
Watch.
|
|
138
|
-
|
|
91
|
+
```ts
|
|
92
|
+
Watch.captureError(error, { operation: "save" });
|
|
93
|
+
Watch.captureMessage("Import took too long", "warning");
|
|
139
94
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
166
|
+
## Privacy
|
|
170
167
|
|
|
171
|
-
|
|
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
|
-
##
|
|
170
|
+
## Migrating from 1.x
|
|
176
171
|
|
|
177
|
-
-
|
|
178
|
-
-
|
|
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
|
-
|
|
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};
|