@blario/react-native 0.1.6 → 0.1.7

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
@@ -407,7 +407,10 @@ If trusted publishing is not wanted, a granular npm token (Read and write, scope
407
407
  Like the web widget, the SDK reports its own contained failures to Blar's Sentry, through a client that is isolated from
408
408
  the host app: it installs no global error handler and no native crash hook, never touches the app's own Sentry, and
409
409
  only sees errors the SDK catches itself (the agent runtime, the backend transport and a boundary around the SDK's own
410
- UI, never the app's components). Events carry the SDK version, platform, publishable key, `source_id`, session id and
410
+ UI, never the app's components). An error in a socket callback, timer, native listener, animation callback or press
411
+ handler would otherwise end the app in a release build, or on Android's bridgeless runtime leave it on a white screen
412
+ with the process alive, so each goes through `contain()` (`src/telemetry/contain.ts`) and a test fails on one that
413
+ doesn't. A native crash can't be contained from JS and isn't reported here: it shows up in the app's own crash reporting. Events carry the SDK version, platform, publishable key, `source_id`, session id and
411
414
  the user's external id; emails, bearer tokens and identity tokens are scrubbed from messages. Network failures and
412
415
  refused credentials only become breadcrumbs, and at most 50 events are sent per launch. Without a DSN (the repo
413
416
  default, set only by the release job) nothing is sent. `enabled={false}` stops the SDK, and its reporting with it.
@@ -48,12 +48,16 @@ export declare class BlarProvider extends Component<BlarProviderProps, BlarProvi
48
48
  private readonly stacking;
49
49
  private readonly attachCursor;
50
50
  private readonly detachCursor;
51
+ private readonly noteInteraction;
51
52
  componentDidMount(): void;
52
53
  componentDidUpdate(previous: BlarProviderProps): void;
53
54
  componentWillUnmount(): void;
55
+ private startContained;
56
+ private syncProps;
54
57
  private startSdk;
55
58
  private stopSdk;
56
59
  render(): React.JSX.Element;
60
+ private renderOverlay;
57
61
  private renderAgentLayer;
58
62
  private renderChat;
59
63
  private chatOptions;
@@ -0,0 +1,5 @@
1
+ type Report = (context: string, error: unknown) => void;
2
+ export declare function setContainmentReporter(reporter: Report): void;
3
+ export declare function attempt<T>(context: string, run: () => T): T | undefined;
4
+ export declare function contain<Args extends unknown[]>(context: string, callback: (...args: Args) => unknown): (...args: Args) => void;
5
+ export {};
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.1.6";
1
+ export declare const SDK_VERSION = "0.1.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blario/react-native",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Blar agent SDK for React Native",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  import React, { Component, createRef, type ReactNode } from 'react';
2
- import { AccessibilityInfo, AppState, Dimensions, Keyboard, StyleSheet, View } from 'react-native';
2
+ import { AccessibilityInfo, AppState, type AppStateStatus, Dimensions, Keyboard, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { createAgentController } from './agent/compose.ts';
5
5
  import { SYSTEM_CLOCK } from './actions/settle.ts';
@@ -31,7 +31,9 @@ import { visibleViewport } from './host/visible-area.ts';
31
31
  import { AgentOverlay } from './overlay/AgentOverlay.tsx';
32
32
  import { CAPTURE_BACKEND } from './screenshot/screenshotter.ts';
33
33
  import { countOpenModals } from './snapshot/modals.ts';
34
+ import { attempt, contain } from './telemetry/contain.ts';
34
35
  import { SdkErrorBoundary } from './telemetry/SdkErrorBoundary.tsx';
36
+ import { reportSdkError } from './telemetry/sdk-reporter.ts';
35
37
  import { resolveConnectionOptions, type ConnectionConfig } from './transport/connection-options.ts';
36
38
  import { RelayTransport } from './transport/relay-transport.ts';
37
39
  import type { AgentTransport } from './transport/transport.ts';
@@ -118,17 +120,37 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
118
120
 
119
121
  private readonly detachCursor = (): void => this.cursor.detach();
120
122
 
123
+ private readonly noteInteraction = contain('BlarProvider.touch', () => this.backend?.noteInteraction());
124
+
121
125
  override componentDidMount(): void {
122
126
  if (this.props.enabled !== false) {
123
- this.startSdk();
127
+ this.startContained();
124
128
  }
125
129
  }
126
130
 
127
131
  override componentDidUpdate(previous: BlarProviderProps): void {
132
+ attempt('BlarProvider.update', () => this.syncProps(previous));
133
+ }
134
+
135
+ override componentWillUnmount(): void {
136
+ attempt('BlarProvider.unmount', () => this.stopSdk());
137
+ }
138
+
139
+ // A failed start leaves the host app running, without the SDK.
140
+ private startContained(): void {
141
+ try {
142
+ this.startSdk();
143
+ } catch (error) {
144
+ reportSdkError('BlarProvider.start', error);
145
+ attempt('BlarProvider.stop', () => this.stopSdk());
146
+ }
147
+ }
148
+
149
+ private syncProps(previous: BlarProviderProps): void {
128
150
  const enabled = this.props.enabled !== false;
129
151
  if (enabled !== (previous.enabled !== false)) {
130
152
  if (enabled) {
131
- this.startSdk();
153
+ this.startContained();
132
154
  } else {
133
155
  this.stopSdk();
134
156
  this.setState({ chat: null, agentMode: null, spotlight: this.spotlight.getSnapshot(), cursor: this.cursor.getSnapshot() });
@@ -145,10 +167,6 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
145
167
  void Blar.setUser(this.props.user);
146
168
  }
147
169
 
148
- override componentWillUnmount(): void {
149
- this.stopSdk();
150
- }
151
-
152
170
  private startSdk(): void {
153
171
  this.connection = resolveConnectionOptions(this.props, typeof __DEV__ !== 'undefined' && __DEV__);
154
172
  const transport = this.createTransport();
@@ -204,25 +222,29 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
204
222
  }
205
223
 
206
224
  override render(): React.JSX.Element {
207
- const strings = resolveStrings(this.props.language ?? 'es', this.props.strings);
208
- const phase = this.state.agentMode?.phase ?? 'idle';
209
225
  return (
210
- <View style={styles.root} collapsable={false} onTouchStart={() => this.backend?.noteInteraction()}>
226
+ <View style={styles.root} collapsable={false} onTouchStart={this.noteInteraction}>
211
227
  <View ref={this.contentRef} style={styles.root} collapsable={false}>
212
228
  {this.props.children}
213
229
  </View>
214
- <SdkErrorBoundary context="BlarProvider.overlay">
215
- <AgentOverlay>
216
- {this.renderChat(strings)}
217
- <AgentLayerHost visible={phase !== 'idle'} generation={this.state.layerGeneration} onRequestClose={() => this.agentMode?.stop()}>
218
- {this.renderAgentLayer(strings)}
219
- </AgentLayerHost>
220
- </AgentOverlay>
221
- </SdkErrorBoundary>
230
+ <SdkErrorBoundary context="BlarProvider.overlay">{attempt('BlarProvider.render', () => this.renderOverlay())}</SdkErrorBoundary>
222
231
  </View>
223
232
  );
224
233
  }
225
234
 
235
+ private renderOverlay(): React.JSX.Element {
236
+ const strings = resolveStrings(this.props.language ?? 'es', this.props.strings);
237
+ const phase = this.state.agentMode?.phase ?? 'idle';
238
+ return (
239
+ <AgentOverlay>
240
+ {this.renderChat(strings)}
241
+ <AgentLayerHost visible={phase !== 'idle'} generation={this.state.layerGeneration} onRequestClose={() => this.agentMode?.stop()}>
242
+ {this.renderAgentLayer(strings)}
243
+ </AgentLayerHost>
244
+ </AgentOverlay>
245
+ );
246
+ }
247
+
226
248
  private renderAgentLayer(strings: UiStrings): React.JSX.Element | null {
227
249
  const { agentMode } = this.state;
228
250
  if (agentMode === null || agentMode.phase === 'idle') {
@@ -280,17 +302,20 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
280
302
  }
281
303
 
282
304
  private watchForeground(): void {
283
- const subscription = AppState.addEventListener('change', (state) => {
284
- if (state !== 'active') {
285
- this.spotlight.backgrounded();
286
- }
287
- });
305
+ const subscription = AppState.addEventListener(
306
+ 'change',
307
+ contain('BlarProvider.foreground', (state: AppStateStatus) => {
308
+ if (state !== 'active') {
309
+ this.spotlight.backgrounded();
310
+ }
311
+ }),
312
+ );
288
313
  this.releases.push(() => subscription.remove());
289
314
  }
290
315
 
291
316
  private watchMotionPreference(): void {
292
317
  void AccessibilityInfo.isReduceMotionEnabled().then((reduceMotion) => this.setState({ reduceMotion }));
293
- const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (reduceMotion) => this.setState({ reduceMotion }));
318
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', contain('BlarProvider.motion', (reduceMotion: boolean) => this.setState({ reduceMotion })));
294
319
  this.releases.push(() => subscription.remove());
295
320
  }
296
321
 
@@ -413,10 +438,12 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
413
438
  return;
414
439
  }
415
440
  this.stackingCheckQueued = true;
416
- requestAnimationFrame(() => {
417
- this.stackingCheckQueued = false;
418
- this.keepLayerOnTop();
419
- });
441
+ requestAnimationFrame(
442
+ contain('BlarProvider.stacking', () => {
443
+ this.stackingCheckQueued = false;
444
+ this.keepLayerOnTop();
445
+ }),
446
+ );
420
447
  }
421
448
 
422
449
  private keepLayerOnTop(): void {
@@ -1,3 +1,5 @@
1
+ import { contain } from '../telemetry/contain.ts';
2
+
1
3
  export interface KeyboardPort {
2
4
  addListener: (event: 'keyboardWillShow' | 'keyboardDidShow', listener: () => void) => { remove: () => void };
3
5
  dismiss: () => void;
@@ -26,7 +28,7 @@ export class KeyboardGuard {
26
28
  if (this.subscriptions.length > 0) {
27
29
  return;
28
30
  }
29
- const dismiss = (): void => this.keyboard.dismiss();
31
+ const dismiss = contain('KeyboardGuard.dismiss', () => this.keyboard.dismiss());
30
32
  this.subscriptions = [this.keyboard.addListener('keyboardWillShow', dismiss), this.keyboard.addListener('keyboardDidShow', dismiss)];
31
33
  dismiss();
32
34
  }
@@ -1,3 +1,5 @@
1
+ import { contain } from '../telemetry/contain.ts';
2
+
1
3
  export interface Timers {
2
4
  set: (callback: () => void, milliseconds: number) => unknown;
3
5
  clear: (handle: unknown) => void;
@@ -5,7 +7,7 @@ export interface Timers {
5
7
  }
6
8
 
7
9
  export const SYSTEM_TIMERS: Timers = {
8
- set: (callback, milliseconds) => setTimeout(callback, milliseconds),
10
+ set: (callback, milliseconds) => setTimeout(contain('Timers', callback), milliseconds),
9
11
  clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
10
12
  now: () => Date.now(),
11
13
  };
@@ -7,6 +7,7 @@ import { chooseDock, freeBand, messageCap, type PanelDock, STACK_GAP } from '../
7
7
  import type { SpotlightSnapshot } from '../spotlight-controller.ts';
8
8
  import { type ActiveSpotlight, AgentPanel, panelChrome } from './AgentPanel.tsx';
9
9
  import { StatusCard, type StatusCorners } from './StatusCard.tsx';
10
+ import { contain } from '../../telemetry/contain.ts';
10
11
 
11
12
  export interface DockActions {
12
13
  place: (id: number, band: Band) => void | Promise<void>;
@@ -88,7 +89,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
88
89
  const pending = useRef<Array<ReturnType<typeof setTimeout>>>([]);
89
90
 
90
91
  const later = (callback: () => void, milliseconds: number): void => {
91
- pending.current.push(setTimeout(callback, milliseconds));
92
+ pending.current.push(setTimeout(contain('AgentDock.later', callback), milliseconds));
92
93
  };
93
94
 
94
95
  const rest = (): void => {
@@ -118,15 +119,15 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
118
119
  const duration = reduceMotion ? REDUCED_MS : CLOSE_MS;
119
120
  const easing = reduceMotion ? Easing.linear : CLOSE;
120
121
  if (dockRef.current === 'bottom') {
121
- Animated.parallel([animate(panelOpacity, 0, duration, easing), animate(panelShift, reduceMotion ? 0 : ENTER_SHIFT, duration, easing)]).start(() => {
122
+ Animated.parallel([animate(panelOpacity, 0, duration, easing), animate(panelShift, reduceMotion ? 0 : ENTER_SHIFT, duration, easing)]).start(contain('AgentDock.close', () => {
122
123
  if (presentedId.current === null) {
123
124
  rest();
124
125
  setPresented(null);
125
126
  }
126
- });
127
+ }));
127
128
  return;
128
129
  }
129
- Animated.parallel([animate(stackOpacity, 0, duration, easing), animate(stackShift, reduceMotion ? 0 : -ENTER_SHIFT, duration, easing)]).start(() => {
130
+ Animated.parallel([animate(stackOpacity, 0, duration, easing), animate(stackShift, reduceMotion ? 0 : -ENTER_SHIFT, duration, easing)]).start(contain('AgentDock.closeTop', () => {
130
131
  if (presentedId.current !== null) {
131
132
  return;
132
133
  }
@@ -134,7 +135,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
134
135
  stackOpacity.setValue(0);
135
136
  setPresented(null);
136
137
  later(() => animate(stackOpacity, 1, STACK_RETURN_MS, Easing.out(Easing.ease)).start(), STACK_RETURN_PAUSE_MS);
137
- });
138
+ }));
138
139
  };
139
140
 
140
141
  useEffect(() => {
@@ -193,7 +194,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
193
194
  Animated.parallel([animate(panelOpacity, 1, duration, easing), animate(panelShift, 0, duration, easing)]).start();
194
195
  return;
195
196
  }
196
- animate(stackOpacity, 0, STACK_FADE_MS, Easing.out(Easing.ease)).start(() => {
197
+ animate(stackOpacity, 0, STACK_FADE_MS, Easing.out(Easing.ease)).start(contain('AgentDock.moveTop', () => {
197
198
  later(() => {
198
199
  if (presentedId.current !== presented.id) {
199
200
  return;
@@ -205,7 +206,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
205
206
  stackShift.setValue(reduceMotion ? 0 : -ENTER_SHIFT);
206
207
  Animated.parallel([animate(stackOpacity, 1, duration, easing), animate(stackShift, 0, duration, easing)]).start();
207
208
  }, STACK_SWAP_PAUSE_MS);
208
- });
209
+ }));
209
210
  }, [presented]);
210
211
 
211
212
  useEffect(() => {
@@ -14,6 +14,7 @@ import { MessageBubbles } from './MessageBubbles.tsx';
14
14
  import { PendingOverlay } from './PendingOverlay.tsx';
15
15
  import { SpotlightLayer } from './SpotlightLayer.tsx';
16
16
  import { TouchShield } from './TouchShield.tsx';
17
+ import { contain } from '../../telemetry/contain.ts';
17
18
 
18
19
  export interface AgentLayerProps {
19
20
  snapshot: AgentModeSnapshot;
@@ -64,7 +65,7 @@ function useBlockedTap(): { toast: boolean; pulse: number; block: () => void } {
64
65
  if (timer.current !== null) {
65
66
  clearTimeout(timer.current);
66
67
  }
67
- timer.current = setTimeout(() => setToast(false), TOAST_MS);
68
+ timer.current = setTimeout(contain('AgentLayer.toast', () => setToast(false)), TOAST_MS);
68
69
  };
69
70
  return { toast, pulse, block };
70
71
  }
@@ -3,6 +3,7 @@ import { Modal, Platform, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { loadFullWindowOverlay } from './full-window-overlay.ts';
5
5
  import { chooseLayerHost } from './layer-host.ts';
6
+ import { contain } from '../../telemetry/contain.ts';
6
7
 
7
8
  export interface AgentLayerHostProps {
8
9
  visible: boolean;
@@ -20,7 +21,7 @@ export function AgentLayerHost({ visible, generation, onRequestClose, children }
20
21
  }
21
22
  if (HOST === 'modal') {
22
23
  return (
23
- <Modal key={generation} visible transparent animationType="none" hardwareAccelerated onRequestClose={onRequestClose}>
24
+ <Modal key={generation} visible transparent animationType="none" hardwareAccelerated onRequestClose={contain('AgentLayerHost.close', onRequestClose)}>
24
25
  {children}
25
26
  </Modal>
26
27
  );
@@ -9,6 +9,7 @@ import type { PanelDock } from '../panel-geometry.ts';
9
9
  import type { SpotlightSnapshot } from '../spotlight-controller.ts';
10
10
  import { MarkdownText } from './MarkdownText.tsx';
11
11
  import { WarningIcon } from './WarningIcon.tsx';
12
+ import { contain } from '../../telemetry/contain.ts';
12
13
 
13
14
  export type ActiveSpotlight = Exclude<SpotlightSnapshot, { kind: 'none' }>;
14
15
 
@@ -104,7 +105,7 @@ export function AgentPanel({ spotlight, dock, strings, color, messageMaxHeight,
104
105
  {spotlight.kind === 'reading' ? (
105
106
  <View style={styles.progressRow}>
106
107
  <Progress durationMs={spotlight.durationMs} running={spotlight.shown} color={color} />
107
- <Pressable accessibilityRole="button" onPress={onSkip} style={styles.skip}>
108
+ <Pressable accessibilityRole="button" onPress={contain('AgentPanel.skip', onSkip)} style={styles.skip}>
108
109
  <Text style={styles.skipText}>{strings.readingSkip}</Text>
109
110
  </Pressable>
110
111
  </View>
@@ -117,12 +118,12 @@ export function AgentPanel({ spotlight, dock, strings, color, messageMaxHeight,
117
118
  </View>
118
119
  ) : null}
119
120
  <View style={[styles.buttons, destructive ? styles.destructiveButtons : null]}>
120
- <Pressable accessibilityRole="button" onPress={onStop} style={[styles.button, styles.stop]}>
121
+ <Pressable accessibilityRole="button" onPress={contain('AgentPanel.stop', onStop)} style={[styles.button, styles.stop]}>
121
122
  <Text style={[styles.buttonText, { color: INK }]}>{strings.confirmStop}</Text>
122
123
  </Pressable>
123
124
  <Pressable
124
125
  accessibilityRole="button"
125
- onPress={onConfirm}
126
+ onPress={contain('AgentPanel.confirm', onConfirm)}
126
127
  style={[styles.button, destructive ? { backgroundColor: DESTRUCTIVE } : [styles.confirmButton, { backgroundColor: color }]]}
127
128
  >
128
129
  <Text style={[styles.buttonText, { color: destructive ? '#ffffff' : onColor(color) }]}>
@@ -3,6 +3,7 @@ import { Animated, Easing, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { centerOf, type Point } from '../../host/rect.ts';
5
5
  import type { CursorSnapshot } from '../cursor.ts';
6
+ import { contain } from '../../telemetry/contain.ts';
6
7
 
7
8
  export interface AgentTapProps {
8
9
  snapshot: CursorSnapshot;
@@ -90,7 +91,7 @@ function TapRing({ point, landed, ending, color, reduceMotion, onEnded }: TapRin
90
91
 
91
92
  useEffect(() => {
92
93
  if (ending) {
93
- timing(opacity, 0, END_FADE_MS, EASE_OUT).start(() => ended.current());
94
+ timing(opacity, 0, END_FADE_MS, EASE_OUT).start(contain('AgentTap.ended', () => ended.current()));
94
95
  }
95
96
  }, [ending, opacity, ended]);
96
97
 
@@ -107,7 +108,7 @@ function TapRipple({ point, onDone }: { point: Point; onDone: () => void }): Rea
107
108
  const progress = useRef(new Animated.Value(0)).current;
108
109
  const done = useLatest(onDone);
109
110
  useEffect(() => {
110
- timing(progress, 1, RIPPLE_MS, RIPPLE, PRESS_MS).start(() => done.current());
111
+ timing(progress, 1, RIPPLE_MS, RIPPLE, PRESS_MS).start(contain('AgentTap.ripple', () => done.current()));
111
112
  }, [progress, done]);
112
113
  return (
113
114
  <Animated.View
@@ -5,6 +5,7 @@ import type { SpotlightSnapshot } from '../spotlight-controller.ts';
5
5
  import type { ActiveSpotlight } from './AgentPanel.tsx';
6
6
  import { SPOTLIGHT_DIM, SpotlightOverlay } from './SpotlightOverlay.tsx';
7
7
  import { TargetPulse } from './TargetPulse.tsx';
8
+ import { contain } from '../../telemetry/contain.ts';
8
9
 
9
10
  export interface SpotlightLayerProps {
10
11
  spotlight: SpotlightSnapshot;
@@ -33,11 +34,11 @@ export function SpotlightLayer({ spotlight, color, reduceMotion, onBackdropPress
33
34
  Animated.timing(opacity, { toValue: 1, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start();
34
35
  return;
35
36
  }
36
- Animated.timing(opacity, { toValue: 0, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start(({ finished }) => {
37
+ Animated.timing(opacity, { toValue: 0, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start(contain('SpotlightLayer.fade', ({ finished }: { finished: boolean }) => {
37
38
  if (finished) {
38
39
  setGone(true);
39
40
  }
40
- });
41
+ }));
41
42
  }, [visible, reduceMotion, opacity]);
42
43
 
43
44
  const current = last.current;
@@ -46,7 +47,7 @@ export function SpotlightLayer({ spotlight, color, reduceMotion, onBackdropPress
46
47
  }
47
48
  return (
48
49
  <Animated.View pointerEvents={visible ? 'box-none' : 'none'} style={[StyleSheet.absoluteFill, { opacity }]}>
49
- {current.kind === 'confirming' ? <Pressable style={StyleSheet.absoluteFill} onPress={onBackdropPress} /> : null}
50
+ {current.kind === 'confirming' ? <Pressable style={StyleSheet.absoluteFill} onPress={contain('SpotlightLayer.backdrop', onBackdropPress)} /> : null}
50
51
  {current.hole === null ? <View pointerEvents="none" style={styles.dim} /> : <SpotlightOverlay hole={current.hole} />}
51
52
  {current.kind === 'confirming' && current.target !== null ? (
52
53
  <TargetPulse target={current.target} radius={current.request.target?.radius ?? 0} color={color} reduceMotion={reduceMotion} />
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
3
3
 
4
4
  import { onColor, withAlpha } from '../colors.ts';
5
+ import { contain } from '../../telemetry/contain.ts';
5
6
 
6
7
  export interface StatusCardProps {
7
8
  status: string;
@@ -54,7 +55,7 @@ function useStopPulse(pulse: number): { scale: Animated.Value; bordered: boolean
54
55
  ]);
55
56
  const animation = Animated.loop(beat, { iterations: PULSE_TIMES });
56
57
  animation.start();
57
- const timer = setTimeout(() => setBordered(false), BORDER_MS);
58
+ const timer = setTimeout(contain('StatusCard.border', () => setBordered(false)), BORDER_MS);
58
59
  return () => {
59
60
  animation.stop();
60
61
  clearTimeout(timer);
@@ -83,7 +84,7 @@ export function StatusCard({ status, queued, stopLabel, color, stopHidden, corne
83
84
  accessibilityLabel={stopLabel}
84
85
  hitSlop={STOP_HIT_SLOP}
85
86
  style={[styles.stop, { backgroundColor: withAlpha(ink, 0.2) }, bordered ? { borderWidth: 2, borderColor: withAlpha(ink, 0.8) } : null]}
86
- onPress={onStop}
87
+ onPress={contain('StatusCard.stop', onStop)}
87
88
  >
88
89
  <View style={[styles.stopIcon, { backgroundColor: ink }]} />
89
90
  </Pressable>
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { StyleSheet, View } from 'react-native';
3
+ import { contain } from '../../telemetry/contain.ts';
3
4
 
4
5
  export interface TouchShieldProps {
5
6
  onBlockedTap: () => void;
@@ -10,7 +11,7 @@ export function TouchShield({ onBlockedTap }: TouchShieldProps): React.JSX.Eleme
10
11
  <View
11
12
  style={styles.shield}
12
13
  onStartShouldSetResponder={() => true}
13
- onResponderRelease={onBlockedTap}
14
+ onResponderRelease={contain('TouchShield.tap', onBlockedTap)}
14
15
  accessibilityElementsHidden
15
16
  importantForAccessibility="no-hide-descendants"
16
17
  />
@@ -1,10 +1,15 @@
1
- import { AppState } from 'react-native';
1
+ import { AppState, type AppStateStatus } from 'react-native';
2
+
3
+ import { contain } from '../telemetry/contain.ts';
2
4
 
3
5
  export function watchForeground(onActive: () => void): () => void {
4
- const subscription = AppState.addEventListener('change', (state) => {
5
- if (state === 'active') {
6
- onActive();
7
- }
8
- });
6
+ const subscription = AppState.addEventListener(
7
+ 'change',
8
+ contain('watchForeground', (state: AppStateStatus) => {
9
+ if (state === 'active') {
10
+ onActive();
11
+ }
12
+ }),
13
+ );
9
14
  return () => subscription.remove();
10
15
  }
@@ -1,6 +1,7 @@
1
1
  import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
2
2
 
3
3
  import { SDK_VERSION } from '../version.ts';
4
+ import { contain } from '../telemetry/contain.ts';
4
5
 
5
6
  export interface ClientHealth {
6
7
  visibility: 'visible' | 'hidden';
@@ -27,13 +28,16 @@ export class ClientHealthProbe {
27
28
  return;
28
29
  }
29
30
  this.hiddenSince = AppState.currentState === 'active' ? null : Date.now();
30
- this.subscription = AppState.addEventListener('change', (state) => this.onStateChange(state));
31
+ this.subscription = AppState.addEventListener('change', contain('ClientHealth.state', (state: AppStateStatus) => this.onStateChange(state)));
31
32
  this.expectedTickAt = Date.now() + PROBE_INTERVAL_MS;
32
- this.timer = setInterval(() => {
33
- const now = Date.now();
34
- this.worstLagMs = Math.max(this.worstLagMs, now - this.expectedTickAt);
35
- this.expectedTickAt = now + PROBE_INTERVAL_MS;
36
- }, PROBE_INTERVAL_MS);
33
+ this.timer = setInterval(
34
+ contain('ClientHealth.probe', () => {
35
+ const now = Date.now();
36
+ this.worstLagMs = Math.max(this.worstLagMs, now - this.expectedTickAt);
37
+ this.expectedTickAt = now + PROBE_INTERVAL_MS;
38
+ }),
39
+ PROBE_INTERVAL_MS,
40
+ );
37
41
  }
38
42
 
39
43
  stop(): void {
@@ -1,6 +1,7 @@
1
- import { AppState, type NativeEventSubscription } from 'react-native';
1
+ import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
2
2
 
3
3
  import type { ApiClient } from './api-client.ts';
4
+ import { contain } from '../telemetry/contain.ts';
4
5
 
5
6
  const HEARTBEAT_INTERVAL_MS = 30_000;
6
7
  const IDLE_TIMEOUT_MS = 3 * 60_000;
@@ -29,12 +30,15 @@ export class PresenceManager {
29
30
  }
30
31
  this.started = true;
31
32
  this.lastInteraction = this.now();
32
- this.subscription = AppState.addEventListener('change', (state) => {
33
- if (state === 'active') {
34
- this.lastInteraction = this.now();
35
- this.fireNow();
36
- }
37
- });
33
+ this.subscription = AppState.addEventListener(
34
+ 'change',
35
+ contain('Presence.state', (state: AppStateStatus) => {
36
+ if (state === 'active') {
37
+ this.lastInteraction = this.now();
38
+ this.fireNow();
39
+ }
40
+ }),
41
+ );
38
42
  this.fireNow();
39
43
  }
40
44
 
@@ -71,7 +75,7 @@ export class PresenceManager {
71
75
  if (this.timer !== null) {
72
76
  clearTimeout(this.timer);
73
77
  }
74
- this.timer = setTimeout(() => this.fireNow(), HEARTBEAT_INTERVAL_MS);
78
+ this.timer = setTimeout(contain('Presence.beat', () => this.fireNow()), HEARTBEAT_INTERVAL_MS);
75
79
  }
76
80
 
77
81
  private async send(): Promise<void> {
@@ -3,6 +3,7 @@ import { backoffDelay, isExhausted, SOCKET_BACKOFF } from './backoff.ts';
3
3
  import type { IdentityProof } from './identity.ts';
4
4
  import { ResponseCache } from './response-cache.ts';
5
5
  import { buildSocketUrl } from './socket-url.ts';
6
+ import { contain } from '../telemetry/contain.ts';
6
7
 
7
8
  export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed';
8
9
 
@@ -149,7 +150,7 @@ export class SessionSocket {
149
150
  return;
150
151
  }
151
152
  this.socket = socket;
152
- socket.onopen = () => {
153
+ socket.onopen = contain('SessionSocket.open', () => {
153
154
  if (this.epoch !== epoch) {
154
155
  return;
155
156
  }
@@ -159,8 +160,8 @@ export class SessionSocket {
159
160
  this.options.onState('connected');
160
161
  socket.send(JSON.stringify({ type: 'joined' }));
161
162
  this.cache.drain().forEach((payload) => socket.send(payload));
162
- };
163
- socket.onmessage = (event) => {
163
+ });
164
+ socket.onmessage = contain('SessionSocket.message', (event: { data?: unknown }) => {
164
165
  if (this.epoch !== epoch || typeof event.data !== 'string') {
165
166
  return;
166
167
  }
@@ -170,13 +171,13 @@ export class SessionSocket {
170
171
  this.identityRefreshUsed = false;
171
172
  }
172
173
  this.options.onMessage(event.data, frame);
173
- };
174
+ });
174
175
  socket.onerror = () => undefined;
175
- socket.onclose = (event) => {
176
+ socket.onclose = contain('SessionSocket.close', (event: { code?: number }) => {
176
177
  if (this.epoch === epoch) {
177
178
  this.handleClose(epoch, event.code ?? STALE_CLOSE_CODE);
178
179
  }
179
- };
180
+ });
180
181
  }
181
182
 
182
183
  private handleClose(epoch: number, code: number): void {
@@ -210,17 +211,20 @@ export class SessionSocket {
210
211
  if (isExhausted(SOCKET_BACKOFF, this.attempts)) {
211
212
  this.options.onState('failed');
212
213
  this.exhausted = true;
213
- this.reconnectTimer = setTimeout(() => this.recover(), EXHAUSTED_RETRY_MS);
214
+ this.reconnectTimer = setTimeout(contain('SessionSocket.recover', () => this.recover()), EXHAUSTED_RETRY_MS);
214
215
  return;
215
216
  }
216
217
  const delay = backoffDelay(SOCKET_BACKOFF, this.attempts);
217
218
  this.options.onState('reconnecting');
218
- this.reconnectTimer = setTimeout(() => {
219
- this.attempts += 1;
220
- if (this.sessionId !== null) {
221
- this.connect(this.sessionId);
222
- }
223
- }, delay);
219
+ this.reconnectTimer = setTimeout(
220
+ contain('SessionSocket.reconnect', () => {
221
+ this.attempts += 1;
222
+ if (this.sessionId !== null) {
223
+ this.connect(this.sessionId);
224
+ }
225
+ }),
226
+ delay,
227
+ );
224
228
  }
225
229
 
226
230
  private recover(): void {
@@ -240,11 +244,14 @@ export class SessionSocket {
240
244
  private startLiveness(): void {
241
245
  this.stopLiveness();
242
246
  this.lastFrameAt = Date.now();
243
- this.livenessTimer = setInterval(() => {
244
- if (Date.now() - this.lastFrameAt >= STALE_AFTER_MS) {
245
- this.dropStaleSocket();
246
- }
247
- }, LIVENESS_CHECK_MS);
247
+ this.livenessTimer = setInterval(
248
+ contain('SessionSocket.liveness', () => {
249
+ if (Date.now() - this.lastFrameAt >= STALE_AFTER_MS) {
250
+ this.dropStaleSocket();
251
+ }
252
+ }),
253
+ LIVENESS_CHECK_MS,
254
+ );
248
255
  }
249
256
 
250
257
  private stopLiveness(): void {