eventlog-rn 0.3.1

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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Network interception logic (Fetch & XHR)
3
+ */
4
+
5
+ import type { EventLog, FeatureConfig, NetworkEventPayload } from '../types';
6
+
7
+ type NetworkConfig = NonNullable<FeatureConfig['network']>;
8
+
9
+ // Keep original globals
10
+ const originalFetch = global.fetch;
11
+ const originalXMLHttpRequest = global.XMLHttpRequest;
12
+
13
+ /**
14
+ * Intercept Fetch API
15
+ */
16
+ const interceptFetch = (eventLog: EventLog, config: NetworkConfig) => {
17
+ if (!config.interceptFetch) return;
18
+
19
+ global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
20
+ const startTime = Date.now();
21
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
22
+ const method = (init?.method ?? 'GET').toUpperCase();
23
+
24
+ try {
25
+ const response = await originalFetch(input, init);
26
+ const endTime = Date.now();
27
+ const duration = endTime - startTime;
28
+
29
+ // Clone response to read body without consuming original stream
30
+ const clone = response.clone();
31
+
32
+ let responseBody: unknown;
33
+ if (config.logResponseBody) {
34
+ try {
35
+ // Text first to check size/parse JSON
36
+ const text = await clone.text();
37
+ if (!config.maxBodySize || text.length <= config.maxBodySize) {
38
+ try {
39
+ responseBody = JSON.parse(text);
40
+ } catch {
41
+ responseBody = text;
42
+ }
43
+ } else {
44
+ responseBody = `[Body too large: ${text.length} bytes]`;
45
+ }
46
+ } catch {
47
+ responseBody = '[Failed to read response body]';
48
+ }
49
+ }
50
+
51
+ const payload: NetworkEventPayload = {
52
+ url,
53
+ method,
54
+ status: response.status,
55
+ duration,
56
+ responseHeaders: config.redactHeaders ? redactHeaders(response.headers, config.redactHeaders) : headersToObject(response.headers),
57
+ responseBody,
58
+ };
59
+
60
+ eventLog.network(payload);
61
+
62
+ return response;
63
+ } catch (error) {
64
+ const endTime = Date.now();
65
+ eventLog.error(error, {
66
+ url,
67
+ method,
68
+ duration: endTime - startTime,
69
+ category: 'network', // Context hack
70
+ });
71
+ throw error;
72
+ }
73
+ };
74
+ };
75
+
76
+ /**
77
+ * Intercept XMLHttpRequest
78
+ */
79
+ const interceptXHR = (eventLog: EventLog, config: NetworkConfig) => {
80
+ // If explicitly disabled, return. Undefined means enabled by default.
81
+ if (config.interceptAxios === false) return;
82
+
83
+ const XHR = originalXMLHttpRequest;
84
+
85
+ // @ts-ignore - Monkey patching
86
+ global.XMLHttpRequest = function() {
87
+ const xhr = new XHR();
88
+ const startTime = Date.now();
89
+ let method = 'GET';
90
+ let url = '';
91
+
92
+ const originalOpen = xhr.open;
93
+ const originalSend = xhr.send;
94
+
95
+ xhr.open = function(m: string, u: string, ...args: any[]) {
96
+ method = m.toUpperCase();
97
+ url = u;
98
+ return originalOpen.apply(this, [m, u, ...args] as any);
99
+ };
100
+
101
+ xhr.send = function(body?: any) {
102
+ xhr.addEventListener('loadend', () => {
103
+ const duration = Date.now() - startTime;
104
+
105
+ // Extract headers (messy in XHR)
106
+ const responseHeadersString = xhr.getAllResponseHeaders();
107
+ const responseHeaders = parseXHRHeaders(responseHeadersString);
108
+
109
+ let responseBody: unknown;
110
+ if (config.logResponseBody) {
111
+ try {
112
+ if (xhr.responseType === '' || xhr.responseType === 'text') {
113
+ responseBody = xhr.responseText;
114
+ if (responseBody && typeof responseBody === 'string' && (!config.maxBodySize || responseBody.length <= config.maxBodySize)) {
115
+ try { responseBody = JSON.parse(responseBody); } catch {}
116
+ }
117
+ }
118
+ } catch {}
119
+ }
120
+
121
+ const payload: NetworkEventPayload = {
122
+ url,
123
+ method,
124
+ status: xhr.status,
125
+ duration,
126
+ responseHeaders: config.redactHeaders ? redactHeaders(responseHeaders, config.redactHeaders) : responseHeaders as any,
127
+ responseBody,
128
+ };
129
+
130
+ // @ts-ignore - We will add this method
131
+ if (typeof eventLog.network === 'function') {
132
+ // @ts-ignore
133
+ eventLog.network(payload);
134
+ }
135
+ });
136
+ return originalSend.apply(this, [body]);
137
+ }
138
+
139
+ return xhr;
140
+ };
141
+
142
+ // Restore prototype chain
143
+ global.XMLHttpRequest.prototype = XHR.prototype;
144
+ Object.assign(global.XMLHttpRequest, XHR);
145
+ };
146
+
147
+ /**
148
+ * Helpers
149
+ */
150
+ const headersToObject = (headers: Headers): Record<string, string> => {
151
+ const obj: Record<string, string> = {};
152
+ headers.forEach((value, key) => {
153
+ obj[key] = value;
154
+ });
155
+ return obj;
156
+ }
157
+
158
+ const redactHeaders = (headers: Headers | Record<string, string>, keysToRedact: ReadonlyArray<string>): Record<string, string> => {
159
+ const obj = headers instanceof Headers ? headersToObject(headers) : headers;
160
+ const redacted: Record<string, string> = { ...obj };
161
+ keysToRedact.forEach(key => {
162
+ const lowerKey = key.toLowerCase();
163
+ // Simple case-insensitive match simulation
164
+ Object.keys(redacted).forEach(h => {
165
+ if (h.toLowerCase() === lowerKey) {
166
+ redacted[h] = '***REDACTED***';
167
+ }
168
+ });
169
+ });
170
+ return redacted;
171
+ }
172
+
173
+ const parseXHRHeaders = (headers: string): Record<string, string> => {
174
+ if (!headers) return {};
175
+ const result: Record<string, string> = {};
176
+ const pairs = headers.trim().split(/[\r\n]+/);
177
+ pairs.forEach(line => {
178
+ const parts = line.split(': ');
179
+ if (parts.length >= 2) {
180
+ const key = parts.shift()!;
181
+ const value = parts.join(': ');
182
+ result[key] = value;
183
+ }
184
+ });
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * Main enable function
190
+ */
191
+ export const enableNetworkInterception = (eventLog: EventLog, config: FeatureConfig['network']) => {
192
+ if (!config || !config.enabled) return;
193
+
194
+ // Cleanup first just in case
195
+ restoreNetworkInterception();
196
+
197
+ if (config.interceptFetch !== false) {
198
+ interceptFetch(eventLog, config);
199
+ }
200
+ // Default to true for XHR if not specified, or checks interceptAxios
201
+ if (config.interceptAxios !== false) {
202
+ interceptXHR(eventLog, config);
203
+ }
204
+ };
205
+
206
+ export const restoreNetworkInterception = () => {
207
+ global.fetch = originalFetch;
208
+ global.XMLHttpRequest = originalXMLHttpRequest;
209
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Query events by filters
3
+ */
4
+
5
+ import type { Event, EventQuery } from '../types';
6
+
7
+ export const queryEvents = (
8
+ events: ReadonlyArray<Event>,
9
+ query: EventQuery
10
+ ): ReadonlyArray<Event> => {
11
+ let filtered = events;
12
+
13
+ // Filter by category
14
+ if (query.category && query.category.length > 0) {
15
+ filtered = filtered.filter((e) => {
16
+ const categories = query.category;
17
+ return categories ? categories.includes(e.category) : true;
18
+ });
19
+ }
20
+
21
+ // Filter by time range
22
+ if (query.timeRange) {
23
+ filtered = filtered.filter(
24
+ (e) =>
25
+ e.timestamp >= query.timeRange!.start &&
26
+ e.timestamp <= query.timeRange!.end
27
+ );
28
+ }
29
+
30
+ // Filter by session ID
31
+ if (query.sessionId) {
32
+ filtered = filtered.filter((e) => e.sessionId === query.sessionId);
33
+ }
34
+
35
+ // Search in payload
36
+ if (query.search) {
37
+ const searchLower = query.search.toLowerCase();
38
+ filtered = filtered.filter((e) => {
39
+ const payloadStr = JSON.stringify(e.payload).toLowerCase();
40
+ return payloadStr.includes(searchLower);
41
+ });
42
+ }
43
+
44
+ // Limit results
45
+ if (query.limit && query.limit > 0) {
46
+ filtered = filtered.slice(-query.limit);
47
+ }
48
+
49
+ return filtered;
50
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure utility functions
3
+ */
4
+
5
+ /**
6
+ * Generate a UUID v4
7
+ */
8
+ export const generateUUID = (): string => {
9
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
10
+ const r = (Math.random() * 16) | 0;
11
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
12
+ return v.toString(16);
13
+ });
14
+ };
15
+
16
+ /**
17
+ * Get current timestamp in milliseconds
18
+ */
19
+ export const getTimestamp = (): number => Date.now();
20
+
21
+ /**
22
+ * Get ISO 8601 timestamp with timezone
23
+ */
24
+ export const getTimestampISO = (): string => new Date().toISOString();
25
+
26
+ /**
27
+ * Get IANA timezone identifier
28
+ */
29
+ export const getTimezone = (): string => {
30
+ try {
31
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
32
+ } catch {
33
+ return 'UTC';
34
+ }
35
+ };
36
+
37
+ /**
38
+ * Debounce function (returns a new function, pure in concept)
39
+ */
40
+ export const debounce = <T extends ReadonlyArray<unknown>>(
41
+ func: (...args: T) => void,
42
+ wait: number
43
+ ): ((...args: T) => void) => {
44
+ let timeout: ReturnType<typeof setTimeout> | null = null;
45
+
46
+ return (...args: T): void => {
47
+ if (timeout !== null) {
48
+ clearTimeout(timeout);
49
+ }
50
+ timeout = setTimeout(() => {
51
+ func(...args);
52
+ }, wait);
53
+ };
54
+ };
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * eventlog-rn
3
+ * Functional, type-safe event logging SDK for React Native
4
+ */
5
+
6
+ export { createEventLog } from './core/eventlog';
7
+ export { EventLogViewer } from './viewer/EventLogViewer';
8
+ export type { EventLogViewerProps } from './viewer/types';
9
+ export type {
10
+ EventLog,
11
+ EventLogConfig,
12
+ Event,
13
+ EventCategory,
14
+ EventContext,
15
+ Session,
16
+ ExportOptions,
17
+ LogLevel,
18
+ Result,
19
+ StorageAdapter,
20
+ NetworkEventPayload,
21
+ ErrorEventPayload,
22
+ EventQuery,
23
+ } from './types';
24
+
25
+ /**
26
+ * Default singleton instance
27
+ *
28
+ * Usage:
29
+ * ```typescript
30
+ * import { eventLog } from 'eventlog-rn';
31
+ * import AsyncStorage from '@react-native-async-storage/async-storage';
32
+ *
33
+ * await eventLog.init({ storage: AsyncStorage });
34
+ * eventLog.screen('Home');
35
+ * ```
36
+ */
37
+ import { eventLog } from './instance';
38
+ export { eventLog };
39
+ export { EventLogErrorBoundary } from './components/EventLogErrorBoundary';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Singleton instance of EventLog
3
+ * Moved here to avoid circular dependencies
4
+ */
5
+ import { createEventLog } from './core/eventlog';
6
+
7
+ export const eventLog = createEventLog();
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Internal MMKV storage adapter
3
+ * Supports both MMKV v3 (RN 0.74+) and v4 (RN 0.75+)
4
+ * MMKV is synchronous, but we wrap it in Promises to match StorageAdapter interface
5
+ */
6
+
7
+ import type { StorageAdapter } from '../types';
8
+
9
+ // Detect and initialize MMKV with version compatibility
10
+ import * as MMKVModule from 'react-native-mmkv';
11
+
12
+ let mmkv: any;
13
+
14
+ try {
15
+ // Handle ESM vs CJS
16
+ const m = (MMKVModule as any)?.default ? (MMKVModule as any).default : MMKVModule;
17
+
18
+ if (typeof (m as any).createMMKV === 'function') {
19
+ // v4 API
20
+ mmkv = (m as any).createMMKV({ id: 'eventlog-rn' });
21
+
22
+ } else if (typeof (m as any).MMKV === 'function') {
23
+ // v3 API
24
+ mmkv = new (m as any).MMKV({ id: 'eventlog-rn' });
25
+
26
+ } else if (typeof m === 'function') {
27
+ // v2/v3 fallback (default export)
28
+ mmkv = new (m as any)({ id: 'eventlog-rn' });
29
+
30
+ } else {
31
+ throw new Error('MMKV module found but no valid constructor (createMMKV or class MMKV) found.');
32
+ }
33
+ } catch (error: any) {
34
+ const errorMessage = error instanceof Error ? error.message : String(error);
35
+ throw new Error(
36
+ `[EventLog] Failed to initialize MMKV: ${errorMessage}.`
37
+ );
38
+ }
39
+
40
+ export const internalStorage: StorageAdapter = {
41
+ setItem: async (key: string, value: string): Promise<void> => {
42
+ mmkv.set(key, value);
43
+ },
44
+
45
+ getItem: async (key: string): Promise<string | null> => {
46
+ return mmkv.getString(key) ?? null;
47
+ },
48
+
49
+ removeItem: async (key: string): Promise<void> => {
50
+ // v4 uses remove(), v3 uses delete()
51
+ if (typeof mmkv.remove === 'function') {
52
+ mmkv.remove(key);
53
+ } else {
54
+ mmkv.delete(key);
55
+ }
56
+ },
57
+ };
package/src/types.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Type definitions for eventlog-rn/core
3
+ * Strict, immutable types following functional programming principles
4
+ */
5
+
6
+ /**
7
+ * Storage adapter interface (AsyncStorage-compatible)
8
+ */
9
+ export type StorageAdapter = Readonly<{
10
+ getItem: (key: string) => Promise<string | null>;
11
+ setItem: (key: string, value: string) => Promise<void>;
12
+ removeItem: (key: string) => Promise<void>;
13
+ }>;
14
+
15
+ /**
16
+ * Event categories
17
+ */
18
+ export type EventCategory = 'screen' | 'action' | 'network' | 'error' | 'log';
19
+
20
+ /**
21
+ * Log levels
22
+ */
23
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
24
+
25
+ /**
26
+ * Event context (attached to all events)
27
+ */
28
+ export type EventContext = Readonly<{
29
+ readonly user?: Readonly<Record<string, unknown>>;
30
+ readonly deviceInfo?: Readonly<Record<string, unknown>>;
31
+ readonly [key: string]: unknown;
32
+ }>;
33
+
34
+ /**
35
+ * Immutable event structure
36
+ */
37
+ export type Event = Readonly<{
38
+ readonly eventId: string;
39
+ readonly sessionId: string;
40
+ readonly seq: number;
41
+ readonly timestamp: number;
42
+ readonly timestampISO: string;
43
+ readonly timezone: string;
44
+ readonly category: EventCategory;
45
+ readonly payload: unknown;
46
+ readonly context: EventContext;
47
+ }>;
48
+
49
+ /**
50
+ * Session information
51
+ */
52
+ export type Session = Readonly<{
53
+ readonly sessionId: string;
54
+ readonly sessionStart: number;
55
+ readonly startType: 'cold' | 'warm';
56
+ readonly seq: number;
57
+ readonly lastActivityTime: number;
58
+ }>;
59
+
60
+ /**
61
+ * Feature configuration
62
+ */
63
+ export type FeatureConfig = Readonly<{
64
+ readonly network?: Readonly<{
65
+ readonly enabled?: boolean;
66
+ readonly interceptFetch?: boolean;
67
+ readonly interceptAxios?: boolean;
68
+ readonly logRequestBody?: boolean;
69
+ readonly logResponseBody?: boolean;
70
+ readonly maxBodySize?: number;
71
+ readonly redactHeaders?: ReadonlyArray<string>;
72
+ }>;
73
+ readonly globalErrors?: Readonly<{
74
+ readonly enabled?: boolean;
75
+ }>;
76
+ }>;
77
+
78
+ /**
79
+ * Tracker configuration
80
+ */
81
+ export type EventLogConfig = Readonly<{
82
+ readonly autoDetect?: boolean;
83
+ readonly maxEvents?: number;
84
+ readonly maxAgeDays?: number;
85
+ readonly sessionTimeoutMinutes?: number;
86
+ readonly batchWriteDelayMs?: number;
87
+ readonly sanitize?: (event: Event) => Event;
88
+ readonly features?: FeatureConfig;
89
+ }>;
90
+
91
+ /**
92
+ * Export options
93
+ */
94
+ export type ExportOptions = Readonly<{
95
+ readonly mode: 'repro' | 'full';
96
+ }>;
97
+
98
+ /**
99
+ * Network event payload
100
+ */
101
+ export type NetworkEventPayload = Readonly<{
102
+ readonly url: string;
103
+ readonly method: string;
104
+ readonly status?: number;
105
+ readonly duration?: number;
106
+ readonly error?: string;
107
+ readonly requestHeaders?: Readonly<Record<string, string>>;
108
+ readonly responseHeaders?: Readonly<Record<string, string>>;
109
+ readonly requestBody?: unknown;
110
+ readonly responseBody?: unknown;
111
+ }>;
112
+
113
+ /**
114
+ * Error event payload
115
+ */
116
+ export type ErrorEventPayload = Readonly<{
117
+ readonly message: string;
118
+ readonly stack?: string;
119
+ readonly isFatal?: boolean;
120
+ readonly context?: unknown;
121
+ }>;
122
+
123
+ /**
124
+ * Result type for operations that can fail
125
+ */
126
+ export type Result<T, E = Error> =
127
+ | Readonly<{ readonly ok: true; readonly value: T }>
128
+ | Readonly<{ readonly ok: false; readonly error: E }>;
129
+
130
+ /**
131
+ * Event query for filtering
132
+ */
133
+ export type EventQuery = Readonly<{
134
+ readonly category?: ReadonlyArray<EventCategory>;
135
+ readonly timeRange?: Readonly<{ readonly start: number; readonly end: number }>;
136
+ readonly sessionId?: string;
137
+ readonly search?: string;
138
+ readonly limit?: number;
139
+ }>;
140
+
141
+ /**
142
+ * EventLog API
143
+ */
144
+ export type EventLog = Readonly<{
145
+ readonly init: (config?: Partial<EventLogConfig>) => Promise<Result<void>>;
146
+ readonly isReady: () => boolean;
147
+ readonly screen: (name: string, params?: unknown) => Result<void>;
148
+ readonly action: (name: string, data?: unknown) => Result<void>;
149
+ readonly log: (level: LogLevel, message: string, data?: unknown) => Result<void>;
150
+ readonly error: (error: unknown, context?: unknown) => Result<void>;
151
+ readonly setUser: (user: Readonly<Record<string, unknown>>) => void;
152
+ readonly setContext: (key: string, value: unknown) => void;
153
+ readonly setDeviceInfo: (info: Readonly<Record<string, unknown>>) => void;
154
+ readonly export: (options: ExportOptions) => Promise<Result<string>>;
155
+ readonly clear: () => Promise<Result<void>>;
156
+ readonly getEvents: () => Result<ReadonlyArray<Event>>;
157
+ readonly query: (query: EventQuery) => Result<ReadonlyArray<Event>>;
158
+ readonly network: (payload: NetworkEventPayload) => Result<void>;
159
+ }>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * EventItem component
3
+ */
4
+
5
+ import React from 'react';
6
+ import { View, Text, TouchableOpacity } from 'react-native';
7
+ import type { EventItemProps } from './types';
8
+ import { CATEGORY_COLORS } from './types';
9
+ import { styles } from './styles';
10
+
11
+ export const EventItem: React.FC<EventItemProps> = ({ event, expanded, onPress }) => {
12
+ return (
13
+ <TouchableOpacity onPress={onPress} style={styles.eventItem}>
14
+ {/* Event Header */}
15
+ <View style={styles.eventHeader}>
16
+ <View
17
+ style={[
18
+ styles.categoryBadge,
19
+ { backgroundColor: CATEGORY_COLORS[event.category] },
20
+ ]}
21
+ >
22
+ <Text style={styles.categoryText}>{event.category}</Text>
23
+ </View>
24
+ <Text style={styles.timestamp}>
25
+ {new Date(event.timestamp).toLocaleTimeString()}
26
+ </Text>
27
+ </View>
28
+
29
+ {/* Event Summary */}
30
+ <Text style={styles.eventSummary} numberOfLines={expanded ? undefined : 1}>
31
+ {JSON.stringify(event.payload)}
32
+ </Text>
33
+
34
+ {/* Expanded Details */}
35
+ {expanded && (
36
+ <View style={styles.eventDetails}>
37
+ <Text style={styles.detailsLabel}>Session:</Text>
38
+ <Text style={styles.detailsText}>{event.sessionId}</Text>
39
+
40
+ <Text style={styles.detailsLabel}>Sequence:</Text>
41
+ <Text style={styles.detailsText}>{event.seq}</Text>
42
+
43
+ <Text style={styles.detailsLabel}>Timezone:</Text>
44
+ <Text style={styles.detailsText}>{event.timezone}</Text>
45
+
46
+ {event.context && Object.keys(event.context).length > 0 && (
47
+ <>
48
+ <Text style={styles.detailsLabel}>Context:</Text>
49
+ <Text style={styles.detailsText}>
50
+ {JSON.stringify(event.context, null, 2)}
51
+ </Text>
52
+ </>
53
+ )}
54
+
55
+ <Text style={styles.detailsLabel}>Full Event:</Text>
56
+ <Text style={styles.detailsText}>
57
+ {JSON.stringify(event, null, 2)}
58
+ </Text>
59
+ </View>
60
+ )}
61
+ </TouchableOpacity>
62
+ );
63
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * EventList component
3
+ */
4
+
5
+ import React from 'react';
6
+ import { FlatList, View, Text } from 'react-native';
7
+ import type { EventListProps } from './types';
8
+ import { EventItem } from './EventItem';
9
+ import { styles } from './styles';
10
+ import type { Event } from '../types';
11
+
12
+ export const EventList: React.FC<EventListProps> = ({ events, onEventPress, expandedId }) => {
13
+ const renderItem = ({ item }: { item: Event }) => (
14
+ <EventItem
15
+ event={item}
16
+ expanded={expandedId === item.eventId}
17
+ onPress={() => onEventPress(item.eventId)}
18
+ />
19
+ );
20
+
21
+ const keyExtractor = (item: Event) => item.eventId;
22
+
23
+ const renderEmpty = () => (
24
+ <View style={styles.emptyState}>
25
+ <Text style={styles.emptyText}>No events</Text>
26
+ </View>
27
+ );
28
+
29
+ return (
30
+ <FlatList
31
+ data={events}
32
+ renderItem={renderItem}
33
+ keyExtractor={keyExtractor}
34
+ ListEmptyComponent={renderEmpty}
35
+ style={styles.eventList}
36
+ initialNumToRender={20}
37
+ maxToRenderPerBatch={10}
38
+ windowSize={5}
39
+ />
40
+ );
41
+ };