@buivietphi/skill-mobile-mt 1.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.
Potentially problematic release.
This version of @buivietphi/skill-mobile-mt might be problematic. Click here for more details.
- package/AGENTS.md +392 -0
- package/README.md +224 -0
- package/SKILL.md +1048 -0
- package/android/android-native.md +208 -0
- package/bin/install.mjs +199 -0
- package/flutter/flutter.md +246 -0
- package/ios/ios-native.md +182 -0
- package/package.json +50 -0
- package/react-native/react-native.md +743 -0
- package/shared/agent-rules-template.md +343 -0
- package/shared/anti-patterns.md +407 -0
- package/shared/bug-detection.md +71 -0
- package/shared/claude-md-template.md +125 -0
- package/shared/code-review.md +121 -0
- package/shared/common-pitfalls.md +117 -0
- package/shared/document-analysis.md +167 -0
- package/shared/error-recovery.md +467 -0
- package/shared/observability.md +688 -0
- package/shared/performance-prediction.md +210 -0
- package/shared/platform-excellence.md +159 -0
- package/shared/prompt-engineering.md +677 -0
- package/shared/release-checklist.md +82 -0
- package/shared/version-management.md +509 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
# Mobile Observability — Sessions as the Fourth Pillar
|
|
2
|
+
|
|
3
|
+
> Sessions unify metrics, logs, and traces into a coherent user journey.
|
|
4
|
+
|
|
5
|
+
## The Four Pillars of Mobile Observability
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Traditional: Metrics + Logs + Traces
|
|
9
|
+
Mobile: Metrics + Logs + Traces + Sessions ← NEW
|
|
10
|
+
|
|
11
|
+
Sessions = The thread that ties everything together
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Without sessions, you have isolated signals.
|
|
15
|
+
With sessions, you have a complete user story.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Session Model
|
|
20
|
+
|
|
21
|
+
### Session Lifecycle
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
interface MobileSession {
|
|
25
|
+
// Identity
|
|
26
|
+
session_id: string; // Unique ID for this session
|
|
27
|
+
user_id?: string; // Authenticated user (nullable)
|
|
28
|
+
device_id: string; // Stable device identifier
|
|
29
|
+
|
|
30
|
+
// Timing
|
|
31
|
+
started_at: number; // Unix timestamp (ms)
|
|
32
|
+
ended_at?: number; // Null if still active
|
|
33
|
+
duration_ms?: number; // Calculated on end
|
|
34
|
+
|
|
35
|
+
// Mobile Context
|
|
36
|
+
app_version: string; // "2.1.3"
|
|
37
|
+
build_number: string; // "42"
|
|
38
|
+
platform: 'ios' | 'android';
|
|
39
|
+
os_version: string; // "17.2", "14"
|
|
40
|
+
device_model: string; // "iPhone 15 Pro", "Pixel 8"
|
|
41
|
+
|
|
42
|
+
// Network
|
|
43
|
+
network_type: 'wifi' | '5g' | '4g' | '3g' | 'offline';
|
|
44
|
+
carrier?: string;
|
|
45
|
+
|
|
46
|
+
// State
|
|
47
|
+
foreground_time_ms: number; // Time app was visible
|
|
48
|
+
background_time_ms: number; // Time app was in background
|
|
49
|
+
crash_count: number; // Crashes in this session
|
|
50
|
+
|
|
51
|
+
// Correlation
|
|
52
|
+
previous_session_id?: string; // Chain sessions
|
|
53
|
+
acquisition_channel?: string; // How user arrived (deeplink, push, organic)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Session Events
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
type SessionEvent =
|
|
61
|
+
| 'session_start'
|
|
62
|
+
| 'session_end'
|
|
63
|
+
| 'session_pause' // App backgrounded
|
|
64
|
+
| 'session_resume' // App foregrounded
|
|
65
|
+
| 'session_crash' // Crash detected
|
|
66
|
+
| 'session_timeout'; // Inactive for N minutes
|
|
67
|
+
|
|
68
|
+
// Session starts when app becomes active
|
|
69
|
+
// Session ends when app is killed or inactive > 30 minutes
|
|
70
|
+
// Background time > 30 minutes = new session on foreground
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Implementation Pattern
|
|
76
|
+
|
|
77
|
+
### React Native / Expo
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
// src/observability/session.ts
|
|
81
|
+
import { AppState, AppStateStatus } from 'react-native';
|
|
82
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
83
|
+
import { nanoid } from 'nanoid/non-secure';
|
|
84
|
+
import DeviceInfo from 'react-native-device-info';
|
|
85
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
86
|
+
|
|
87
|
+
class SessionManager {
|
|
88
|
+
private currentSession: MobileSession | null = null;
|
|
89
|
+
private appStateSubscription: any = null;
|
|
90
|
+
private backgroundTimer: NodeJS.Timeout | null = null;
|
|
91
|
+
private readonly SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
92
|
+
|
|
93
|
+
async startSession(): Promise<MobileSession> {
|
|
94
|
+
const previousSessionId = await this.getPreviousSessionId();
|
|
95
|
+
const netInfo = await NetInfo.fetch();
|
|
96
|
+
|
|
97
|
+
this.currentSession = {
|
|
98
|
+
session_id: nanoid(),
|
|
99
|
+
device_id: await DeviceInfo.getUniqueId(),
|
|
100
|
+
app_version: DeviceInfo.getVersion(),
|
|
101
|
+
build_number: DeviceInfo.getBuildNumber(),
|
|
102
|
+
platform: Platform.OS as 'ios' | 'android',
|
|
103
|
+
os_version: DeviceInfo.getSystemVersion(),
|
|
104
|
+
device_model: DeviceInfo.getModel(),
|
|
105
|
+
network_type: this.mapNetworkType(netInfo.type),
|
|
106
|
+
started_at: Date.now(),
|
|
107
|
+
foreground_time_ms: 0,
|
|
108
|
+
background_time_ms: 0,
|
|
109
|
+
crash_count: 0,
|
|
110
|
+
previous_session_id: previousSessionId,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await this.saveSession(this.currentSession);
|
|
114
|
+
this.trackEvent('session_start', this.currentSession);
|
|
115
|
+
this.observeAppState();
|
|
116
|
+
|
|
117
|
+
return this.currentSession;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private observeAppState() {
|
|
121
|
+
let lastActiveTime = Date.now();
|
|
122
|
+
|
|
123
|
+
this.appStateSubscription = AppState.addEventListener(
|
|
124
|
+
'change',
|
|
125
|
+
async (nextState: AppStateStatus) => {
|
|
126
|
+
if (nextState === 'background' || nextState === 'inactive') {
|
|
127
|
+
// App going to background
|
|
128
|
+
this.currentSession!.foreground_time_ms += Date.now() - lastActiveTime;
|
|
129
|
+
this.trackEvent('session_pause', { session_id: this.currentSession!.session_id });
|
|
130
|
+
|
|
131
|
+
// Set timeout for new session on resume
|
|
132
|
+
this.backgroundTimer = setTimeout(() => {
|
|
133
|
+
this.markSessionExpired();
|
|
134
|
+
}, this.SESSION_TIMEOUT_MS);
|
|
135
|
+
|
|
136
|
+
} else if (nextState === 'active') {
|
|
137
|
+
// App returning to foreground
|
|
138
|
+
if (this.backgroundTimer) clearTimeout(this.backgroundTimer);
|
|
139
|
+
|
|
140
|
+
if (this.isSessionExpired()) {
|
|
141
|
+
await this.endSession('session_timeout');
|
|
142
|
+
await this.startSession();
|
|
143
|
+
} else {
|
|
144
|
+
const backgroundStart = lastActiveTime;
|
|
145
|
+
lastActiveTime = Date.now();
|
|
146
|
+
this.currentSession!.background_time_ms += lastActiveTime - backgroundStart;
|
|
147
|
+
this.trackEvent('session_resume', { session_id: this.currentSession!.session_id });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async endSession(reason: SessionEvent = 'session_end') {
|
|
155
|
+
if (!this.currentSession) return;
|
|
156
|
+
|
|
157
|
+
this.currentSession.ended_at = Date.now();
|
|
158
|
+
this.currentSession.duration_ms =
|
|
159
|
+
this.currentSession.ended_at - this.currentSession.started_at;
|
|
160
|
+
|
|
161
|
+
this.trackEvent(reason, this.currentSession);
|
|
162
|
+
await this.savePreviousSessionId(this.currentSession.session_id);
|
|
163
|
+
this.currentSession = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
getContext(): Record<string, string> {
|
|
167
|
+
if (!this.currentSession) return {};
|
|
168
|
+
return {
|
|
169
|
+
session_id: this.currentSession.session_id,
|
|
170
|
+
device_id: this.currentSession.device_id,
|
|
171
|
+
app_version: this.currentSession.app_version,
|
|
172
|
+
platform: this.currentSession.platform,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Inject session context into every log/metric/trace
|
|
177
|
+
enrichEvent(event: Record<string, any>): Record<string, any> {
|
|
178
|
+
return {
|
|
179
|
+
...event,
|
|
180
|
+
...this.getContext(),
|
|
181
|
+
timestamp: Date.now(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const sessionManager = new SessionManager();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Flutter (Dart)
|
|
190
|
+
|
|
191
|
+
```dart
|
|
192
|
+
// lib/observability/session_manager.dart
|
|
193
|
+
import 'dart:io';
|
|
194
|
+
import 'package:device_info_plus/device_info_plus.dart';
|
|
195
|
+
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
196
|
+
import 'package:shared_preferences/shared_preferences.dart';
|
|
197
|
+
import 'package:uuid/uuid.dart';
|
|
198
|
+
|
|
199
|
+
class SessionManager {
|
|
200
|
+
static final SessionManager _instance = SessionManager._internal();
|
|
201
|
+
factory SessionManager() => _instance;
|
|
202
|
+
SessionManager._internal();
|
|
203
|
+
|
|
204
|
+
MobileSession? _currentSession;
|
|
205
|
+
final _uuid = const Uuid();
|
|
206
|
+
|
|
207
|
+
Future<MobileSession> startSession() async {
|
|
208
|
+
final prefs = await SharedPreferences.getInstance();
|
|
209
|
+
final previousId = prefs.getString('last_session_id');
|
|
210
|
+
final deviceInfo = DeviceInfoPlugin();
|
|
211
|
+
final connectivity = await Connectivity().checkConnectivity();
|
|
212
|
+
|
|
213
|
+
String deviceId = '';
|
|
214
|
+
String osVersion = '';
|
|
215
|
+
String deviceModel = '';
|
|
216
|
+
|
|
217
|
+
if (Platform.isIOS) {
|
|
218
|
+
final iosInfo = await deviceInfo.iosInfo;
|
|
219
|
+
deviceId = iosInfo.identifierForVendor ?? '';
|
|
220
|
+
osVersion = iosInfo.systemVersion;
|
|
221
|
+
deviceModel = iosInfo.model;
|
|
222
|
+
} else if (Platform.isAndroid) {
|
|
223
|
+
final androidInfo = await deviceInfo.androidInfo;
|
|
224
|
+
deviceId = androidInfo.id;
|
|
225
|
+
osVersion = androidInfo.version.release;
|
|
226
|
+
deviceModel = androidInfo.model;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_currentSession = MobileSession(
|
|
230
|
+
sessionId: _uuid.v4(),
|
|
231
|
+
deviceId: deviceId,
|
|
232
|
+
appVersion: '1.0.0', // From package_info_plus
|
|
233
|
+
buildNumber: '1',
|
|
234
|
+
platform: Platform.isIOS ? 'ios' : 'android',
|
|
235
|
+
osVersion: osVersion,
|
|
236
|
+
deviceModel: deviceModel,
|
|
237
|
+
networkType: _mapConnectivity(connectivity),
|
|
238
|
+
startedAt: DateTime.now().millisecondsSinceEpoch,
|
|
239
|
+
previousSessionId: previousId,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
_trackEvent('session_start', _currentSession!.toMap());
|
|
243
|
+
return _currentSession!;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
Map<String, String> getContext() {
|
|
247
|
+
final session = _currentSession;
|
|
248
|
+
if (session == null) return {};
|
|
249
|
+
return {
|
|
250
|
+
'session_id': session.sessionId,
|
|
251
|
+
'device_id': session.deviceId,
|
|
252
|
+
'app_version': session.appVersion,
|
|
253
|
+
'platform': session.platform,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Call this before every log, metric, or trace
|
|
258
|
+
Map<String, dynamic> enrichEvent(Map<String, dynamic> event) {
|
|
259
|
+
return {
|
|
260
|
+
...event,
|
|
261
|
+
...getContext(),
|
|
262
|
+
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Unified Observability Stack
|
|
271
|
+
|
|
272
|
+
### Signal Unification
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
// Every signal (metric/log/trace) MUST include session context
|
|
276
|
+
|
|
277
|
+
// ❌ WRONG: Missing session context
|
|
278
|
+
logger.info('User logged in');
|
|
279
|
+
metrics.increment('login_success');
|
|
280
|
+
tracer.startSpan('auth.login');
|
|
281
|
+
|
|
282
|
+
// ✅ CORRECT: Session context injected
|
|
283
|
+
const context = sessionManager.getContext();
|
|
284
|
+
|
|
285
|
+
logger.info('User logged in', { ...context, method: 'email' });
|
|
286
|
+
metrics.increment('login_success', { ...context });
|
|
287
|
+
tracer.startSpan('auth.login', { attributes: context });
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Correlation Architecture
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
User Journey (Session Layer)
|
|
294
|
+
│
|
|
295
|
+
├── Screen: LoginScreen (session_id: abc123)
|
|
296
|
+
│ ├── Metric: screen_view {session_id: abc123, screen: "login"}
|
|
297
|
+
│ ├── Log: "Attempting login" {session_id: abc123}
|
|
298
|
+
│ └── Trace: auth.login {session_id: abc123}
|
|
299
|
+
│ ├── span: validate_email {duration: 2ms}
|
|
300
|
+
│ ├── span: api.call {duration: 234ms}
|
|
301
|
+
│ └── span: save_token {duration: 5ms}
|
|
302
|
+
│
|
|
303
|
+
└── Screen: HomeScreen (session_id: abc123)
|
|
304
|
+
├── Metric: screen_view {session_id: abc123, screen: "home"}
|
|
305
|
+
├── Log: "Feed loaded" {session_id: abc123, items: 20}
|
|
306
|
+
└── Trace: feed.load {session_id: abc123}
|
|
307
|
+
|
|
308
|
+
QUERY POWER: "Show me all logs, metrics, and traces for session abc123"
|
|
309
|
+
→ Complete user journey reconstruction
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Instrumentation Patterns
|
|
315
|
+
|
|
316
|
+
### Screen Tracking
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
// ✅ Context-rich screen tracking
|
|
320
|
+
function trackScreen(screenName: string, params?: Record<string, string>) {
|
|
321
|
+
const event = sessionManager.enrichEvent({
|
|
322
|
+
event: 'screen_view',
|
|
323
|
+
screen_name: screenName,
|
|
324
|
+
screen_class: screenName,
|
|
325
|
+
// Avoid PII — see anti-patterns.md
|
|
326
|
+
...params,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
analytics.track(event);
|
|
330
|
+
logger.debug('Screen viewed', event);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Usage
|
|
334
|
+
trackScreen('ProductDetail', { product_id: product.id, category: product.category });
|
|
335
|
+
// NOT: trackScreen('ProductDetail', { user_email: user.email }); ← PII leak
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### API Call Tracking
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// ✅ Full API instrumentation
|
|
342
|
+
async function trackedRequest(
|
|
343
|
+
method: string,
|
|
344
|
+
url: string,
|
|
345
|
+
options?: RequestInit
|
|
346
|
+
): Promise<Response> {
|
|
347
|
+
const traceId = nanoid();
|
|
348
|
+
const startTime = Date.now();
|
|
349
|
+
const context = sessionManager.getContext();
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const response = await fetch(url, {
|
|
353
|
+
...options,
|
|
354
|
+
headers: {
|
|
355
|
+
...options?.headers,
|
|
356
|
+
'X-Trace-Id': traceId, // Correlate with backend
|
|
357
|
+
'X-Session-Id': context.session_id,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const duration = Date.now() - startTime;
|
|
362
|
+
|
|
363
|
+
metrics.histogram('api.duration', duration, {
|
|
364
|
+
...context,
|
|
365
|
+
method,
|
|
366
|
+
endpoint: sanitizeUrl(url), // Remove user IDs from URL
|
|
367
|
+
status: String(response.status),
|
|
368
|
+
success: String(response.ok),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
logger.info('API call completed', {
|
|
372
|
+
...context,
|
|
373
|
+
trace_id: traceId,
|
|
374
|
+
method,
|
|
375
|
+
endpoint: sanitizeUrl(url),
|
|
376
|
+
status: response.status,
|
|
377
|
+
duration_ms: duration,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return response;
|
|
381
|
+
|
|
382
|
+
} catch (error) {
|
|
383
|
+
metrics.increment('api.error', {
|
|
384
|
+
...context,
|
|
385
|
+
method,
|
|
386
|
+
endpoint: sanitizeUrl(url),
|
|
387
|
+
error_type: error instanceof Error ? error.name : 'unknown',
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
logger.error('API call failed', {
|
|
391
|
+
...context,
|
|
392
|
+
trace_id: traceId,
|
|
393
|
+
method,
|
|
394
|
+
endpoint: sanitizeUrl(url),
|
|
395
|
+
error: error instanceof Error ? error.message : String(error),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function sanitizeUrl(url: string): string {
|
|
403
|
+
// Remove UUIDs and numeric IDs from URLs
|
|
404
|
+
return url
|
|
405
|
+
.replace(/\/[0-9a-f-]{36}/g, '/:id') // UUIDs
|
|
406
|
+
.replace(/\/\d+/g, '/:id') // Numeric IDs
|
|
407
|
+
.replace(/\?.*$/, ''); // Query params
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Error Tracking
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
// ✅ Crash reporting with session context
|
|
415
|
+
function trackError(error: Error, context?: Record<string, string>) {
|
|
416
|
+
const sessionContext = sessionManager.getContext();
|
|
417
|
+
|
|
418
|
+
// Update session crash count
|
|
419
|
+
sessionManager.incrementCrashCount();
|
|
420
|
+
|
|
421
|
+
crashReporter.captureException(error, {
|
|
422
|
+
tags: {
|
|
423
|
+
...sessionContext,
|
|
424
|
+
...context,
|
|
425
|
+
},
|
|
426
|
+
extra: {
|
|
427
|
+
crash_count: sessionManager.getCrashCount(),
|
|
428
|
+
// Breadcrumbs from session
|
|
429
|
+
recent_screens: sessionManager.getRecentScreens(),
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
logger.error('Application error', {
|
|
434
|
+
...sessionContext,
|
|
435
|
+
error_name: error.name,
|
|
436
|
+
error_message: error.message,
|
|
437
|
+
// No stack traces in production logs (can contain file paths with PII)
|
|
438
|
+
stack_hash: hashString(error.stack ?? ''),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Global error boundary (React Native)
|
|
443
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
444
|
+
|
|
445
|
+
function GlobalErrorBoundary({ children }: { children: React.ReactNode }) {
|
|
446
|
+
return (
|
|
447
|
+
<ErrorBoundary
|
|
448
|
+
onError={(error, info) => {
|
|
449
|
+
trackError(error, { component: info.componentStack?.split('\n')[1] ?? 'unknown' });
|
|
450
|
+
}}
|
|
451
|
+
fallback={<ErrorScreen />}
|
|
452
|
+
>
|
|
453
|
+
{children}
|
|
454
|
+
</ErrorBoundary>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Performance Tracking
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
// ✅ Frame rate and rendering monitoring
|
|
463
|
+
import { PerformanceObserver } from 'react-native';
|
|
464
|
+
|
|
465
|
+
function trackRenderPerformance(componentName: string) {
|
|
466
|
+
return function <T extends React.ComponentType<any>>(WrappedComponent: T): T {
|
|
467
|
+
const displayName = componentName || WrappedComponent.displayName || 'Unknown';
|
|
468
|
+
|
|
469
|
+
function TrackedComponent(props: React.ComponentProps<T>) {
|
|
470
|
+
const renderStart = useRef(Date.now());
|
|
471
|
+
const context = sessionManager.getContext();
|
|
472
|
+
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
const renderDuration = Date.now() - renderStart.current;
|
|
475
|
+
|
|
476
|
+
if (renderDuration > 16) { // > 1 frame at 60fps
|
|
477
|
+
metrics.histogram('render.duration', renderDuration, {
|
|
478
|
+
...context,
|
|
479
|
+
component: displayName,
|
|
480
|
+
slow: String(renderDuration > 100), // > 6 frames
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}, []);
|
|
484
|
+
|
|
485
|
+
return <WrappedComponent {...props} />;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
TrackedComponent.displayName = `Tracked(${displayName})`;
|
|
489
|
+
return TrackedComponent as T;
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Usage
|
|
494
|
+
export default trackRenderPerformance('ProductList')(ProductListComponent);
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Alerting Based on Sessions
|
|
500
|
+
|
|
501
|
+
### Alert Patterns
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
// Alert: Crash rate spike
|
|
505
|
+
if (session.crash_count > 0) {
|
|
506
|
+
alerts.trigger('crash_detected', {
|
|
507
|
+
session_id: session.session_id,
|
|
508
|
+
app_version: session.app_version,
|
|
509
|
+
crash_count: session.crash_count,
|
|
510
|
+
platform: session.platform,
|
|
511
|
+
os_version: session.os_version,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Alert: Long session with no screen changes (app hung?)
|
|
516
|
+
if (session.duration_ms > 10 * 60 * 1000 && getScreenCount() <= 1) {
|
|
517
|
+
alerts.trigger('potential_freeze', {
|
|
518
|
+
session_id: session.session_id,
|
|
519
|
+
duration_ms: session.duration_ms,
|
|
520
|
+
screen_count: getScreenCount(),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Alert: High background time (battery killer?)
|
|
525
|
+
const backgroundRatio =
|
|
526
|
+
session.background_time_ms / session.duration_ms;
|
|
527
|
+
if (backgroundRatio > 0.5) {
|
|
528
|
+
logger.warn('High background activity', {
|
|
529
|
+
session_id: session.session_id,
|
|
530
|
+
background_ratio: backgroundRatio.toFixed(2),
|
|
531
|
+
background_ms: session.background_time_ms,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### Dashboard Queries
|
|
537
|
+
|
|
538
|
+
```sql
|
|
539
|
+
-- Session-based queries (example for any analytics platform)
|
|
540
|
+
|
|
541
|
+
-- Active sessions by platform
|
|
542
|
+
SELECT platform, COUNT(DISTINCT session_id) as active_sessions
|
|
543
|
+
FROM sessions
|
|
544
|
+
WHERE started_at > NOW() - INTERVAL '1 hour'
|
|
545
|
+
GROUP BY platform;
|
|
546
|
+
|
|
547
|
+
-- Crash rate by app version
|
|
548
|
+
SELECT app_version,
|
|
549
|
+
COUNT(DISTINCT session_id) as total_sessions,
|
|
550
|
+
SUM(crash_count) as total_crashes,
|
|
551
|
+
ROUND(SUM(crash_count)::numeric / COUNT(DISTINCT session_id) * 100, 2) as crash_rate_pct
|
|
552
|
+
FROM sessions
|
|
553
|
+
GROUP BY app_version
|
|
554
|
+
ORDER BY app_version DESC;
|
|
555
|
+
|
|
556
|
+
-- Session duration distribution
|
|
557
|
+
SELECT
|
|
558
|
+
platform,
|
|
559
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY duration_ms) as p50_ms,
|
|
560
|
+
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY duration_ms) as p90_ms,
|
|
561
|
+
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms) as p99_ms
|
|
562
|
+
FROM sessions
|
|
563
|
+
WHERE duration_ms IS NOT NULL
|
|
564
|
+
GROUP BY platform;
|
|
565
|
+
|
|
566
|
+
-- Network type vs error rate
|
|
567
|
+
SELECT s.network_type,
|
|
568
|
+
COUNT(DISTINCT s.session_id) as sessions,
|
|
569
|
+
COUNT(e.error_id) as errors,
|
|
570
|
+
ROUND(COUNT(e.error_id)::numeric / COUNT(DISTINCT s.session_id), 2) as errors_per_session
|
|
571
|
+
FROM sessions s
|
|
572
|
+
LEFT JOIN errors e USING (session_id)
|
|
573
|
+
GROUP BY s.network_type;
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## Context Requirements Checklist
|
|
579
|
+
|
|
580
|
+
Every instrumented event MUST include:
|
|
581
|
+
|
|
582
|
+
```
|
|
583
|
+
□ session_id (links everything together)
|
|
584
|
+
□ device_id (user journey across sessions)
|
|
585
|
+
□ app_version (for regression detection)
|
|
586
|
+
□ platform (iOS vs Android differences)
|
|
587
|
+
□ timestamp (for timeline reconstruction)
|
|
588
|
+
|
|
589
|
+
OPTIONAL but valuable:
|
|
590
|
+
□ screen_name (where in the app)
|
|
591
|
+
□ network_type (wifi vs cellular behavior differences)
|
|
592
|
+
□ os_version (OS-specific bug detection)
|
|
593
|
+
□ user_tier (free vs premium behavior)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Events MUST NOT include:
|
|
597
|
+
```
|
|
598
|
+
✗ user_email (PII — see anti-patterns.md)
|
|
599
|
+
✗ user_name (PII)
|
|
600
|
+
✗ phone_number (PII)
|
|
601
|
+
✗ raw_user_id (replace with hashed or session_id)
|
|
602
|
+
✗ full URL params (may contain tokens)
|
|
603
|
+
✗ auth_token (security risk)
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Observability Stack Recommendations
|
|
609
|
+
|
|
610
|
+
### React Native
|
|
611
|
+
|
|
612
|
+
| Tool | Purpose | Integration |
|
|
613
|
+
|------|---------|-------------|
|
|
614
|
+
| **Sentry** | Crash reporting + error tracking | `@sentry/react-native` |
|
|
615
|
+
| **Datadog RUM** | Real User Monitoring | `@datadog/mobile-react-native` |
|
|
616
|
+
| **Firebase Crashlytics** | Crash reporting (free) | `@react-native-firebase/crashlytics` |
|
|
617
|
+
| **Firebase Analytics** | Event tracking (free) | `@react-native-firebase/analytics` |
|
|
618
|
+
| **Segment** | CDP (routes to other tools) | `@segment/analytics-react-native` |
|
|
619
|
+
| **New Relic Mobile** | Full observability | `newrelic-react-native-agent` |
|
|
620
|
+
|
|
621
|
+
### Flutter
|
|
622
|
+
|
|
623
|
+
| Tool | Purpose | Integration |
|
|
624
|
+
|------|---------|-------------|
|
|
625
|
+
| **Sentry** | Crash reporting + error tracking | `sentry_flutter` |
|
|
626
|
+
| **Firebase Crashlytics** | Crash reporting (free) | `firebase_crashlytics` |
|
|
627
|
+
| **Firebase Analytics** | Event tracking (free) | `firebase_analytics` |
|
|
628
|
+
| **Datadog** | Full observability | `datadog_flutter_plugin` |
|
|
629
|
+
| **Segment** | CDP | `analytics` (Flutter) |
|
|
630
|
+
| **OpenTelemetry** | Vendor-neutral | `opentelemetry_dart` |
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## Session-Aware Testing
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
// tests/observability/session.test.ts
|
|
638
|
+
describe('SessionManager', () => {
|
|
639
|
+
it('enriches all events with session context', () => {
|
|
640
|
+
const session = await sessionManager.startSession();
|
|
641
|
+
|
|
642
|
+
const rawEvent = { event: 'button_tap', button: 'subscribe' };
|
|
643
|
+
const enriched = sessionManager.enrichEvent(rawEvent);
|
|
644
|
+
|
|
645
|
+
expect(enriched).toMatchObject({
|
|
646
|
+
event: 'button_tap',
|
|
647
|
+
button: 'subscribe',
|
|
648
|
+
session_id: session.session_id,
|
|
649
|
+
device_id: expect.any(String),
|
|
650
|
+
app_version: expect.any(String),
|
|
651
|
+
platform: expect.stringMatching(/^(ios|android)$/),
|
|
652
|
+
timestamp: expect.any(Number),
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('starts a new session after 30 min background', async () => {
|
|
657
|
+
const session1 = await sessionManager.startSession();
|
|
658
|
+
|
|
659
|
+
// Simulate 31 minutes in background
|
|
660
|
+
jest.advanceTimersByTime(31 * 60 * 1000);
|
|
661
|
+
|
|
662
|
+
// App comes back to foreground
|
|
663
|
+
simulateAppState('active');
|
|
664
|
+
|
|
665
|
+
const session2 = sessionManager.getCurrentSession();
|
|
666
|
+
expect(session2.session_id).not.toBe(session1.session_id);
|
|
667
|
+
expect(session2.previous_session_id).toBe(session1.session_id);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Summary
|
|
675
|
+
|
|
676
|
+
**Sessions are the fourth pillar because:**
|
|
677
|
+
1. **Metrics** tell you WHAT happened (count, duration, rate)
|
|
678
|
+
2. **Logs** tell you WHAT was happening at a moment (details)
|
|
679
|
+
3. **Traces** tell you HOW it happened (call chain)
|
|
680
|
+
4. **Sessions** tell you WHO experienced it and WHEN in their journey
|
|
681
|
+
|
|
682
|
+
Without sessions:
|
|
683
|
+
- "Error spike at 2pm" → Can't tell if 1 user or 1000 users
|
|
684
|
+
- "API timeout" → Can't tell if it always happens or only on cellular
|
|
685
|
+
|
|
686
|
+
With sessions:
|
|
687
|
+
- "Error spike at 2pm" → 47 unique sessions, all on iOS 17.1, v2.1.3
|
|
688
|
+
- "API timeout" → 89% occur on 3G sessions, not WiFi
|