@rozenite/network-activity-plugin 1.10.0 → 1.12.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.
- package/CHANGELOG.md +28 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-DsimzJvx.js → App-2rukIHdY.js} +1013 -264
- package/dist/devtools/assets/{App-CUXU0mup.css → App-xppYUJvX.css} +94 -0
- package/dist/rozenite.json +1 -1
- package/package.json +6 -6
- package/src/ui/components/FilterBar.tsx +13 -45
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +6 -185
- package/src/ui/components/Toolbar.tsx +13 -1
- package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
- package/src/ui/state/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +2 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +1 -0
- package/src/ui/state/store.ts +24 -2
- package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +73 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +7 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/symbolication.ts +37 -10
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +40 -8
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ProcessedRequest } from '../../state/model';
|
|
3
|
+
import {
|
|
4
|
+
formatTimelineOffset,
|
|
5
|
+
getTimelineModel,
|
|
6
|
+
getTimelineRequestEndTime,
|
|
7
|
+
getTimelineTicks,
|
|
8
|
+
isRequestActive,
|
|
9
|
+
requestOverlapsTimelineRange,
|
|
10
|
+
TIMELINE_LAYOUT,
|
|
11
|
+
} from '../timelineModel';
|
|
12
|
+
|
|
13
|
+
const createRequest = (
|
|
14
|
+
overrides: Partial<ProcessedRequest> = {},
|
|
15
|
+
): ProcessedRequest => ({
|
|
16
|
+
id: 'request-1',
|
|
17
|
+
type: 'http',
|
|
18
|
+
name: 'https://example.com/api',
|
|
19
|
+
status: 'finished',
|
|
20
|
+
timestamp: 0,
|
|
21
|
+
duration: 100,
|
|
22
|
+
size: null,
|
|
23
|
+
method: 'GET',
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('timelineModel', () => {
|
|
28
|
+
it('formats minute offsets without rolling seconds up to 60', () => {
|
|
29
|
+
expect(formatTimelineOffset(119_900)).toBe('2m 00s');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats websocket closing state as active', () => {
|
|
33
|
+
expect(
|
|
34
|
+
isRequestActive(
|
|
35
|
+
createRequest({
|
|
36
|
+
type: 'websocket',
|
|
37
|
+
status: 'closing',
|
|
38
|
+
method: 'WS',
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('uses the earliest ending lane when all lanes are occupied', () => {
|
|
45
|
+
const requests = Array.from(
|
|
46
|
+
{ length: TIMELINE_LAYOUT.laneCount },
|
|
47
|
+
(_, index) =>
|
|
48
|
+
createRequest({
|
|
49
|
+
id: `request-${index}`,
|
|
50
|
+
timestamp: 0,
|
|
51
|
+
duration: index === 1 ? 100 : 1000,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const overflowingRequest = createRequest({
|
|
56
|
+
id: 'overflowing-request',
|
|
57
|
+
timestamp: 50,
|
|
58
|
+
duration: 200,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const model = getTimelineModel([...requests, overflowingRequest], 0);
|
|
62
|
+
const overflowingRow = model.rows.find(
|
|
63
|
+
(row) => row.request.id === overflowingRequest.id,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(overflowingRow?.lane).toBe(1);
|
|
67
|
+
expect(overflowingRow?.isOverflowingLane).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps long-session tick counts near the target count', () => {
|
|
71
|
+
const oneHour = 60 * 60 * 1000;
|
|
72
|
+
const model = getTimelineModel(
|
|
73
|
+
[
|
|
74
|
+
createRequest({
|
|
75
|
+
duration: oneHour,
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
0,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(model.ticks.length).toBeLessThanOrEqual(
|
|
82
|
+
TIMELINE_LAYOUT.tickTargetCount + 2,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not add a duplicate final tick label', () => {
|
|
87
|
+
const ticks = getTimelineTicks(1548, {
|
|
88
|
+
...TIMELINE_LAYOUT,
|
|
89
|
+
tickTargetCount: 7,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(ticks.map((tick) => tick.label)).toEqual([
|
|
93
|
+
'0 ms',
|
|
94
|
+
'250 ms',
|
|
95
|
+
'500 ms',
|
|
96
|
+
'750 ms',
|
|
97
|
+
'1.0 s',
|
|
98
|
+
'1.3 s',
|
|
99
|
+
'1.5 s',
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('caps rendered rows for large recordings', () => {
|
|
104
|
+
const maxRenderedRequests = 3;
|
|
105
|
+
const model = getTimelineModel(
|
|
106
|
+
Array.from({ length: 5 }, (_, index) =>
|
|
107
|
+
createRequest({
|
|
108
|
+
id: `request-${index}`,
|
|
109
|
+
timestamp: index,
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
0,
|
|
113
|
+
{
|
|
114
|
+
...TIMELINE_LAYOUT,
|
|
115
|
+
maxRenderedRequests,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(model.rows.map((row) => row.request.id)).toEqual([
|
|
120
|
+
'request-2',
|
|
121
|
+
'request-3',
|
|
122
|
+
'request-4',
|
|
123
|
+
]);
|
|
124
|
+
expect(model.totalRequestCount).toBe(5);
|
|
125
|
+
expect(model.hiddenRequestCount).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('caps websocket and SSE duration in the timeline model', () => {
|
|
129
|
+
const now = 60_000;
|
|
130
|
+
const websocketRequest = createRequest({
|
|
131
|
+
type: 'websocket',
|
|
132
|
+
status: 'open',
|
|
133
|
+
method: 'WS',
|
|
134
|
+
timestamp: 0,
|
|
135
|
+
duration: undefined,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(getTimelineRequestEndTime(websocketRequest, now)).toBe(
|
|
139
|
+
TIMELINE_LAYOUT.streamingRequestMaxDurationMs,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const model = getTimelineModel([websocketRequest], now);
|
|
143
|
+
|
|
144
|
+
expect(model.rows[0].duration).toBe(
|
|
145
|
+
TIMELINE_LAYOUT.streamingRequestMaxDurationMs,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('matches requests that overlap a selected timeline range', () => {
|
|
150
|
+
const request = createRequest({
|
|
151
|
+
timestamp: 1000,
|
|
152
|
+
duration: 400,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
requestOverlapsTimelineRange(
|
|
157
|
+
request,
|
|
158
|
+
{ startTime: 1200, endTime: 1400 },
|
|
159
|
+
0,
|
|
160
|
+
),
|
|
161
|
+
).toBe(true);
|
|
162
|
+
expect(
|
|
163
|
+
requestOverlapsTimelineRange(
|
|
164
|
+
request,
|
|
165
|
+
{ startTime: 1500, endTime: 1600 },
|
|
166
|
+
0,
|
|
167
|
+
),
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/ui/utils/download.ts
CHANGED
|
@@ -152,3 +152,10 @@ export const downloadBlob = (blob: Blob, filename: string): void => {
|
|
|
152
152
|
// the request when the URL disappears mid-click.
|
|
153
153
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
154
154
|
};
|
|
155
|
+
|
|
156
|
+
export const downloadJson = (data: unknown, filename: string): void => {
|
|
157
|
+
downloadBlob(
|
|
158
|
+
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
|
|
159
|
+
filename,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { FilterState } from '../state/filter';
|
|
2
|
+
import type { ProcessedRequest } from '../state/model';
|
|
3
|
+
import type { HttpMethod } from '../../shared/client';
|
|
4
|
+
|
|
5
|
+
export type RequestFilterOptions = {
|
|
6
|
+
hasOverride?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const parseThreshold = (value: string): number | null => {
|
|
10
|
+
const normalizedValue = value.trim();
|
|
11
|
+
if (!normalizedValue) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parsedValue = Number(normalizedValue);
|
|
16
|
+
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const matchesStatusFilter = (
|
|
20
|
+
statusCode: number | undefined,
|
|
21
|
+
statusFilter: string,
|
|
22
|
+
) => {
|
|
23
|
+
const normalizedFilter = statusFilter.trim().toLowerCase();
|
|
24
|
+
if (!normalizedFilter) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (statusCode === undefined) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const statusRangeMatch = normalizedFilter.match(/^(\d{3})\s*-\s*(\d{3})$/);
|
|
33
|
+
if (statusRangeMatch) {
|
|
34
|
+
const min = Number(statusRangeMatch[1]);
|
|
35
|
+
const max = Number(statusRangeMatch[2]);
|
|
36
|
+
return statusCode >= min && statusCode <= max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const statusClassMatch = normalizedFilter.match(/^([1-5])xx$/);
|
|
40
|
+
if (statusClassMatch) {
|
|
41
|
+
return Math.floor(statusCode / 100) === Number(statusClassMatch[1]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const comparisonMatch = normalizedFilter.match(/^(>=|<=|>|<)\s*(\d{3})$/);
|
|
45
|
+
if (comparisonMatch) {
|
|
46
|
+
const value = Number(comparisonMatch[2]);
|
|
47
|
+
switch (comparisonMatch[1]) {
|
|
48
|
+
case '>=':
|
|
49
|
+
return statusCode >= value;
|
|
50
|
+
case '<=':
|
|
51
|
+
return statusCode <= value;
|
|
52
|
+
case '>':
|
|
53
|
+
return statusCode > value;
|
|
54
|
+
case '<':
|
|
55
|
+
return statusCode < value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return statusCode === Number(normalizedFilter);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const isInFlightStatus = (status: ProcessedRequest['status']) => {
|
|
63
|
+
return ['pending', 'loading', 'connecting', 'open'].includes(status);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isFailedStatus = (status: ProcessedRequest['status']) => {
|
|
67
|
+
return ['failed', 'error'].includes(status);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isHttpMethod = (
|
|
71
|
+
method: ProcessedRequest['method'],
|
|
72
|
+
): method is HttpMethod => method !== 'WS' && method !== 'SSE';
|
|
73
|
+
|
|
74
|
+
const extractDomainAndPath = (url: string) => {
|
|
75
|
+
try {
|
|
76
|
+
const { hostname, pathname, search, hash, port } = new URL(url);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
domain: `${hostname}${port ? `:${port}` : ''}`,
|
|
80
|
+
path: `${pathname}${search}${hash}`,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return { domain: 'unknown', path: url };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const matchesRequestFilter = (
|
|
88
|
+
request: ProcessedRequest,
|
|
89
|
+
filter: FilterState,
|
|
90
|
+
options: RequestFilterOptions = {},
|
|
91
|
+
) => {
|
|
92
|
+
if (filter.types.size > 0 && !filter.types.has(request.type)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
filter.advanced.methods.size > 0 &&
|
|
98
|
+
(!isHttpMethod(request.method) ||
|
|
99
|
+
!filter.advanced.methods.has(request.method))
|
|
100
|
+
) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
filter.advanced.sources.size > 0 &&
|
|
106
|
+
(!request.source || !filter.advanced.sources.has(request.source))
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!matchesStatusFilter(request.httpStatus, filter.advanced.status)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { domain, path } = extractDomainAndPath(request.name);
|
|
116
|
+
const domainFilter = filter.advanced.domain.trim().toLowerCase();
|
|
117
|
+
if (domainFilter && !domain.toLowerCase().includes(domainFilter)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const contentTypeFilter = filter.advanced.contentType.trim().toLowerCase();
|
|
122
|
+
if (
|
|
123
|
+
contentTypeFilter &&
|
|
124
|
+
!request.contentType?.toLowerCase().includes(contentTypeFilter)
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (filter.advanced.failedOnly && !isFailedStatus(request.status)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (filter.advanced.inFlightOnly && !isInFlightStatus(request.status)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (filter.advanced.overriddenOnly && !options.hasOverride) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const minSize = parseThreshold(filter.advanced.minSize);
|
|
142
|
+
if (minSize !== null && (request.size === null || request.size < minSize)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const maxSize = parseThreshold(filter.advanced.maxSize);
|
|
147
|
+
if (maxSize !== null && (request.size === null || request.size > maxSize)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const duration = request.duration || 0;
|
|
152
|
+
const minDuration = parseThreshold(filter.advanced.minDuration);
|
|
153
|
+
if (minDuration !== null && duration < minDuration) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const maxDuration = parseThreshold(filter.advanced.maxDuration);
|
|
158
|
+
if (maxDuration !== null && duration > maxDuration) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const searchText = filter.text.trim().toLowerCase();
|
|
163
|
+
if (!searchText) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const searchableFields = [
|
|
168
|
+
request.name,
|
|
169
|
+
request.method,
|
|
170
|
+
request.status,
|
|
171
|
+
request.httpStatus,
|
|
172
|
+
request.source,
|
|
173
|
+
request.type,
|
|
174
|
+
request.contentType,
|
|
175
|
+
domain,
|
|
176
|
+
path,
|
|
177
|
+
]
|
|
178
|
+
.filter((value) => value !== undefined && value !== null)
|
|
179
|
+
.join(' ')
|
|
180
|
+
.toLowerCase();
|
|
181
|
+
|
|
182
|
+
return searchableFields.includes(searchText);
|
|
183
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HttpNetworkEntry,
|
|
3
|
+
NetworkEntry,
|
|
4
|
+
RequestId,
|
|
5
|
+
SSENetworkEntry,
|
|
6
|
+
WebSocketMessage,
|
|
7
|
+
WebSocketNetworkEntry,
|
|
8
|
+
} from '../state/model';
|
|
9
|
+
|
|
10
|
+
const EXPORT_SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
type ExportedHttpEntry = {
|
|
13
|
+
id: RequestId;
|
|
14
|
+
type: 'http';
|
|
15
|
+
source?: NetworkEntry['source'];
|
|
16
|
+
timestamp: number;
|
|
17
|
+
duration: number | null;
|
|
18
|
+
status: HttpNetworkEntry['status'];
|
|
19
|
+
error?: string;
|
|
20
|
+
canceled?: boolean;
|
|
21
|
+
request: HttpNetworkEntry['request'];
|
|
22
|
+
response: HttpNetworkEntry['response'] | null;
|
|
23
|
+
size: number | null;
|
|
24
|
+
ttfb: number | null;
|
|
25
|
+
initiator?: HttpNetworkEntry['initiator'];
|
|
26
|
+
resourceType?: HttpNetworkEntry['resourceType'];
|
|
27
|
+
progress?: HttpNetworkEntry['progress'];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ExportedWebSocketEntry = {
|
|
31
|
+
id: RequestId;
|
|
32
|
+
type: 'websocket';
|
|
33
|
+
source?: NetworkEntry['source'];
|
|
34
|
+
timestamp: number;
|
|
35
|
+
duration: number | null;
|
|
36
|
+
status: WebSocketNetworkEntry['status'];
|
|
37
|
+
connection: WebSocketNetworkEntry['connection'];
|
|
38
|
+
error?: string;
|
|
39
|
+
closeCode?: number;
|
|
40
|
+
closeReason?: string;
|
|
41
|
+
messages: WebSocketMessage[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ExportedSSEEntry = {
|
|
45
|
+
id: RequestId;
|
|
46
|
+
type: 'sse';
|
|
47
|
+
source?: NetworkEntry['source'];
|
|
48
|
+
timestamp: number;
|
|
49
|
+
duration: number | null;
|
|
50
|
+
status: SSENetworkEntry['status'];
|
|
51
|
+
error?: string;
|
|
52
|
+
request: SSENetworkEntry['request'];
|
|
53
|
+
response: SSENetworkEntry['response'] | null;
|
|
54
|
+
initiator?: SSENetworkEntry['initiator'];
|
|
55
|
+
resourceType?: SSENetworkEntry['resourceType'];
|
|
56
|
+
messages: SSENetworkEntry['messages'];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ExportedNetworkEntry =
|
|
60
|
+
| ExportedHttpEntry
|
|
61
|
+
| ExportedWebSocketEntry
|
|
62
|
+
| ExportedSSEEntry;
|
|
63
|
+
|
|
64
|
+
export type NetworkActivitySessionExport = {
|
|
65
|
+
schemaVersion: typeof EXPORT_SCHEMA_VERSION;
|
|
66
|
+
tool: 'rozenite-network-activity';
|
|
67
|
+
exportedAt: string;
|
|
68
|
+
summary: {
|
|
69
|
+
totalEntries: number;
|
|
70
|
+
httpRequests: number;
|
|
71
|
+
webSocketConnections: number;
|
|
72
|
+
sseConnections: number;
|
|
73
|
+
realtimeMessages: number;
|
|
74
|
+
};
|
|
75
|
+
entries: ExportedNetworkEntry[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getDuration = (duration: number | undefined) => duration ?? null;
|
|
79
|
+
|
|
80
|
+
const serializeHttpEntry = (entry: HttpNetworkEntry): ExportedHttpEntry => ({
|
|
81
|
+
id: entry.id,
|
|
82
|
+
type: 'http',
|
|
83
|
+
source: entry.source,
|
|
84
|
+
timestamp: entry.timestamp,
|
|
85
|
+
duration: getDuration(entry.duration),
|
|
86
|
+
status: entry.status,
|
|
87
|
+
error: entry.error,
|
|
88
|
+
canceled: entry.canceled,
|
|
89
|
+
request: entry.request,
|
|
90
|
+
response: entry.response ?? null,
|
|
91
|
+
size: entry.size ?? null,
|
|
92
|
+
ttfb: entry.ttfb ?? null,
|
|
93
|
+
initiator: entry.initiator,
|
|
94
|
+
resourceType: entry.resourceType,
|
|
95
|
+
progress: entry.progress,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const serializeWebSocketEntry = (
|
|
99
|
+
entry: WebSocketNetworkEntry,
|
|
100
|
+
websocketMessages: Map<RequestId, WebSocketMessage[]>,
|
|
101
|
+
): ExportedWebSocketEntry => ({
|
|
102
|
+
id: entry.id,
|
|
103
|
+
type: 'websocket',
|
|
104
|
+
source: entry.source,
|
|
105
|
+
timestamp: entry.timestamp,
|
|
106
|
+
duration: getDuration(entry.duration),
|
|
107
|
+
status: entry.status,
|
|
108
|
+
connection: entry.connection,
|
|
109
|
+
error: entry.error,
|
|
110
|
+
closeCode: entry.closeCode,
|
|
111
|
+
closeReason: entry.closeReason,
|
|
112
|
+
messages: websocketMessages.get(entry.id) ?? [],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const serializeSSEEntry = (entry: SSENetworkEntry): ExportedSSEEntry => ({
|
|
116
|
+
id: entry.id,
|
|
117
|
+
type: 'sse',
|
|
118
|
+
source: entry.source,
|
|
119
|
+
timestamp: entry.timestamp,
|
|
120
|
+
duration: getDuration(entry.duration),
|
|
121
|
+
status: entry.status,
|
|
122
|
+
error: entry.error,
|
|
123
|
+
request: entry.request,
|
|
124
|
+
response: entry.response ?? null,
|
|
125
|
+
initiator: entry.initiator,
|
|
126
|
+
resourceType: entry.resourceType,
|
|
127
|
+
messages: entry.messages,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const serializeEntry = (
|
|
131
|
+
entry: NetworkEntry,
|
|
132
|
+
websocketMessages: Map<RequestId, WebSocketMessage[]>,
|
|
133
|
+
): ExportedNetworkEntry => {
|
|
134
|
+
switch (entry.type) {
|
|
135
|
+
case 'http':
|
|
136
|
+
return serializeHttpEntry(entry);
|
|
137
|
+
case 'websocket':
|
|
138
|
+
return serializeWebSocketEntry(entry, websocketMessages);
|
|
139
|
+
case 'sse':
|
|
140
|
+
return serializeSSEEntry(entry);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const createNetworkActivitySessionExport = (
|
|
145
|
+
networkEntries: Map<RequestId, NetworkEntry>,
|
|
146
|
+
websocketMessages: Map<RequestId, WebSocketMessage[]>,
|
|
147
|
+
exportedAt = new Date(),
|
|
148
|
+
): NetworkActivitySessionExport => {
|
|
149
|
+
const entries = Array.from(networkEntries.values())
|
|
150
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
151
|
+
.map((entry) => serializeEntry(entry, websocketMessages));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
schemaVersion: EXPORT_SCHEMA_VERSION,
|
|
155
|
+
tool: 'rozenite-network-activity',
|
|
156
|
+
exportedAt: exportedAt.toISOString(),
|
|
157
|
+
summary: {
|
|
158
|
+
totalEntries: entries.length,
|
|
159
|
+
httpRequests: entries.filter((entry) => entry.type === 'http').length,
|
|
160
|
+
webSocketConnections: entries.filter(
|
|
161
|
+
(entry) => entry.type === 'websocket',
|
|
162
|
+
).length,
|
|
163
|
+
sseConnections: entries.filter((entry) => entry.type === 'sse').length,
|
|
164
|
+
realtimeMessages: entries.reduce((count, entry) => {
|
|
165
|
+
if (entry.type === 'websocket' || entry.type === 'sse') {
|
|
166
|
+
return count + entry.messages.length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return count;
|
|
170
|
+
}, 0),
|
|
171
|
+
},
|
|
172
|
+
entries,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const getNetworkActivitySessionExportFileName = (
|
|
177
|
+
exportedAt = new Date(),
|
|
178
|
+
) => {
|
|
179
|
+
const timestamp = exportedAt
|
|
180
|
+
.toISOString()
|
|
181
|
+
.replace(/\.\d{3}Z$/, 'Z')
|
|
182
|
+
.replace(/[:]/g, '-');
|
|
183
|
+
|
|
184
|
+
return `rozenite-network-session-${timestamp}.json`;
|
|
185
|
+
};
|
|
@@ -37,9 +37,12 @@ const getGeneratedFrameLocation = (frame: InitiatorStackFrame) => ({
|
|
|
37
37
|
const isGeneratedBundleUrl = (url: string) =>
|
|
38
38
|
/[^/]+\.bundle(?:[/?#]|$)/.test(url);
|
|
39
39
|
|
|
40
|
+
const isMetroSymbolicatableUrl = (url?: string) =>
|
|
41
|
+
url?.startsWith('http') ?? false;
|
|
42
|
+
|
|
40
43
|
const canSymbolicateStack = (stack?: InitiatorStackFrame[]) =>
|
|
41
44
|
stack?.some((frame) =>
|
|
42
|
-
getGeneratedFrameLocation(frame).url
|
|
45
|
+
isMetroSymbolicatableUrl(getGeneratedFrameLocation(frame).url),
|
|
43
46
|
) ?? false;
|
|
44
47
|
|
|
45
48
|
const toReactNativeStackFrame = (
|
|
@@ -47,7 +50,7 @@ const toReactNativeStackFrame = (
|
|
|
47
50
|
): ReactNativeStackFrame | null => {
|
|
48
51
|
const generatedLocation = getGeneratedFrameLocation(frame);
|
|
49
52
|
|
|
50
|
-
if (!generatedLocation.url) {
|
|
53
|
+
if (!isMetroSymbolicatableUrl(generatedLocation.url)) {
|
|
51
54
|
return null;
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -195,21 +198,45 @@ export const symbolicateInitiator = async (
|
|
|
195
198
|
return null;
|
|
196
199
|
}
|
|
197
200
|
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
const originalStack = initiator.stack ?? [];
|
|
202
|
+
const generatedStackFrames = originalStack.flatMap(
|
|
203
|
+
(originalFrame, originalIndex) => {
|
|
204
|
+
const frame = toReactNativeStackFrame(originalFrame);
|
|
205
|
+
return frame ? [{ frame, originalIndex }] : [];
|
|
206
|
+
},
|
|
207
|
+
);
|
|
202
208
|
|
|
203
209
|
if (generatedStackFrames.length === 0) {
|
|
204
210
|
return null;
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
try {
|
|
208
|
-
const symbolicatedStackTrace =
|
|
209
|
-
|
|
214
|
+
const symbolicatedStackTrace = await symbolicateStackTrace(
|
|
215
|
+
generatedStackFrames.map((entry) => entry.frame),
|
|
216
|
+
);
|
|
210
217
|
|
|
211
|
-
const
|
|
212
|
-
|
|
218
|
+
const symbolicatedFramesByOriginalIndex = new Map<
|
|
219
|
+
number,
|
|
220
|
+
InitiatorStackFrame
|
|
221
|
+
>();
|
|
222
|
+
|
|
223
|
+
symbolicatedStackTrace.stack.forEach((frame, index) => {
|
|
224
|
+
const generatedFrame = generatedStackFrames[index];
|
|
225
|
+
if (!generatedFrame) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
symbolicatedFramesByOriginalIndex.set(
|
|
230
|
+
generatedFrame.originalIndex,
|
|
231
|
+
fromSymbolicatedStackFrame(
|
|
232
|
+
frame,
|
|
233
|
+
originalStack[generatedFrame.originalIndex],
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const symbolicatedStack = originalStack.map(
|
|
239
|
+
(frame, index) => symbolicatedFramesByOriginalIndex.get(index) ?? frame,
|
|
213
240
|
);
|
|
214
241
|
const sourceFrame = getPreferredSourceFrame(
|
|
215
242
|
symbolicatedStack,
|