@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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import LuciqConstants from './LuciqConstants';
|
|
2
2
|
import { stringifyIfNotString, generateW3CHeader } from './LuciqUtils';
|
|
3
|
-
import {
|
|
3
|
+
import { getCachedW3cFlags } from './FeatureFlags';
|
|
4
|
+
import { Logger } from './logger';
|
|
5
|
+
const TAG = 'LCQ-RN-NET:';
|
|
4
6
|
const XMLHttpRequest = global.XMLHttpRequest;
|
|
5
7
|
let originalXHROpen = XMLHttpRequest.prototype.open;
|
|
6
8
|
let originalXHRSend = XMLHttpRequest.prototype.send;
|
|
@@ -8,40 +10,34 @@ let originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
|
|
8
10
|
let onProgressCallback;
|
|
9
11
|
let onDoneCallback;
|
|
10
12
|
let isInterceptorEnabled = false;
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
};
|
|
39
|
-
const getTraceparentHeader = async (networkData) => {
|
|
40
|
-
const [isW3cExternalTraceIDEnabled, isW3cExternalGeneratedHeaderEnabled, isW3cCaughtHeaderEnabled,] = await Promise.all([
|
|
41
|
-
FeatureFlags.isW3ExternalTraceID(),
|
|
42
|
-
FeatureFlags.isW3ExternalGeneratedHeader(),
|
|
43
|
-
FeatureFlags.isW3CaughtHeader(),
|
|
44
|
-
]);
|
|
13
|
+
const networkMap = new WeakMap();
|
|
14
|
+
const createNetworkData = () => ({
|
|
15
|
+
id: '',
|
|
16
|
+
url: '',
|
|
17
|
+
method: '',
|
|
18
|
+
requestBody: '',
|
|
19
|
+
requestBodySize: 0,
|
|
20
|
+
responseBody: '',
|
|
21
|
+
responseBodySize: 0,
|
|
22
|
+
responseCode: 0,
|
|
23
|
+
requestHeaders: {},
|
|
24
|
+
responseHeaders: {},
|
|
25
|
+
contentType: '',
|
|
26
|
+
errorDomain: '',
|
|
27
|
+
errorCode: 0,
|
|
28
|
+
startTime: 0,
|
|
29
|
+
duration: 0,
|
|
30
|
+
gqlQueryName: '',
|
|
31
|
+
serverErrorMessage: '',
|
|
32
|
+
requestContentType: '',
|
|
33
|
+
isW3cHeaderFound: null,
|
|
34
|
+
partialId: null,
|
|
35
|
+
networkStartTimeInSeconds: null,
|
|
36
|
+
w3cGeneratedHeader: null,
|
|
37
|
+
w3cCaughtHeader: null,
|
|
38
|
+
});
|
|
39
|
+
const getTraceparentHeader = (networkData) => {
|
|
40
|
+
const { isW3cExternalTraceIDEnabled, isW3cExternalGeneratedHeaderEnabled, isW3cCaughtHeaderEnabled, } = getCachedW3cFlags();
|
|
45
41
|
return injectHeaders(networkData, {
|
|
46
42
|
isW3cExternalTraceIDEnabled,
|
|
47
43
|
isW3cExternalGeneratedHeaderEnabled,
|
|
@@ -85,39 +81,57 @@ export default {
|
|
|
85
81
|
onProgressCallback = callback;
|
|
86
82
|
},
|
|
87
83
|
enableInterception() {
|
|
88
|
-
// Prevents infinite calls to XMLHttpRequest.open when enabling interception multiple times
|
|
89
84
|
if (isInterceptorEnabled) {
|
|
85
|
+
Logger.debug(TAG, 'enableInterception called but already enabled, skipping');
|
|
90
86
|
return;
|
|
91
87
|
}
|
|
88
|
+
Logger.debug(TAG, 'Enabling XHR network interception');
|
|
92
89
|
originalXHROpen = XMLHttpRequest.prototype.open;
|
|
93
90
|
originalXHRSend = XMLHttpRequest.prototype.send;
|
|
94
91
|
originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
|
95
92
|
// An error code that signifies an issue with the RN client.
|
|
96
93
|
const clientErrorCode = 9876;
|
|
97
94
|
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
const networkData = createNetworkData();
|
|
96
|
+
networkData.url = url;
|
|
97
|
+
networkData.method = method;
|
|
98
|
+
networkMap.set(this, networkData);
|
|
99
|
+
Logger.debug(TAG, `[open] ${method} ${url}`);
|
|
101
100
|
originalXHROpen.apply(this, [method, url, ...args]);
|
|
102
101
|
};
|
|
103
102
|
XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
|
|
104
|
-
// According to the HTTP RFC, headers are case-insensitive, so we convert
|
|
105
|
-
// them to lower-case to make accessing headers predictable.
|
|
106
|
-
// This avoid issues like failing to get the Content-Type header for a request
|
|
107
|
-
// because the header is set as 'Content-Type' instead of 'content-type'.
|
|
108
103
|
const key = header.toLowerCase();
|
|
109
|
-
|
|
104
|
+
const networkData = networkMap.get(this);
|
|
105
|
+
if (networkData) {
|
|
106
|
+
networkData.requestHeaders[key] = stringifyIfNotString(value);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
Logger.debug(TAG, `[setRequestHeader] No networkData found in WeakMap for header "${key}" — request may have been GC'd or open() was not called`);
|
|
110
|
+
}
|
|
110
111
|
originalXHRSetRequestHeader.apply(this, [header, value]);
|
|
111
112
|
};
|
|
112
|
-
XMLHttpRequest.prototype.send =
|
|
113
|
-
const
|
|
113
|
+
XMLHttpRequest.prototype.send = function (data) {
|
|
114
|
+
const networkData = networkMap.get(this);
|
|
115
|
+
if (!networkData) {
|
|
116
|
+
Logger.debug(TAG, '[send] No networkData found in WeakMap — falling back to original send (open() was not intercepted)');
|
|
117
|
+
originalXHRSend.apply(this, [data]);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
Logger.debug(TAG, `[send] ${networkData.method} ${networkData.url}`);
|
|
121
|
+
const cloneNetwork = JSON.parse(JSON.stringify(networkData));
|
|
114
122
|
cloneNetwork.requestBody = data ? data : '';
|
|
115
123
|
if (typeof cloneNetwork.requestBody !== 'string') {
|
|
116
124
|
cloneNetwork.requestBody = JSON.stringify(cloneNetwork.requestBody);
|
|
117
125
|
}
|
|
126
|
+
let isReported = false;
|
|
118
127
|
if (this.addEventListener) {
|
|
119
128
|
this.addEventListener('readystatechange', async () => {
|
|
120
129
|
if (!isInterceptorEnabled) {
|
|
130
|
+
Logger.debug(TAG, `[readystatechange] Interceptor disabled, ignoring state=${this.readyState} for ${cloneNetwork.url}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (isReported) {
|
|
134
|
+
Logger.debug(TAG, `[readystatechange] Already reported, ignoring state=${this.readyState} for ${cloneNetwork.url}`);
|
|
121
135
|
return;
|
|
122
136
|
}
|
|
123
137
|
if (this.readyState === this.HEADERS_RECEIVED) {
|
|
@@ -146,6 +160,7 @@ export default {
|
|
|
146
160
|
cloneNetwork.requestContentType =
|
|
147
161
|
cloneNetwork.requestHeaders['content-type'].split(';')[0];
|
|
148
162
|
}
|
|
163
|
+
Logger.debug(TAG, `[readystatechange] HEADERS_RECEIVED for ${cloneNetwork.url}, contentType=${cloneNetwork.contentType}`);
|
|
149
164
|
}
|
|
150
165
|
if (this.readyState === this.DONE) {
|
|
151
166
|
cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
|
|
@@ -166,11 +181,11 @@ export default {
|
|
|
166
181
|
cloneNetwork.requestBody =
|
|
167
182
|
typeof _response === 'string' ? _response : JSON.stringify(_response);
|
|
168
183
|
cloneNetwork.responseBody = '';
|
|
169
|
-
// Detect a more descriptive error message.
|
|
170
184
|
if (typeof _response === 'string' && _response.length > 0) {
|
|
171
185
|
cloneNetwork.errorDomain = _response;
|
|
172
186
|
}
|
|
173
187
|
cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
|
|
188
|
+
Logger.debug(TAG, `[readystatechange] DONE with client error for ${cloneNetwork.url}, errorDomain=${cloneNetwork.errorDomain}`);
|
|
174
189
|
// @ts-ignore
|
|
175
190
|
}
|
|
176
191
|
else if (this._timedOut) {
|
|
@@ -179,6 +194,7 @@ export default {
|
|
|
179
194
|
cloneNetwork.responseCode = 0;
|
|
180
195
|
cloneNetwork.contentType = 'text/plain';
|
|
181
196
|
cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
|
|
197
|
+
Logger.debug(TAG, `[readystatechange] DONE with timeout for ${cloneNetwork.url}`);
|
|
182
198
|
}
|
|
183
199
|
// Only set response body if not already set by error handlers
|
|
184
200
|
if (!cloneNetwork.errorDomain) {
|
|
@@ -225,16 +241,20 @@ export default {
|
|
|
225
241
|
else {
|
|
226
242
|
delete cloneNetwork.gqlQueryName;
|
|
227
243
|
}
|
|
244
|
+
isReported = true;
|
|
245
|
+
Logger.debug(TAG, `[readystatechange] DONE for ${cloneNetwork.method} ${cloneNetwork.url} — status=${cloneNetwork.responseCode}, duration=${cloneNetwork.duration}ms, hasCallback=${!!onDoneCallback}`);
|
|
228
246
|
if (onDoneCallback) {
|
|
229
247
|
onDoneCallback(cloneNetwork);
|
|
230
248
|
}
|
|
249
|
+
else {
|
|
250
|
+
Logger.debug(TAG, `[readystatechange] WARNING: onDoneCallback is null, network log for ${cloneNetwork.url} will be LOST`);
|
|
251
|
+
}
|
|
231
252
|
}
|
|
232
253
|
});
|
|
233
254
|
const downloadUploadProgressCallback = (event) => {
|
|
234
255
|
if (!isInterceptorEnabled) {
|
|
235
256
|
return;
|
|
236
257
|
}
|
|
237
|
-
// check if will be able to compute progress
|
|
238
258
|
if (event.lengthComputable && onProgressCallback) {
|
|
239
259
|
const totalBytesSent = event.loaded;
|
|
240
260
|
const totalBytesExpectedToSend = event.total - event.loaded;
|
|
@@ -243,31 +263,43 @@ export default {
|
|
|
243
263
|
};
|
|
244
264
|
this.addEventListener('progress', downloadUploadProgressCallback);
|
|
245
265
|
this.upload.addEventListener('progress', downloadUploadProgressCallback);
|
|
246
|
-
// Handler for abort events (works with fetch, Axios, and any XHR-based requests)
|
|
247
266
|
this.addEventListener('abort', () => {
|
|
248
267
|
if (!isInterceptorEnabled) {
|
|
268
|
+
Logger.debug(TAG, `[abort] Interceptor disabled, ignoring abort for ${cloneNetwork.url}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (isReported) {
|
|
272
|
+
Logger.debug(TAG, `[abort] Already reported via readystatechange DONE, ignoring duplicate abort for ${cloneNetwork.url}`);
|
|
249
273
|
return;
|
|
250
274
|
}
|
|
275
|
+
isReported = true;
|
|
251
276
|
cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
|
|
252
277
|
cloneNetwork.responseCode = 0;
|
|
253
278
|
cloneNetwork.errorCode = clientErrorCode;
|
|
254
279
|
cloneNetwork.errorDomain = 'cancelled';
|
|
255
280
|
cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
|
|
281
|
+
Logger.debug(TAG, `[abort] Request cancelled: ${cloneNetwork.method} ${cloneNetwork.url}, duration=${cloneNetwork.duration}ms, hasCallback=${!!onDoneCallback}`);
|
|
282
|
+
if (onDoneCallback) {
|
|
283
|
+
onDoneCallback(cloneNetwork);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
Logger.debug(TAG, `[abort] WARNING: onDoneCallback is null, cancelled log for ${cloneNetwork.url} will be LOST`);
|
|
287
|
+
}
|
|
256
288
|
});
|
|
257
289
|
}
|
|
258
290
|
cloneNetwork.startTime = Date.now();
|
|
259
|
-
const traceparent =
|
|
291
|
+
const traceparent = getTraceparentHeader(cloneNetwork);
|
|
260
292
|
if (traceparent) {
|
|
261
293
|
this.setRequestHeader('Traceparent', traceparent);
|
|
262
|
-
|
|
263
|
-
if (this.readyState === this.UNSENT) {
|
|
264
|
-
return; // Prevent sending the request if not opened
|
|
294
|
+
Logger.debug(TAG, `[send] Injected traceparent header for ${cloneNetwork.url}`);
|
|
265
295
|
}
|
|
266
296
|
originalXHRSend.apply(this, [data]);
|
|
267
297
|
};
|
|
268
298
|
isInterceptorEnabled = true;
|
|
299
|
+
Logger.debug(TAG, 'XHR network interception enabled');
|
|
269
300
|
},
|
|
270
301
|
disableInterception() {
|
|
302
|
+
Logger.debug(TAG, 'Disabling XHR network interception');
|
|
271
303
|
isInterceptorEnabled = false;
|
|
272
304
|
XMLHttpRequest.prototype.send = originalXHRSend;
|
|
273
305
|
XMLHttpRequest.prototype.open = originalXHROpen;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
#import <LuciqSDK/LCQTypes.h>
|
|
9
9
|
#import <React/RCTUIManager.h>
|
|
10
10
|
#import "Util/LCQAPM+PrivateAPIs.h"
|
|
11
|
+
#import "LuciqScreenLoadingFrameTracker.h"
|
|
11
12
|
|
|
12
13
|
@implementation LuciqAPMBridge
|
|
13
14
|
|
|
@@ -145,7 +146,88 @@ RCT_EXPORT_METHOD(isAPMEnabled:(RCTPromiseResolveBlock)resolve
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// Screen Loading methods
|
|
150
|
+
RCT_EXPORT_METHOD(initScreenFrameTracking:(RCTPromiseResolveBlock)resolve
|
|
151
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
152
|
+
{
|
|
153
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
154
|
+
[[LuciqScreenLoadingFrameTracker sharedInstance] initializeFrameTracking];
|
|
155
|
+
resolve(nil);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
RCT_EXPORT_METHOD(setActiveScreenSpanId:(NSString *)spanId)
|
|
160
|
+
{
|
|
161
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
162
|
+
[[LuciqScreenLoadingFrameTracker sharedInstance] startTrackingForSpanId:spanId];
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
RCT_EXPORT_METHOD(getScreenTimeToDisplay:(NSString *)spanId
|
|
167
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
168
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
169
|
+
{
|
|
170
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
171
|
+
NSNumber *timestamp = [[LuciqScreenLoadingFrameTracker sharedInstance] getFrameTimestampForSpanId:spanId];
|
|
172
|
+
resolve(timestamp);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
RCT_EXPORT_METHOD(isScreenLoadingEnabled:(RCTPromiseResolveBlock)resolve
|
|
177
|
+
rejecter:(RCTPromiseRejectBlock)reject){
|
|
148
178
|
|
|
179
|
+
BOOL isScreenLoadingEnabled = LCQAPM.screenLoadingEnabled;
|
|
180
|
+
resolve(@(isScreenLoadingEnabled));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
RCT_EXPORT_METHOD(isEndScreenLoadingEnabled:(RCTPromiseResolveBlock)resolve
|
|
184
|
+
rejecter:(RCTPromiseRejectBlock)reject){
|
|
185
|
+
|
|
186
|
+
BOOL isEndScreenLoadingEnabled = LCQAPM.endScreenLoadingEnabled;
|
|
187
|
+
resolve(@(isEndScreenLoadingEnabled));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// uiTraceId is unused on iOS but required to keep the React Native Bridge call
|
|
191
|
+
// signature consistent with Android, which uses it.
|
|
192
|
+
RCT_EXPORT_METHOD(endScreenLoading:(double)timeStampMicro
|
|
193
|
+
uiTraceId:(double)uiTraceId){
|
|
194
|
+
[LCQAPM endScreenLoadingCPWithEndTimestampMUS:timeStampMicro];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
RCT_EXPORT_METHOD(setScreenLoadingEnabled:(BOOL)isEnabled){
|
|
198
|
+
LCQAPM.screenLoadingEnabled = isEnabled;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
- (NSMutableDictionary<NSString *, NSNumber *> *)buildStagesMapFromAttributes:(NSDictionary *)stages {
|
|
202
|
+
NSMutableDictionary<NSString *, NSNumber *> *stagesMap = [NSMutableDictionary dictionary];
|
|
203
|
+
NSArray<NSString *> *keys = @[@"cnst_mus_st" , @"cnst_mus",@"rnd_mus_st", @"rnd_mus", @"mnt_mus_st" ,@"mnt_mus", @"lyt_mus_st" , @"lyt_mus"];
|
|
204
|
+
for (NSString *key in keys) {
|
|
205
|
+
if (stages[key])
|
|
206
|
+
stagesMap[key] = @([stages[key] longLongValue]);
|
|
207
|
+
}
|
|
208
|
+
return stagesMap;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Syncs screen loading data to native layer for reporting
|
|
212
|
+
RCT_EXPORT_METHOD(syncScreenLoading:(double)spanId
|
|
213
|
+
screenName:(NSString *)screenName
|
|
214
|
+
startTimestamp:(double)startTimestamp
|
|
215
|
+
ttid_us:(double)ttid_us
|
|
216
|
+
attributes:(NSDictionary *)stages){
|
|
217
|
+
|
|
218
|
+
NSMutableDictionary<NSString *, NSNumber *> *stagesMap = [self buildStagesMapFromAttributes:stages];
|
|
219
|
+
[LCQAPM reportScreenLoadingCPWithStartTimestampMUS:startTimestamp durationMUS:ttid_us stages:stagesMap];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Syncs manual screen loading measurements to native layer for reporting (no span ID)
|
|
223
|
+
RCT_EXPORT_METHOD(syncManualScreenLoading:(NSString *)screenName
|
|
224
|
+
startTimestamp:(double)startTimestamp
|
|
225
|
+
ttid_mus:(double)ttid_mus
|
|
226
|
+
attributes:(NSDictionary *)stages){
|
|
227
|
+
|
|
228
|
+
NSMutableDictionary<NSString *, NSNumber *> *stagesMap = [self buildStagesMapFromAttributes:stages];
|
|
229
|
+
[LCQAPM reportScreenLoadingCPUITraceWithName:screenName screenLoadingStartMUS:startTimestamp screenLoadingDurationMUS:ttid_mus stages:stagesMap];
|
|
230
|
+
}
|
|
149
231
|
|
|
150
232
|
@synthesize description;
|
|
151
233
|
|
|
@@ -453,7 +453,7 @@ RCT_EXPORT_METHOD(show) {
|
|
|
453
453
|
[[NSRunLoop mainRunLoop] performSelector:@selector(show) target:[Luciq class] argument:nil order:0 modes:@[NSDefaultRunLoopMode]];
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
RCT_EXPORT_METHOD(reportScreenChange:(NSString *)screenName) {
|
|
456
|
+
RCT_EXPORT_METHOD(reportScreenChange:(NSString *)screenName spanId:(NSString * _Nullable)spanId) {
|
|
457
457
|
SEL setPrivateApiSEL = NSSelectorFromString(@"logViewDidAppearEvent:");
|
|
458
458
|
if ([[Luciq class] respondsToSelector:setPrivateApiSEL]) {
|
|
459
459
|
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:[[Luciq class] methodSignatureForSelector:setPrivateApiSEL]];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
@interface LuciqScreenLoadingFrameTracker : NSObject
|
|
4
|
+
|
|
5
|
+
+ (instancetype)sharedInstance;
|
|
6
|
+
- (void)startTrackingForSpanId:(NSString *)spanId;
|
|
7
|
+
- (NSNumber *)getFrameTimestampForSpanId:(NSString *)spanId;
|
|
8
|
+
- (void)initializeFrameTracking;
|
|
9
|
+
- (void)cleanup;
|
|
10
|
+
|
|
11
|
+
@end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#import "LuciqScreenLoadingFrameTracker.h"
|
|
2
|
+
#import <QuartzCore/CADisplayLink.h>
|
|
3
|
+
|
|
4
|
+
@interface LuciqScreenLoadingFrameTracker ()
|
|
5
|
+
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *spanIdToTimestamp;
|
|
6
|
+
@property (nonatomic, strong) NSMutableSet<NSString *> *activeSpanIds;
|
|
7
|
+
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *spanIdToTrackingStart;
|
|
8
|
+
@property (nonatomic, assign) NSInteger maxStorageCapacity;
|
|
9
|
+
@property (nonatomic, assign) BOOL isTracking;
|
|
10
|
+
@property (nonatomic, strong) CADisplayLink *displayLink;
|
|
11
|
+
@end
|
|
12
|
+
|
|
13
|
+
@implementation LuciqScreenLoadingFrameTracker
|
|
14
|
+
|
|
15
|
+
+ (instancetype)sharedInstance {
|
|
16
|
+
static LuciqScreenLoadingFrameTracker *instance = nil;
|
|
17
|
+
static dispatch_once_t onceToken;
|
|
18
|
+
dispatch_once(&onceToken, ^{
|
|
19
|
+
instance = [[self alloc] init];
|
|
20
|
+
});
|
|
21
|
+
return instance;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
- (instancetype)init {
|
|
25
|
+
if (self = [super init]) {
|
|
26
|
+
self.spanIdToTimestamp = [NSMutableDictionary dictionary];
|
|
27
|
+
self.activeSpanIds = [NSMutableSet set];
|
|
28
|
+
self.spanIdToTrackingStart = [NSMutableDictionary dictionary];
|
|
29
|
+
self.maxStorageCapacity = 50;
|
|
30
|
+
self.isTracking = NO;
|
|
31
|
+
}
|
|
32
|
+
return self;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
- (void)initializeFrameTracking {
|
|
36
|
+
if (self.isTracking) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
|
|
41
|
+
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
|
42
|
+
self.isTracking = YES;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
- (void)handleDisplayLink:(CADisplayLink *)displayLink {
|
|
46
|
+
[self frameRenderedWithTimestamp:displayLink.timestamp];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
- (void)frameRenderedWithTimestamp:(NSTimeInterval)timestamp {
|
|
50
|
+
if (self.activeSpanIds.count > 0) {
|
|
51
|
+
// timestamp is monotonic (seconds since boot, from CADisplayLink / mach_absolute_time)
|
|
52
|
+
// Convert to epoch microseconds using the same approach as Android:
|
|
53
|
+
// figure out how long ago the frame was rendered, then subtract from current epoch
|
|
54
|
+
NSTimeInterval currentUptime = [[NSProcessInfo processInfo] systemUptime];
|
|
55
|
+
NSTimeInterval currentEpoch = [[NSDate date] timeIntervalSince1970];
|
|
56
|
+
NSTimeInterval timeSinceFrame = currentUptime - timestamp;
|
|
57
|
+
NSTimeInterval frameEpochSeconds = currentEpoch - timeSinceFrame;
|
|
58
|
+
NSTimeInterval epochTimestampMicroseconds = frameEpochSeconds * 1000000;
|
|
59
|
+
NSNumber *timestampNumber = @(epochTimestampMicroseconds);
|
|
60
|
+
|
|
61
|
+
NSMutableSet<NSString *> *resolvedSpanIds = [NSMutableSet set];
|
|
62
|
+
for (NSString *spanId in self.activeSpanIds) {
|
|
63
|
+
NSNumber *trackingStart = self.spanIdToTrackingStart[spanId];
|
|
64
|
+
if (trackingStart && timestamp < trackingStart.doubleValue) {
|
|
65
|
+
NSLog(@"[ScreenLoading] Skipping frame for span %@ (VSync %.6fs < tracking start %.6fs)", spanId, timestamp, trackingStart.doubleValue);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
self.spanIdToTimestamp[spanId] = timestampNumber;
|
|
69
|
+
[resolvedSpanIds addObject:spanId];
|
|
70
|
+
[self.spanIdToTrackingStart removeObjectForKey:spanId];
|
|
71
|
+
NSLog(@"[ScreenLoading] Frame rendered for span %@ at %.0fμs", spanId, epochTimestampMicroseconds);
|
|
72
|
+
}
|
|
73
|
+
[self.activeSpanIds minusSet:resolvedSpanIds];
|
|
74
|
+
|
|
75
|
+
// Cleanup if exceeding capacity
|
|
76
|
+
if (self.spanIdToTimestamp.count > self.maxStorageCapacity) {
|
|
77
|
+
[self cleanupStorage];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
- (void)startTrackingForSpanId:(NSString *)spanId {
|
|
83
|
+
[self.activeSpanIds addObject:spanId];
|
|
84
|
+
self.spanIdToTrackingStart[spanId] = @([[NSProcessInfo processInfo] systemUptime]);
|
|
85
|
+
NSLog(@"[ScreenLoading] Started tracking for span %@", spanId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
- (NSNumber *)getFrameTimestampForSpanId:(NSString *)spanId {
|
|
89
|
+
NSNumber *timestamp = self.spanIdToTimestamp[spanId];
|
|
90
|
+
if (timestamp) {
|
|
91
|
+
[self.spanIdToTimestamp removeObjectForKey:spanId];
|
|
92
|
+
NSLog(@"[ScreenLoading] Retrieved timestamp %@μs for span %@", timestamp, spanId);
|
|
93
|
+
}
|
|
94
|
+
return timestamp;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
- (void)cleanup {
|
|
98
|
+
if (self.isTracking) {
|
|
99
|
+
[self.displayLink invalidate];
|
|
100
|
+
self.displayLink = nil;
|
|
101
|
+
self.isTracking = NO;
|
|
102
|
+
}
|
|
103
|
+
[self.spanIdToTimestamp removeAllObjects];
|
|
104
|
+
[self.activeSpanIds removeAllObjects];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
- (void)cleanupStorage {
|
|
108
|
+
NSArray *sortedKeys = [self.spanIdToTimestamp keysSortedByValueUsingComparator:^NSComparisonResult(NSNumber *obj1, NSNumber *obj2) {
|
|
109
|
+
return [obj1 compare:obj2];
|
|
110
|
+
}];
|
|
111
|
+
|
|
112
|
+
NSInteger itemsToRemove = self.spanIdToTimestamp.count - 30;
|
|
113
|
+
if (itemsToRemove > 0) {
|
|
114
|
+
for (NSInteger i = 0; i < itemsToRemove; i++) {
|
|
115
|
+
[self.spanIdToTimestamp removeObjectForKey:sortedKeys[i]];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@end
|
|
121
|
+
|
|
@@ -10,7 +10,21 @@
|
|
|
10
10
|
@interface LCQAPM (PrivateAPIs)
|
|
11
11
|
|
|
12
12
|
@property (class, atomic, assign) BOOL networkEnabled;
|
|
13
|
+
@property (class, atomic, readonly) BOOL endScreenLoadingEnabled;
|
|
14
|
+
|
|
15
|
+
typedef void (^LCQDisplayLinkObservationCallback)(NSTimeInterval currentTimestamp, NSTimeInterval targetTimestamp);
|
|
13
16
|
|
|
14
17
|
+ (BOOL)customSpansEnabled;
|
|
15
18
|
|
|
19
|
+
+ (void)endScreenLoadingCPWithEndTimestampMUS:(double)endTimestampMUS;
|
|
20
|
+
+ (void)reportScreenLoadingCPWithStartTimestampMUS:(double)startTimestampMUS
|
|
21
|
+
durationMUS:(double)durationMUS
|
|
22
|
+
stages:(nullable NSDictionary<NSString *, NSNumber *> *)stages;;
|
|
23
|
+
|
|
24
|
+
+ (void)startObservingDisplayLinkWithCallback:(LCQDisplayLinkObservationCallback _Nonnull)callback;
|
|
25
|
+
+ (void)stopObservingDisplayLink;
|
|
26
|
+
+ (void)reportScreenLoadingCPUITraceWithName:(NSString *_Nonnull)name
|
|
27
|
+
screenLoadingStartMUS:(double)screenLoadingStartMUS
|
|
28
|
+
screenLoadingDurationMUS:(double)screenLoadingDurationMUS
|
|
29
|
+
stages:(nullable NSDictionary<NSString *, NSNumber *> *)stages;
|
|
16
30
|
@end
|
package/ios/native.rb
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luciq/react-native",
|
|
3
3
|
"description": "Luciq is the Agentic Observability Platform built for Mobile.",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.6.0-51917-SNAPSHOT",
|
|
5
5
|
"author": "Luciq (https://luciq.ai)",
|
|
6
6
|
"repository": "github:luciqai/luciq-reactnative-sdk",
|
|
7
7
|
"homepage": "https://www.luciq.ai/platforms/react-native",
|
|
@@ -43,18 +43,19 @@
|
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@apollo/client": "^3.7.0",
|
|
46
|
-
"@instabug/danger-plugin-coverage": "Instabug/danger-plugin-coverage",
|
|
47
46
|
"@react-native-community/eslint-config": "^3.1.0",
|
|
48
47
|
"@react-navigation/native": "^6.1.7",
|
|
49
48
|
"@rollup/plugin-commonjs": "^25.0.3",
|
|
50
49
|
"@rollup/plugin-json": "^6.0.0",
|
|
51
50
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
|
52
51
|
"@rollup/plugin-typescript": "^11.0.0",
|
|
52
|
+
"@testing-library/react-native": "^13.3.3",
|
|
53
53
|
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
|
54
54
|
"@types/jest": "^29.5.3",
|
|
55
55
|
"@types/minimatch": "3.0.4",
|
|
56
56
|
"@types/node": "^20.4.8",
|
|
57
57
|
"@types/react-native": "^0.72.2",
|
|
58
|
+
"@types/react-test-renderer": "^19.1.0",
|
|
58
59
|
"axios": "1.11.0",
|
|
59
60
|
"babel-core": "7.0.0-bridge.0",
|
|
60
61
|
"babel-jest": "^29.6.2",
|
|
@@ -75,6 +76,7 @@
|
|
|
75
76
|
"react-native": "^0.72.3",
|
|
76
77
|
"react-native-navigation": "7.36.0",
|
|
77
78
|
"react-navigation": "^4.4.4",
|
|
79
|
+
"react-test-renderer": "18.3.1",
|
|
78
80
|
"rollup": "^3.27.2",
|
|
79
81
|
"rollup-plugin-cleanup": "^3.2.1",
|
|
80
82
|
"rollup-plugin-copy": "^3.5.0",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Generates a GitHub App installation token using openssl + curl.
|
|
4
|
+
# No external dependencies required.
|
|
5
|
+
#
|
|
6
|
+
# Usage: bash get-github-app-token.sh <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>
|
|
7
|
+
# Example: bash get-github-app-token.sh AND_LUCIQ_APP_ID AND_LUCIQ_PRIVATE_KEY AND_LUCIQ_INSTALLATION_ID
|
|
8
|
+
# Example: bash get-github-app-token.sh AND_INSTABUG_APP_ID AND_INSTABUG_PRIVATE_KEY AND_INSTABUG_INSTALLATION_ID
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
APP_ID_ENV="${1:?Usage: $0 <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>}"
|
|
13
|
+
PRIVATE_KEY_ENV="${2:?Usage: $0 <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>}"
|
|
14
|
+
INSTALL_ID_ENV="${3:?Usage: $0 <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>}"
|
|
15
|
+
|
|
16
|
+
APP_ID="${!APP_ID_ENV:?Error: $APP_ID_ENV is not set}"
|
|
17
|
+
PRIVATE_KEY="${!PRIVATE_KEY_ENV:?Error: $PRIVATE_KEY_ENV is not set}"
|
|
18
|
+
INSTALL_ID="${!INSTALL_ID_ENV:?Error: $INSTALL_ID_ENV is not set}"
|
|
19
|
+
|
|
20
|
+
# Reconstruct PEM file from flattened env var
|
|
21
|
+
# CircleCI flattens multiline env vars into a single line,
|
|
22
|
+
# so we extract header/footer and re-wrap the base64 body at 64 chars
|
|
23
|
+
PEM_FILE=$(mktemp)
|
|
24
|
+
chmod 600 "$PEM_FILE"
|
|
25
|
+
trap 'rm -f "$PEM_FILE"' EXIT
|
|
26
|
+
|
|
27
|
+
BODY=$(printf '%s' "$PRIVATE_KEY" | sed 's/-----BEGIN RSA PRIVATE KEY-----//;s/-----END RSA PRIVATE KEY-----//;s/ //g')
|
|
28
|
+
{
|
|
29
|
+
echo "-----BEGIN RSA PRIVATE KEY-----"
|
|
30
|
+
echo "$BODY" | fold -w 64
|
|
31
|
+
echo "-----END RSA PRIVATE KEY-----"
|
|
32
|
+
} > "$PEM_FILE"
|
|
33
|
+
|
|
34
|
+
# Base64url encode (RFC 4648): replace +/ with -_, strip =
|
|
35
|
+
b64url() {
|
|
36
|
+
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
NOW=$(date +%s)
|
|
40
|
+
IAT=$((NOW - 60)) # 60s clock skew allowance per GitHub docs
|
|
41
|
+
EXP=$((NOW + 600)) # 10min max JWT lifetime per GitHub docs
|
|
42
|
+
|
|
43
|
+
# Create JWT header and payload
|
|
44
|
+
HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
|
|
45
|
+
PAYLOAD=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$IAT" "$EXP" "$APP_ID" | b64url)
|
|
46
|
+
|
|
47
|
+
# Sign with RSA-SHA256
|
|
48
|
+
SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -sign "$PEM_FILE" -binary | b64url)
|
|
49
|
+
|
|
50
|
+
JWT_TOKEN="${HEADER}.${PAYLOAD}.${SIGNATURE}"
|
|
51
|
+
|
|
52
|
+
# Exchange JWT for installation token
|
|
53
|
+
RESPONSE=$(curl -sf -X POST \
|
|
54
|
+
-H "Authorization: Bearer $JWT_TOKEN" \
|
|
55
|
+
-H "Accept: application/vnd.github+json" \
|
|
56
|
+
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
57
|
+
"https://api.github.com/app/installations/${INSTALL_ID}/access_tokens") || {
|
|
58
|
+
echo "Error: GitHub API request failed (HTTP error)" >&2
|
|
59
|
+
exit 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
TOKEN=$(echo "$RESPONSE" | jq -r '.token // empty')
|
|
63
|
+
|
|
64
|
+
if [ -z "$TOKEN" ]; then
|
|
65
|
+
ERROR_MSG=$(echo "$RESPONSE" | jq -r '.message // "unknown error"')
|
|
66
|
+
echo "Error: Failed to get installation token: $ERROR_MSG" >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
echo "$TOKEN"
|
package/scripts/notify-github.sh
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
3
5
|
pr_url="https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pulls?head=$CIRCLE_PROJECT_USERNAME:$CIRCLE_BRANCH&state=open"
|
|
4
6
|
pr_response=$(curl --location --request GET "$pr_url" --header "Authorization: Bearer $RELEASE_GITHUB_TOKEN")
|
|
5
7
|
|
|
6
|
-
if
|
|
7
|
-
echo "
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
if ! echo "$pr_response" | jq -e 'type == "array"' >/dev/null; then
|
|
9
|
+
echo "Unexpected GitHub API response (not an array):"
|
|
10
|
+
echo "$pr_response"
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
--data-raw "$1"
|
|
14
|
+
if [ "$(echo "$pr_response" | jq length)" -eq 0 ]; then
|
|
15
|
+
echo "No PR found to update"
|
|
16
|
+
exit 0
|
|
15
17
|
fi
|
|
18
|
+
|
|
19
|
+
pr_comment_url=$(echo "$pr_response" | jq -r ".[]._links.comments.href")
|
|
20
|
+
|
|
21
|
+
curl --location --request POST "$pr_comment_url" \
|
|
22
|
+
--header 'Content-Type: application/json' \
|
|
23
|
+
--header "Authorization: Bearer $RELEASE_GITHUB_TOKEN" \
|
|
24
|
+
--data-raw "$1"
|