@luciq/react-native 19.4.0 → 19.6.0-51917-SNAPSHOT
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/.claude/agents/codebase-analyzer.md +33 -0
- package/.claude/agents/codebase-locator.md +42 -0
- package/.claude/agents/codebase-pattern-finder.md +40 -0
- package/.claude/commands/apply-pr-reviews.md +253 -0
- package/.claude/commands/create-jira-workitem.md +27 -0
- package/.claude/commands/create-pr.md +138 -0
- package/.claude/commands/create-public-release-notes.md +145 -0
- package/.claude/commands/create-rca.md +286 -0
- package/.claude/commands/debug-sdk.md +66 -0
- package/.claude/commands/describe-pr.md +40 -0
- package/.claude/commands/new-api.md +60 -0
- package/.claude/commands/new-feature.md +75 -0
- package/.claude/commands/pr-review.md +85 -0
- package/.claude/commands/research-codebase.md +41 -0
- package/.claude/commands/review.md +73 -0
- package/.claude/memory/MEMORY.md +1 -0
- package/.claude/memory/feedback_pr_title_format.md +10 -0
- package/.claude/rules/react-native-typescript.md +46 -0
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +125 -0
- package/android/native.gradle +1 -1
- package/android/proguard-rules.txt +1 -1
- package/android/src/main/java/ai/luciq/reactlibrary/LuciqScreenLoadingFrameTracker.java +88 -0
- package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqAPMModule.java +193 -10
- package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqNetworkLoggerModule.java +29 -7
- package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqReactnativeModule.java +36 -12
- package/android/src/main/java/ai/luciq/reactlibrary/utils/EventEmitterModule.java +7 -0
- package/dist/components/LuciqCaptureScreenLoading.d.ts +8 -0
- package/dist/components/LuciqCaptureScreenLoading.js +154 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/modules/APM.d.ts +19 -0
- package/dist/modules/APM.js +38 -0
- package/dist/modules/Luciq.d.ts +1 -1
- package/dist/modules/Luciq.js +179 -12
- package/dist/modules/NetworkLogger.d.ts +0 -5
- package/dist/modules/NetworkLogger.js +9 -1
- package/dist/modules/apm/ScreenLoadingManager.d.ts +99 -0
- package/dist/modules/apm/ScreenLoadingManager.js +296 -0
- package/dist/native/NativeAPM.d.ts +9 -0
- package/dist/native/NativeLuciq.d.ts +1 -1
- package/dist/utils/FeatureFlags.d.ts +6 -0
- package/dist/utils/FeatureFlags.js +35 -0
- package/dist/utils/LuciqUtils.d.ts +25 -0
- package/dist/utils/LuciqUtils.js +50 -0
- package/dist/utils/RouteMatcher.d.ts +30 -0
- package/dist/utils/RouteMatcher.js +67 -0
- package/dist/utils/XhrNetworkInterceptor.js +85 -53
- package/ios/RNLuciq/LuciqAPMBridge.m +82 -0
- package/ios/RNLuciq/LuciqReactBridge.m +1 -1
- package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.h +11 -0
- package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.m +121 -0
- package/ios/RNLuciq/Util/LCQAPM+PrivateAPIs.h +14 -0
- package/ios/native.rb +1 -1
- package/package.json +4 -2
- package/scripts/get-github-app-token.sh +70 -0
- package/scripts/notify-github.sh +17 -8
- package/src/components/LuciqCaptureScreenLoading.tsx +210 -0
- package/src/index.ts +4 -0
- package/src/modules/APM.ts +42 -0
- package/src/modules/Luciq.ts +210 -12
- package/src/modules/NetworkLogger.ts +26 -1
- package/src/modules/apm/ScreenLoadingManager.ts +364 -0
- package/src/native/NativeAPM.ts +22 -0
- package/src/native/NativeLuciq.ts +1 -1
- package/src/utils/FeatureFlags.ts +44 -0
- package/src/utils/LuciqUtils.ts +64 -0
- package/src/utils/RouteMatcher.ts +83 -0
- package/src/utils/XhrNetworkInterceptor.ts +128 -55
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { NativeAPM } from '../../native/NativeAPM';
|
|
2
|
+
import { Logger } from '../../utils/logger';
|
|
3
|
+
import { fromEpochMicros, nowMicros, toEpochMicros } from '../../utils/LuciqUtils';
|
|
4
|
+
|
|
5
|
+
export interface ScreenLoadingSpan {
|
|
6
|
+
spanId: string;
|
|
7
|
+
screenName: string;
|
|
8
|
+
startTimestamp: number;
|
|
9
|
+
endTimestamp?: number;
|
|
10
|
+
ttid?: number;
|
|
11
|
+
status: 'pending' | 'measuring' | 'completed' | 'error';
|
|
12
|
+
isManual: boolean;
|
|
13
|
+
attributes: Map<string, number>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Automatic Screen Loading Measurement
|
|
18
|
+
*
|
|
19
|
+
* Start point: `__unsafe_action__` navigation event (below).
|
|
20
|
+
* - Fires when a navigation action is dispatched, before the target screen mounts.
|
|
21
|
+
* - `ScreenLoadingManager.createSpan()` records `nowMicros()` as the span start.
|
|
22
|
+
*
|
|
23
|
+
* End point: `_onNavigationStateChange()` (called from `onStateChange`).
|
|
24
|
+
* - Fires after navigation state has settled and the new screen is mounted.
|
|
25
|
+
* - `ScreenLoadingManager.endSpan()` fetches the native frame timestamp from
|
|
26
|
+
* CADisplayLink (iOS) / Choreographer (Android) to mark actual render completion.
|
|
27
|
+
* - The TTID is: native frame timestamp − span start.
|
|
28
|
+
*
|
|
29
|
+
*
|
|
30
|
+
* Manual Screen Loading Measurement
|
|
31
|
+
*
|
|
32
|
+
* Start point: Component instantiation (lazy init block before first render).
|
|
33
|
+
* - `nowMicros()` is captured as `constructorTimestampRef` and passed to
|
|
34
|
+
* `ScreenLoadingManager.createSpan()` as the span's start timestamp.
|
|
35
|
+
*
|
|
36
|
+
* End point: `useLayoutEffect` (fires synchronously after React commits DOM
|
|
37
|
+
* mutations, before the browser paints).
|
|
38
|
+
* - `ScreenLoadingManager.endSpan()` fetches the native frame timestamp
|
|
39
|
+
* from CADisplayLink (iOS) / Choreographer (Android) to mark the actual
|
|
40
|
+
* render completion. The TTID is: native frame timestamp − span start.
|
|
41
|
+
*
|
|
42
|
+
* Both approaches share the same `endSpan()` path so TTID values are comparable.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
class ScreenLoadingManagerClass {
|
|
46
|
+
private activeSpans: Map<string, ScreenLoadingSpan> = new Map();
|
|
47
|
+
private isInitialized: boolean = false;
|
|
48
|
+
private isEnabled: boolean = false;
|
|
49
|
+
private isEndScreenLoadingEnabled: boolean = false;
|
|
50
|
+
private isFrameTrackingInitialized: boolean = false;
|
|
51
|
+
private activeSpanId: string | null = null;
|
|
52
|
+
private maxConcurrentSpans: number = 50;
|
|
53
|
+
private excludedRoutes: Set<string> = new Set();
|
|
54
|
+
|
|
55
|
+
async initialize(): Promise<void> {
|
|
56
|
+
if (this.isInitialized) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Check feature flags
|
|
62
|
+
this.isEnabled = await NativeAPM.isScreenLoadingEnabled();
|
|
63
|
+
this.isEndScreenLoadingEnabled = await NativeAPM.isEndScreenLoadingEnabled();
|
|
64
|
+
if (this.isEnabled) {
|
|
65
|
+
await NativeAPM.initScreenFrameTracking();
|
|
66
|
+
this.isFrameTrackingInitialized = true;
|
|
67
|
+
Logger.log('[ScreenLoading] Manager initialized, feature enabled');
|
|
68
|
+
} else {
|
|
69
|
+
Logger.log('[ScreenLoading] Feature disabled by flag');
|
|
70
|
+
}
|
|
71
|
+
this.isInitialized = true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
Logger.error('[ScreenLoading] Failed to initialize:', error);
|
|
74
|
+
this.isEnabled = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async refreshFlags(): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
this.isEnabled = await NativeAPM.isScreenLoadingEnabled();
|
|
81
|
+
this.isEndScreenLoadingEnabled = await NativeAPM.isEndScreenLoadingEnabled();
|
|
82
|
+
|
|
83
|
+
if (this.isEnabled && !this.isFrameTrackingInitialized) {
|
|
84
|
+
await NativeAPM.initScreenFrameTracking();
|
|
85
|
+
this.isFrameTrackingInitialized = true;
|
|
86
|
+
Logger.log('[ScreenLoading] Frame tracking initialized after flag refresh');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Logger.log(
|
|
90
|
+
`[ScreenLoading] Flags refreshed - enabled: ${this.isEnabled}, endScreenLoading: ${this.isEndScreenLoadingEnabled}`,
|
|
91
|
+
);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
Logger.error('[ScreenLoading] Failed to refresh flags:', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Exclude specific routes from automatic screen loading measurement
|
|
99
|
+
* @param routes Array of route names to exclude
|
|
100
|
+
*/
|
|
101
|
+
excludeRoutes(routes: string[]): void {
|
|
102
|
+
routes.forEach((route) => this.excludedRoutes.add(route));
|
|
103
|
+
Logger.log('[ScreenLoading] Excluded routes:', Array.from(this.excludedRoutes));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Include previously excluded routes back into screen loading measurement
|
|
108
|
+
* @param routes Array of route names to include (or empty to clear all exclusions)
|
|
109
|
+
*/
|
|
110
|
+
includeRoutes(routes?: string[]): void {
|
|
111
|
+
if (!routes || routes.length === 0) {
|
|
112
|
+
this.excludedRoutes.clear();
|
|
113
|
+
Logger.log('[ScreenLoading] Cleared all route exclusions');
|
|
114
|
+
} else {
|
|
115
|
+
routes.forEach((route) => this.excludedRoutes.delete(route));
|
|
116
|
+
Logger.log('[ScreenLoading] Removed exclusions for:', routes);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a route is excluded from measurement
|
|
122
|
+
*/
|
|
123
|
+
isRouteExcluded(routeName: string): boolean {
|
|
124
|
+
return this.excludedRoutes.has(routeName);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a new screen loading span
|
|
129
|
+
* @param screenName Name of the screen
|
|
130
|
+
* @param isManual Whether the span is manual (not automatically created)
|
|
131
|
+
* @param startTimestampParam Optional start timestamp in microseconds (defaults to nowMicros())
|
|
132
|
+
* @returns The created span or null if the feature is not enabled
|
|
133
|
+
*/
|
|
134
|
+
createSpan(
|
|
135
|
+
screenName: string,
|
|
136
|
+
isManual: boolean = false,
|
|
137
|
+
startTimestampParam?: number,
|
|
138
|
+
): ScreenLoadingSpan | null {
|
|
139
|
+
if (!this.isEnabled) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if route is excluded (only for automatic tracking)
|
|
144
|
+
if (!isManual && this.isRouteExcluded(screenName)) {
|
|
145
|
+
Logger.log(`[ScreenLoading] Route "${screenName}" is excluded from automatic measurement`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Cleanup if exceeding capacity
|
|
150
|
+
if (this.activeSpans.size >= this.maxConcurrentSpans) {
|
|
151
|
+
this.cleanupOldestSpans();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const spanId = Date.now().toString();
|
|
155
|
+
const startTimestamp = startTimestampParam ?? nowMicros();
|
|
156
|
+
|
|
157
|
+
const span: ScreenLoadingSpan = {
|
|
158
|
+
spanId,
|
|
159
|
+
screenName,
|
|
160
|
+
startTimestamp,
|
|
161
|
+
status: 'pending',
|
|
162
|
+
isManual,
|
|
163
|
+
attributes: new Map<string, number>(),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
this.activeSpans.set(spanId, span);
|
|
167
|
+
|
|
168
|
+
// Register with native for frame tracking
|
|
169
|
+
try {
|
|
170
|
+
NativeAPM.setActiveScreenSpanId(spanId);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
Logger.error(`[ScreenLoading] Failed to set active span ID ${spanId}:`, error);
|
|
173
|
+
}
|
|
174
|
+
if (!isManual) {
|
|
175
|
+
this.activeSpanId = spanId;
|
|
176
|
+
}
|
|
177
|
+
span.status = 'measuring';
|
|
178
|
+
|
|
179
|
+
Logger.log(
|
|
180
|
+
`[ScreenLoading] Created span ${spanId} for screen "${screenName}" (${isManual ? 'manual' : 'automatic'})`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return span;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* End a screen loading span
|
|
188
|
+
* @param spanId The ID of the span to end
|
|
189
|
+
*/
|
|
190
|
+
async endSpan(spanId: string): Promise<void> {
|
|
191
|
+
if (!this.isEnabled) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const span = this.activeSpans.get(spanId);
|
|
196
|
+
if (!span || span.status === 'completed') {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// Get frame timestamp from native with retry logic
|
|
202
|
+
// The native frame callback (CADisplayLink/Choreographer) may not have executed yet
|
|
203
|
+
// if endSpan is called very quickly after createSpan. Retry up to 3 times with
|
|
204
|
+
// a delay of ~20ms (slightly more than one frame at 60fps) between attempts.
|
|
205
|
+
const maxRetries = 3;
|
|
206
|
+
const retryDelayMs = 20;
|
|
207
|
+
let frameTimestamp: number | null = null;
|
|
208
|
+
|
|
209
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
210
|
+
frameTimestamp = await NativeAPM.getScreenTimeToDisplay(spanId);
|
|
211
|
+
|
|
212
|
+
if (frameTimestamp) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Wait for next frame before retrying (only if not last attempt)
|
|
217
|
+
if (attempt < maxRetries - 1) {
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (frameTimestamp) {
|
|
223
|
+
// Native returns epoch microseconds; convert to monotonic for consistent internal math
|
|
224
|
+
span.endTimestamp = fromEpochMicros(frameTimestamp);
|
|
225
|
+
span.ttid = span.endTimestamp - span.startTimestamp;
|
|
226
|
+
|
|
227
|
+
span.status = 'completed';
|
|
228
|
+
|
|
229
|
+
// Log the measurement
|
|
230
|
+
this.logScreenLoading(span);
|
|
231
|
+
} else {
|
|
232
|
+
span.status = 'error';
|
|
233
|
+
Logger.warn(`[ScreenLoading] No frame timestamp available for span ${spanId}`);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
span.status = 'error';
|
|
237
|
+
Logger.error(`[ScreenLoading] Failed to get timestamp for span ${spanId}:`, error);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Cleanup after a delay
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
this.activeSpans.delete(spanId);
|
|
243
|
+
}, 5000);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Log a screen loading span
|
|
248
|
+
* @param span The span to log
|
|
249
|
+
*/
|
|
250
|
+
private logScreenLoading(span: ScreenLoadingSpan): void {
|
|
251
|
+
// Convert Map to plain object for JSON serialization (JSON.stringify cannot serialize Maps)
|
|
252
|
+
const attributesObject = Object.fromEntries(span.attributes);
|
|
253
|
+
|
|
254
|
+
const startEpochUs = Math.round(toEpochMicros(span.startTimestamp));
|
|
255
|
+
const endEpochUs = span.endTimestamp ? Math.round(toEpochMicros(span.endTimestamp)) : undefined;
|
|
256
|
+
|
|
257
|
+
const logData = {
|
|
258
|
+
span_id: span.spanId,
|
|
259
|
+
screen_name: span.screenName,
|
|
260
|
+
start_timestamp_us: startEpochUs,
|
|
261
|
+
end_timestamp_us: endEpochUs,
|
|
262
|
+
ttid_us: span.ttid ? Math.round(span.ttid) : undefined,
|
|
263
|
+
ttid_ms: span.ttid ? Math.round(span.ttid) / 1000 : undefined,
|
|
264
|
+
is_manual: span.isManual,
|
|
265
|
+
attributes: attributesObject,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
Logger.log('[ScreenLoading] Measurement:', JSON.stringify(logData, null, 2));
|
|
269
|
+
|
|
270
|
+
// Sync screen loading data to native layer (also pass converted object)
|
|
271
|
+
try {
|
|
272
|
+
if (span.isManual) {
|
|
273
|
+
NativeAPM.syncManualScreenLoading(
|
|
274
|
+
span.screenName,
|
|
275
|
+
startEpochUs,
|
|
276
|
+
Math.round(span.ttid!),
|
|
277
|
+
attributesObject,
|
|
278
|
+
);
|
|
279
|
+
} else {
|
|
280
|
+
NativeAPM.syncScreenLoading(
|
|
281
|
+
Number(span.spanId),
|
|
282
|
+
span.screenName,
|
|
283
|
+
startEpochUs,
|
|
284
|
+
Math.round(span.ttid!),
|
|
285
|
+
attributesObject,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
Logger.error(`[ScreenLoading] Failed to sync screen loading for span ${span.spanId}:`, error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* End a screen loading span using the current timestamp and active span ID
|
|
295
|
+
*/
|
|
296
|
+
endScreenLoading(): void {
|
|
297
|
+
if (!this.isEndScreenLoadingFeatureEnabled()) {
|
|
298
|
+
Logger.error('[ScreenLoading] End screen loading feature is not enabled');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!this.activeSpanId) {
|
|
302
|
+
Logger.warn('[ScreenLoading] No active span to end screen loading');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const timeStampMicro = Math.round(toEpochMicros(nowMicros()));
|
|
307
|
+
const uiTraceId = Number(this.activeSpanId);
|
|
308
|
+
NativeAPM.endScreenLoading(timeStampMicro, uiTraceId);
|
|
309
|
+
Logger.log(
|
|
310
|
+
`[ScreenLoading] endScreenLoading() was called at ${timeStampMicro} for ui trace id "${uiTraceId}"`,
|
|
311
|
+
);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
Logger.error('[ScreenLoading] Failed to end screen loading:', error);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Discard a span without logging or syncing it to native.
|
|
319
|
+
* Used when a span should be silently dropped (e.g., excluded route resolved after creation).
|
|
320
|
+
*/
|
|
321
|
+
discardSpan(spanId: string): void {
|
|
322
|
+
const span = this.activeSpans.get(spanId);
|
|
323
|
+
if (span) {
|
|
324
|
+
this.activeSpans.delete(spanId);
|
|
325
|
+
Logger.log(`[ScreenLoading] Discarded span ${spanId} for screen "${span.screenName}"`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getActiveSpan(spanId: string): ScreenLoadingSpan | undefined {
|
|
330
|
+
return this.activeSpans.get(spanId);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getAllActiveSpans(): ScreenLoadingSpan[] {
|
|
334
|
+
return Array.from(this.activeSpans.values());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
addSpanAttribute(spanId: string, key: string, value: number): void {
|
|
338
|
+
const span = this.activeSpans.get(spanId);
|
|
339
|
+
if (span) {
|
|
340
|
+
span.attributes.set(key, value);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private cleanupOldestSpans(): void {
|
|
345
|
+
const sortedSpans = Array.from(this.activeSpans.entries()).sort(
|
|
346
|
+
(a, b) => a[1].startTimestamp - b[1].startTimestamp,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const toRemove = Math.max(0, sortedSpans.length - 30);
|
|
350
|
+
for (let i = 0; i < toRemove; i++) {
|
|
351
|
+
this.activeSpans.delete(sortedSpans[i][0]);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
isFeatureEnabled(): boolean {
|
|
356
|
+
return this.isEnabled;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
isEndScreenLoadingFeatureEnabled(): boolean {
|
|
360
|
+
return this.isEnabled && this.isEndScreenLoadingEnabled;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export const ScreenLoadingManager = new ScreenLoadingManagerClass();
|
package/src/native/NativeAPM.ts
CHANGED
|
@@ -54,6 +54,28 @@ export interface ApmNativeModule extends NativeModule {
|
|
|
54
54
|
isCustomSpanEnabled(): Promise<boolean>;
|
|
55
55
|
|
|
56
56
|
isAPMEnabled(): Promise<boolean>;
|
|
57
|
+
|
|
58
|
+
// Screen Loading methods
|
|
59
|
+
initScreenFrameTracking(): Promise<void>;
|
|
60
|
+
setActiveScreenSpanId(spanId: string): void;
|
|
61
|
+
getScreenTimeToDisplay(spanId: string): Promise<number | null>;
|
|
62
|
+
isScreenLoadingEnabled(): Promise<boolean>;
|
|
63
|
+
isEndScreenLoadingEnabled(): Promise<boolean>;
|
|
64
|
+
endScreenLoading(timeStampMicro: number, uiTraceId: number): void;
|
|
65
|
+
setScreenLoadingEnabled(isEnabled: boolean): void;
|
|
66
|
+
syncScreenLoading(
|
|
67
|
+
spanId: number,
|
|
68
|
+
screenName: string,
|
|
69
|
+
startTimestamp: number,
|
|
70
|
+
durationUS: number,
|
|
71
|
+
attributes: Record<string, any>,
|
|
72
|
+
): void;
|
|
73
|
+
syncManualScreenLoading(
|
|
74
|
+
screenName: string,
|
|
75
|
+
startTimestamp: number,
|
|
76
|
+
durationUS: number,
|
|
77
|
+
attributes: Record<string, any>,
|
|
78
|
+
): void;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
export const NativeAPM = NativeModules.LCQAPM;
|
|
@@ -92,7 +92,7 @@ export interface LuciqNativeModule extends NativeModule {
|
|
|
92
92
|
sessionReplay: ReproStepsMode,
|
|
93
93
|
): void;
|
|
94
94
|
setTrackUserSteps(isEnabled: boolean): void;
|
|
95
|
-
reportScreenChange(
|
|
95
|
+
reportScreenChange(screenName: string, spanId: string | null): void;
|
|
96
96
|
reportCurrentViewChange(screenName: string): void;
|
|
97
97
|
addPrivateView(nativeTag: number | null): void;
|
|
98
98
|
removePrivateView(nativeTag: number | null): void;
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { NativeLuciq } from '../native/NativeLuciq';
|
|
2
2
|
import { _registerFeatureFlagsChangeListener } from '../modules/Luciq';
|
|
3
|
+
import { Logger } from './logger';
|
|
4
|
+
|
|
5
|
+
const TAG = 'LCQ-RN-NET:';
|
|
6
|
+
|
|
7
|
+
let cachedW3cFlags = {
|
|
8
|
+
isW3cExternalTraceIDEnabled: false,
|
|
9
|
+
isW3cExternalGeneratedHeaderEnabled: false,
|
|
10
|
+
isW3cCaughtHeaderEnabled: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function initFeatureFlagsCache() {
|
|
14
|
+
Logger.debug(TAG, '[FeatureFlags] Initializing W3C feature flags cache from native bridge...');
|
|
15
|
+
try {
|
|
16
|
+
const [traceID, generatedHeader, caughtHeader] = await Promise.all([
|
|
17
|
+
NativeLuciq.isW3ExternalTraceIDEnabled(),
|
|
18
|
+
NativeLuciq.isW3ExternalGeneratedHeaderEnabled(),
|
|
19
|
+
NativeLuciq.isW3CaughtHeaderEnabled(),
|
|
20
|
+
]);
|
|
21
|
+
cachedW3cFlags = {
|
|
22
|
+
isW3cExternalTraceIDEnabled: traceID,
|
|
23
|
+
isW3cExternalGeneratedHeaderEnabled: generatedHeader,
|
|
24
|
+
isW3cCaughtHeaderEnabled: caughtHeader,
|
|
25
|
+
};
|
|
26
|
+
Logger.debug(
|
|
27
|
+
TAG,
|
|
28
|
+
`[FeatureFlags] Cache initialized: traceID=${traceID}, generatedHeader=${generatedHeader}, caughtHeader=${caughtHeader}`,
|
|
29
|
+
);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
Logger.debug(TAG, '[FeatureFlags] Failed to initialize cache, using defaults (all false):', e);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getCachedW3cFlags() {
|
|
36
|
+
return cachedW3cFlags;
|
|
37
|
+
}
|
|
3
38
|
|
|
4
39
|
export const FeatureFlags = {
|
|
5
40
|
isW3ExternalTraceID: () => NativeLuciq.isW3ExternalTraceIDEnabled(),
|
|
@@ -16,6 +51,15 @@ export const registerFeatureFlagsListener = () => {
|
|
|
16
51
|
isW3CaughtHeaderEnabled: boolean;
|
|
17
52
|
networkBodyLimit: number;
|
|
18
53
|
}) => {
|
|
54
|
+
Logger.debug(
|
|
55
|
+
TAG,
|
|
56
|
+
`[FeatureFlags] Flags updated from native listener: traceID=${res.isW3ExternalTraceIDEnabled}, generatedHeader=${res.isW3ExternalGeneratedHeaderEnabled}, caughtHeader=${res.isW3CaughtHeaderEnabled}, bodyLimit=${res.networkBodyLimit}`,
|
|
57
|
+
);
|
|
58
|
+
cachedW3cFlags = {
|
|
59
|
+
isW3cExternalTraceIDEnabled: res.isW3ExternalTraceIDEnabled,
|
|
60
|
+
isW3cExternalGeneratedHeaderEnabled: res.isW3ExternalGeneratedHeaderEnabled,
|
|
61
|
+
isW3cCaughtHeaderEnabled: res.isW3CaughtHeaderEnabled,
|
|
62
|
+
};
|
|
19
63
|
FeatureFlags.isW3ExternalTraceID = async () => {
|
|
20
64
|
return res.isW3ExternalTraceIDEnabled;
|
|
21
65
|
};
|
package/src/utils/LuciqUtils.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { NativeCrashReporting } from '../native/NativeCrashReporting';
|
|
|
12
12
|
import type { NetworkData } from './XhrNetworkInterceptor';
|
|
13
13
|
import { NativeLuciq } from '../native/NativeLuciq';
|
|
14
14
|
import { NativeAPM } from '../native/NativeAPM';
|
|
15
|
+
import { Logger } from './logger';
|
|
15
16
|
import * as NetworkLogger from '../modules/NetworkLogger';
|
|
16
17
|
import {
|
|
17
18
|
NativeNetworkLogger,
|
|
@@ -231,6 +232,11 @@ export const reportNetworkLog = (network: NetworkData) => {
|
|
|
231
232
|
const requestHeaders = JSON.stringify(network.requestHeaders);
|
|
232
233
|
const responseHeaders = JSON.stringify(network.responseHeaders);
|
|
233
234
|
|
|
235
|
+
Logger.debug(
|
|
236
|
+
'LCQ-RN-NET:',
|
|
237
|
+
`[reportNetworkLog] Sending to NativeLuciq.networkLogAndroid: ${network.method} ${network.url}, status=${network.responseCode}, duration=${network.duration}ms, error=${network.errorDomain || 'none'}`,
|
|
238
|
+
);
|
|
239
|
+
|
|
234
240
|
NativeLuciq.networkLogAndroid(
|
|
235
241
|
network.url,
|
|
236
242
|
network.requestBody,
|
|
@@ -246,6 +252,10 @@ export const reportNetworkLog = (network: NetworkData) => {
|
|
|
246
252
|
!apmFlags.hasAPMNetworkPlugin ||
|
|
247
253
|
!apmFlags.shouldEnableNativeInterception
|
|
248
254
|
) {
|
|
255
|
+
Logger.debug(
|
|
256
|
+
'LCQ-RN-NET:',
|
|
257
|
+
`[reportNetworkLog] Also sending to NativeAPM.networkLogAndroid (native interception disabled): ${network.method} ${network.url}`,
|
|
258
|
+
);
|
|
249
259
|
NativeAPM.networkLogAndroid(
|
|
250
260
|
network.startTime,
|
|
251
261
|
network.duration,
|
|
@@ -271,6 +281,11 @@ export const reportNetworkLog = (network: NetworkData) => {
|
|
|
271
281
|
network.gqlQueryName,
|
|
272
282
|
network.serverErrorMessage,
|
|
273
283
|
);
|
|
284
|
+
} else {
|
|
285
|
+
Logger.debug(
|
|
286
|
+
'LCQ-RN-NET:',
|
|
287
|
+
`[reportNetworkLog] Skipping NativeAPM.networkLogAndroid (native interception enabled): nativeFeature=${apmFlags.isNativeInterceptionFeatureEnabled}, hasPlugin=${apmFlags.hasAPMNetworkPlugin}, shouldEnable=${apmFlags.shouldEnableNativeInterception}`,
|
|
288
|
+
);
|
|
274
289
|
}
|
|
275
290
|
} else {
|
|
276
291
|
NativeLuciq.networkLogIOS(
|
|
@@ -420,6 +435,54 @@ export function updateNetworkLogSnapshot(networkSnapshot: NetworkData) {
|
|
|
420
435
|
);
|
|
421
436
|
}
|
|
422
437
|
|
|
438
|
+
/**
|
|
439
|
+
* @internal
|
|
440
|
+
* This method is for internal use only.
|
|
441
|
+
*
|
|
442
|
+
* Parses a string value to an integer, returning null if the value is null or cannot be parsed.
|
|
443
|
+
* @param value The string value to parse
|
|
444
|
+
* @returns The parsed integer or null
|
|
445
|
+
*/
|
|
446
|
+
export function getIntValue(value: string | null): number | null {
|
|
447
|
+
if (value === null) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const parsed = parseInt(value, 10);
|
|
451
|
+
return isNaN(parsed) ? null : parsed;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// One-time anchor captured at module load to convert performance.now() to epoch time.
|
|
455
|
+
// performance.now() gives high-resolution monotonic time (sub-ms precision) but relative
|
|
456
|
+
// to app start. We pair it with Date.now() once, then derive epoch from the offset.
|
|
457
|
+
const perfAnchorMs: number = performance.now();
|
|
458
|
+
const epochAnchorUs: number = Date.now() * 1000;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Returns a high-resolution monotonic timestamp in microseconds.
|
|
462
|
+
* Use this for all internal duration measurements.
|
|
463
|
+
*/
|
|
464
|
+
export function nowMicros(): number {
|
|
465
|
+
return performance.now() * 1000;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Converts an internal monotonic microsecond timestamp to epoch microseconds.
|
|
470
|
+
* Use this only when reporting to the native layer or external systems.
|
|
471
|
+
*/
|
|
472
|
+
export function toEpochMicros(monotonicUs: number): number {
|
|
473
|
+
const offsetUs = monotonicUs - perfAnchorMs * 1000;
|
|
474
|
+
return epochAnchorUs + offsetUs;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Converts an epoch microsecond timestamp to internal monotonic microseconds.
|
|
479
|
+
* Use this when receiving timestamps from the native layer that are epoch-based.
|
|
480
|
+
*/
|
|
481
|
+
export function fromEpochMicros(epochUs: number): number {
|
|
482
|
+
const offsetUs = epochUs - epochAnchorUs;
|
|
483
|
+
return perfAnchorMs * 1000 + offsetUs;
|
|
484
|
+
}
|
|
485
|
+
|
|
423
486
|
export default {
|
|
424
487
|
parseErrorStack,
|
|
425
488
|
captureJsErrors,
|
|
@@ -427,6 +490,7 @@ export default {
|
|
|
427
490
|
getFullRoute,
|
|
428
491
|
getStackTrace,
|
|
429
492
|
stringifyIfNotString,
|
|
493
|
+
getIntValue,
|
|
430
494
|
sendCrashReport,
|
|
431
495
|
reportNetworkLog,
|
|
432
496
|
generateTracePartialId,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// TODO: This class is currently unused but will be used later for route matching.
|
|
2
|
+
/**
|
|
3
|
+
* Matches route path definitions (potentially containing parameters and wildcards)
|
|
4
|
+
* against actual navigation paths.
|
|
5
|
+
*
|
|
6
|
+
* Supports `:param` segments for named parameters and `**` for wildcard matching.
|
|
7
|
+
*/
|
|
8
|
+
class RouteMatcher {
|
|
9
|
+
private static _instance: RouteMatcher = new RouteMatcher();
|
|
10
|
+
|
|
11
|
+
static get instance(): RouteMatcher {
|
|
12
|
+
return RouteMatcher._instance;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @internal visible for testing */
|
|
16
|
+
static setInstance(instance: RouteMatcher): void {
|
|
17
|
+
RouteMatcher._instance = instance;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks whether the given `routePath` definition matches the given `actualPath`.
|
|
22
|
+
*
|
|
23
|
+
* The `routePath` definition can contain parameters in the form of `:param`,
|
|
24
|
+
* or `**` for a wildcard parameter.
|
|
25
|
+
*
|
|
26
|
+
* Returns `true` if the `actualPath` matches the `routePath`, otherwise `false`.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* RouteMatcher.instance.match('/users', '/users'); // true
|
|
31
|
+
* RouteMatcher.instance.match('/user/:id', '/user/123'); // true
|
|
32
|
+
* RouteMatcher.instance.match('/user/**', '/user/123/profile'); // true
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
match(routePath: string | null, actualPath: string | null): boolean {
|
|
36
|
+
if (routePath == null || actualPath == null) {
|
|
37
|
+
return routePath === actualPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const routePathSegments = this.segmentPath(routePath);
|
|
41
|
+
const actualPathSegments = this.segmentPath(actualPath);
|
|
42
|
+
|
|
43
|
+
const hasWildcard = routePathSegments.includes('**');
|
|
44
|
+
|
|
45
|
+
if (routePathSegments.length !== actualPathSegments.length && !hasWildcard) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < routePathSegments.length; i++) {
|
|
50
|
+
const routeSegment = routePathSegments[i];
|
|
51
|
+
|
|
52
|
+
const isWildcard = routeSegment === '**';
|
|
53
|
+
const isParameter = routeSegment.startsWith(':');
|
|
54
|
+
|
|
55
|
+
const noMoreActualSegments = i >= actualPathSegments.length;
|
|
56
|
+
|
|
57
|
+
if (noMoreActualSegments) {
|
|
58
|
+
return isWildcard;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isParameter) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (isWildcard) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (routeSegment !== actualPathSegments[i]) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private segmentPath(path: string): string[] {
|
|
78
|
+
const pathWithoutQuery = path.split('?')[0];
|
|
79
|
+
return pathWithoutQuery.split('/').filter((segment) => segment.length > 0);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default RouteMatcher;
|