@signalr-devtools/analysis 0.1.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/LICENSE +18 -0
- package/README.md +89 -0
- package/msgpackDecoder.js +371 -0
- package/package.json +45 -0
- package/sessionFormat.js +254 -0
- package/signalrAnalysis.js +798 -0
- package/signalrProtocol.js +324 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
(function exposeSignalRAnalysis(root) {
|
|
4
|
+
const INVOCATION_TYPES = new Set([1, 4]);
|
|
5
|
+
const DEFAULT_MAX_RECEIVE_MESSAGE_SIZE = 32 * 1024;
|
|
6
|
+
const LARGE_PAYLOAD_WARNING_SIZE = Math.floor(DEFAULT_MAX_RECEIVE_MESSAGE_SIZE * 0.8);
|
|
7
|
+
const PENDING_INVOCATION_GRACE_MS = 30_000;
|
|
8
|
+
const DEFAULT_KEEP_ALIVE_GAP_WARNING_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
function oppositeDirection(direction) {
|
|
11
|
+
return direction === 'incoming' ? 'outgoing' : 'incoming';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatDuration(durationMs) {
|
|
15
|
+
if (!Number.isFinite(durationMs)) {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
const normalizedDuration = Math.max(0, durationMs);
|
|
19
|
+
if (normalizedDuration < 1_000) {
|
|
20
|
+
return `${Math.round(normalizedDuration)} ms`;
|
|
21
|
+
}
|
|
22
|
+
return `${(normalizedDuration / 1_000).toFixed(2)} s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function messageInfoFor(map, messageId) {
|
|
26
|
+
if (!map.has(messageId)) {
|
|
27
|
+
map.set(messageId, {
|
|
28
|
+
flowLabels: [],
|
|
29
|
+
relatedMessageIds: [],
|
|
30
|
+
streamChildren: [],
|
|
31
|
+
streamParentId: null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return map.get(messageId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function addRelated(info, messageId) {
|
|
38
|
+
if (messageId && !info.relatedMessageIds.includes(messageId)) {
|
|
39
|
+
info.relatedMessageIds.push(messageId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordValues(parsed) {
|
|
44
|
+
return (parsed?.records ?? [])
|
|
45
|
+
.map((record) => record?.value)
|
|
46
|
+
.filter((value) => value && typeof value === 'object');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lifecycleLabel(eventType) {
|
|
50
|
+
return {
|
|
51
|
+
negotiate: 'Negotiate',
|
|
52
|
+
'azure-signalr-redirect': 'Azure SignalR redirect',
|
|
53
|
+
'transport-open': 'Transport connected',
|
|
54
|
+
'transport-close': 'Transport disconnected',
|
|
55
|
+
'transport-error': 'Transport error',
|
|
56
|
+
}[eventType];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function endpointKey(endpoint) {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(endpoint);
|
|
62
|
+
if (url.protocol === 'ws:') {
|
|
63
|
+
url.protocol = 'http:';
|
|
64
|
+
} else if (url.protocol === 'wss:') {
|
|
65
|
+
url.protocol = 'https:';
|
|
66
|
+
}
|
|
67
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
68
|
+
} catch {
|
|
69
|
+
return endpoint;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function observedConnectionKey(message) {
|
|
74
|
+
if (
|
|
75
|
+
Number.isSafeInteger(message.connectionSeq) &&
|
|
76
|
+
message.connectionSeq > 0 &&
|
|
77
|
+
typeof message.documentId === 'string' &&
|
|
78
|
+
message.documentId
|
|
79
|
+
) {
|
|
80
|
+
return `captured\n${message.documentId}\n${message.connectionSeq}`;
|
|
81
|
+
}
|
|
82
|
+
return `heuristic\n${endpointKey(message.endpoint)}\n${message.transport}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function median(values) {
|
|
86
|
+
const ordered = [...values].sort((left, right) => left - right);
|
|
87
|
+
const middle = Math.floor(ordered.length / 2);
|
|
88
|
+
return ordered.length % 2 === 0 ? (ordered[middle - 1] + ordered[middle]) / 2 : ordered[middle];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createConnection(id, message) {
|
|
92
|
+
return {
|
|
93
|
+
id,
|
|
94
|
+
endpoint: message.endpoint,
|
|
95
|
+
transport: message.transport === 'negotiation' ? '' : message.transport,
|
|
96
|
+
startedAt: message.timestamp,
|
|
97
|
+
endedAt: null,
|
|
98
|
+
status: 'observed',
|
|
99
|
+
azureEndpoint: null,
|
|
100
|
+
serviceNegotiated: false,
|
|
101
|
+
keyKind: null,
|
|
102
|
+
documentId: message.documentId ?? null,
|
|
103
|
+
handshakeRequested: false,
|
|
104
|
+
handshakeAccepted: false,
|
|
105
|
+
sawHubFrames: false,
|
|
106
|
+
sawNonPingHubFrame: false,
|
|
107
|
+
closed: false,
|
|
108
|
+
gracefulClose: false,
|
|
109
|
+
inbound: { acknowledgedThrough: null, resumesAt: null },
|
|
110
|
+
outbound: { acknowledgedThrough: null, resumesAt: null },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function analyzeConnections(messages, parsedByMessage, messageInfo) {
|
|
115
|
+
const connections = [];
|
|
116
|
+
const connectionByMessage = new Map();
|
|
117
|
+
const currentByConnection = new Map();
|
|
118
|
+
const pendingNegotiationByEndpoint = new Map();
|
|
119
|
+
const pingStatsByConnection = new Map();
|
|
120
|
+
const timeline = [];
|
|
121
|
+
let connectionCount = 0;
|
|
122
|
+
|
|
123
|
+
function pushEvent(connection, message, { kind, label, detail = '' }) {
|
|
124
|
+
const event = {
|
|
125
|
+
id: `${message.id}:${kind}:${timeline.length}`,
|
|
126
|
+
connectionId: connection.id,
|
|
127
|
+
messageId: message.id,
|
|
128
|
+
timestamp: message.timestamp,
|
|
129
|
+
kind,
|
|
130
|
+
label,
|
|
131
|
+
detail,
|
|
132
|
+
};
|
|
133
|
+
timeline.push(event);
|
|
134
|
+
return event;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function queueNegotiation(endpoint, connection) {
|
|
138
|
+
const queue = pendingNegotiationByEndpoint.get(endpoint) ?? [];
|
|
139
|
+
queue.push(connection);
|
|
140
|
+
pendingNegotiationByEndpoint.set(endpoint, queue);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function takeNegotiation(endpoint) {
|
|
144
|
+
const queue = pendingNegotiationByEndpoint.get(endpoint);
|
|
145
|
+
const connection = queue?.shift() ?? null;
|
|
146
|
+
if (queue?.length === 0) {
|
|
147
|
+
pendingNegotiationByEndpoint.delete(endpoint);
|
|
148
|
+
}
|
|
149
|
+
return connection;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function startConnection(message, reuseNegotiation = true) {
|
|
153
|
+
const normalizedEndpoint = endpointKey(message.endpoint);
|
|
154
|
+
let connection = reuseNegotiation ? takeNegotiation(normalizedEndpoint) : null;
|
|
155
|
+
if (connection) {
|
|
156
|
+
connection.endpoint = message.endpoint;
|
|
157
|
+
connection.transport = message.transport;
|
|
158
|
+
} else {
|
|
159
|
+
connectionCount += 1;
|
|
160
|
+
connection = createConnection(`connection-${connectionCount}`, message);
|
|
161
|
+
connections.push(connection);
|
|
162
|
+
pushEvent(connection, message, {
|
|
163
|
+
kind: 'connection-observed',
|
|
164
|
+
label: 'Connection observed',
|
|
165
|
+
detail: message.transport,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
currentByConnection.set(observedConnectionKey(message), connection);
|
|
169
|
+
return connection;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A transport whose first hub frame is a Sequence instead of a handshake can only be a
|
|
173
|
+
// stateful reconnect resume: the protocol requires every fresh connection to open with a
|
|
174
|
+
// handshake, and only a resume skips it. Connection tokens are sanitized away before
|
|
175
|
+
// capture, so the split card is folded back into the interrupted connection it continues.
|
|
176
|
+
// The interrupted side may itself lack a captured handshake (log cleared, activation on an
|
|
177
|
+
// already-connected page, oldest entries evicted) — any decoded hub frame is accepted as
|
|
178
|
+
// equivalent proof that the candidate speaks hub protocol.
|
|
179
|
+
function mergeStatefulResume(connection, message) {
|
|
180
|
+
if (connection.handshakeRequested || connection.handshakeAccepted) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const normalizedEndpoint = endpointKey(connection.endpoint);
|
|
184
|
+
const candidates = connections.filter(
|
|
185
|
+
(candidate) =>
|
|
186
|
+
candidate !== connection &&
|
|
187
|
+
candidate.closed &&
|
|
188
|
+
!candidate.gracefulClose &&
|
|
189
|
+
(candidate.status === 'disconnected' || candidate.status === 'error') &&
|
|
190
|
+
(candidate.handshakeAccepted || candidate.sawHubFrames) &&
|
|
191
|
+
candidate.transport === connection.transport &&
|
|
192
|
+
endpointKey(candidate.endpoint) === normalizedEndpoint &&
|
|
193
|
+
(connection.documentId === null ||
|
|
194
|
+
candidate.documentId === null ||
|
|
195
|
+
candidate.documentId === connection.documentId) &&
|
|
196
|
+
message.timestamp - (candidate.endedAt ?? candidate.startedAt) <= 30_000,
|
|
197
|
+
);
|
|
198
|
+
// Prefer the drop closest in time to the resume over mere creation order.
|
|
199
|
+
const resumed = candidates.reduce(
|
|
200
|
+
(closest, candidate) =>
|
|
201
|
+
closest === null ||
|
|
202
|
+
Math.abs(message.timestamp - (candidate.endedAt ?? candidate.startedAt)) <
|
|
203
|
+
Math.abs(message.timestamp - (closest.endedAt ?? closest.startedAt))
|
|
204
|
+
? candidate
|
|
205
|
+
: closest,
|
|
206
|
+
null,
|
|
207
|
+
);
|
|
208
|
+
if (!resumed) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
for (const [messageId, connectionId] of connectionByMessage) {
|
|
212
|
+
if (connectionId === connection.id) {
|
|
213
|
+
connectionByMessage.set(messageId, resumed.id);
|
|
214
|
+
messageInfoFor(messageInfo, messageId).connectionId = resumed.id;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
|
218
|
+
const event = timeline[index];
|
|
219
|
+
if (event.connectionId !== connection.id) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (event.kind === 'connection-observed') {
|
|
223
|
+
timeline.splice(index, 1);
|
|
224
|
+
} else {
|
|
225
|
+
event.connectionId = resumed.id;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
connections.splice(connections.indexOf(connection), 1);
|
|
229
|
+
for (const [key, current] of currentByConnection) {
|
|
230
|
+
if (current === connection) {
|
|
231
|
+
currentByConnection.set(key, resumed);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const stats = pingStatsByConnection.get(connection.id);
|
|
235
|
+
if (stats) {
|
|
236
|
+
const resumedStats = pingStatsByConnection.get(resumed.id);
|
|
237
|
+
if (!resumedStats) {
|
|
238
|
+
pingStatsByConnection.set(resumed.id, stats);
|
|
239
|
+
} else {
|
|
240
|
+
// Fold the temporary card's pings into the resumed connection's single event; the
|
|
241
|
+
// gap across the drop itself was never observed, so it is not synthesized here.
|
|
242
|
+
resumedStats.count += stats.count;
|
|
243
|
+
resumedStats.gaps.push(...stats.gaps);
|
|
244
|
+
resumedStats.lastAt = Math.max(resumedStats.lastAt ?? stats.lastAt, stats.lastAt);
|
|
245
|
+
resumedStats.event.detail =
|
|
246
|
+
resumedStats.gaps.length === 0
|
|
247
|
+
? `${resumedStats.count} pings observed`
|
|
248
|
+
: `${resumedStats.count} pings · median gap ${formatDuration(median(resumedStats.gaps))}`;
|
|
249
|
+
const duplicateEventIndex = timeline.indexOf(stats.event);
|
|
250
|
+
if (duplicateEventIndex !== -1) {
|
|
251
|
+
timeline.splice(duplicateEventIndex, 1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
pingStatsByConnection.delete(connection.id);
|
|
255
|
+
}
|
|
256
|
+
for (const channelName of ['inbound', 'outbound']) {
|
|
257
|
+
for (const field of ['acknowledgedThrough', 'resumesAt']) {
|
|
258
|
+
if (connection[channelName][field] !== null && resumed[channelName][field] === null) {
|
|
259
|
+
resumed[channelName][field] = connection[channelName][field];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
resumed.closed = false;
|
|
264
|
+
resumed.endedAt = null;
|
|
265
|
+
resumed.status = 'connected';
|
|
266
|
+
resumed.transport = connection.transport;
|
|
267
|
+
resumed.endpoint = connection.endpoint;
|
|
268
|
+
return resumed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const message of messages) {
|
|
272
|
+
const parsed = parsedByMessage.get(message);
|
|
273
|
+
const normalizedEndpoint = endpointKey(message.endpoint);
|
|
274
|
+
const connectionKey = observedConnectionKey(message);
|
|
275
|
+
const isNegotiation = ['negotiate', 'azure-signalr-redirect'].includes(
|
|
276
|
+
message.lifecycleEvent,
|
|
277
|
+
);
|
|
278
|
+
const startsTransport = message.lifecycleEvent === 'transport-open';
|
|
279
|
+
const endsTransport =
|
|
280
|
+
message.lifecycleEvent === 'transport-close' ||
|
|
281
|
+
message.lifecycleEvent === 'transport-error';
|
|
282
|
+
const startsHandshake = parsed?.records?.some((record) => record.kind === 'Handshake');
|
|
283
|
+
let connection = currentByConnection.get(connectionKey);
|
|
284
|
+
let mergedDuplicateOpen = false;
|
|
285
|
+
|
|
286
|
+
if (isNegotiation) {
|
|
287
|
+
// An Azure SignalR redirect is followed by a second negotiation against the service
|
|
288
|
+
// endpoint for the same logical connection — merge it instead of opening a new card.
|
|
289
|
+
const redirected =
|
|
290
|
+
message.lifecycleEvent === 'negotiate'
|
|
291
|
+
? pendingNegotiationByEndpoint
|
|
292
|
+
.get(normalizedEndpoint)
|
|
293
|
+
?.find((candidate) => candidate.azureEndpoint && !candidate.serviceNegotiated)
|
|
294
|
+
: null;
|
|
295
|
+
if (redirected) {
|
|
296
|
+
redirected.serviceNegotiated = true;
|
|
297
|
+
connection = redirected;
|
|
298
|
+
} else {
|
|
299
|
+
connectionCount += 1;
|
|
300
|
+
connection = createConnection(`connection-${connectionCount}`, message);
|
|
301
|
+
if (message.lifecycleEvent === 'azure-signalr-redirect') {
|
|
302
|
+
connection.azureEndpoint = message.lifecycleDetail;
|
|
303
|
+
}
|
|
304
|
+
connections.push(connection);
|
|
305
|
+
queueNegotiation(endpointKey(connection.azureEndpoint || message.endpoint), connection);
|
|
306
|
+
}
|
|
307
|
+
} else if (
|
|
308
|
+
!connection ||
|
|
309
|
+
(connection.closed && !endsTransport) ||
|
|
310
|
+
(startsHandshake && connection.handshakeRequested) ||
|
|
311
|
+
(startsTransport && connection.transport && connection.status === 'connected')
|
|
312
|
+
) {
|
|
313
|
+
// A live Server-Sent Events connection can be reported by two observers at once:
|
|
314
|
+
// the page world (captured key) and the DevTools network observer, which sees only
|
|
315
|
+
// the outgoing POSTs (heuristic key). Attach the second transport-open to the
|
|
316
|
+
// existing connection instead of splitting one conversation into two cards.
|
|
317
|
+
const messageKeyKind = connectionKey.startsWith('captured\n') ? 'captured' : 'heuristic';
|
|
318
|
+
const dualObserver =
|
|
319
|
+
startsTransport && message.transport === 'server-sent events'
|
|
320
|
+
? [...connections]
|
|
321
|
+
.reverse()
|
|
322
|
+
.find(
|
|
323
|
+
(candidate) =>
|
|
324
|
+
!candidate.closed &&
|
|
325
|
+
candidate.status === 'connected' &&
|
|
326
|
+
candidate.transport === 'server-sent events' &&
|
|
327
|
+
candidate.keyKind !== null &&
|
|
328
|
+
candidate.keyKind !== messageKeyKind &&
|
|
329
|
+
endpointKey(candidate.endpoint) === normalizedEndpoint,
|
|
330
|
+
)
|
|
331
|
+
: null;
|
|
332
|
+
if (dualObserver) {
|
|
333
|
+
connection = dualObserver;
|
|
334
|
+
currentByConnection.set(connectionKey, connection);
|
|
335
|
+
mergedDuplicateOpen = true;
|
|
336
|
+
}
|
|
337
|
+
const previous = mergedDuplicateOpen
|
|
338
|
+
? null
|
|
339
|
+
: [...connections]
|
|
340
|
+
.reverse()
|
|
341
|
+
.find(
|
|
342
|
+
(candidate) =>
|
|
343
|
+
endpointKey(candidate.endpoint) === normalizedEndpoint && candidate.transport,
|
|
344
|
+
);
|
|
345
|
+
if (!mergedDuplicateOpen) {
|
|
346
|
+
connection = startConnection(message);
|
|
347
|
+
connection.keyKind = messageKeyKind;
|
|
348
|
+
}
|
|
349
|
+
if (
|
|
350
|
+
previous?.closed &&
|
|
351
|
+
previous.id !== connection.id &&
|
|
352
|
+
previous.transport !== message.transport &&
|
|
353
|
+
message.timestamp - (previous.endedAt ?? previous.startedAt) <= 30_000
|
|
354
|
+
) {
|
|
355
|
+
pushEvent(connection, message, {
|
|
356
|
+
kind: 'transport-fallback',
|
|
357
|
+
label: 'Transport fallback',
|
|
358
|
+
detail: `${previous.transport} → ${message.transport}`,
|
|
359
|
+
});
|
|
360
|
+
} else if (previous?.closed && previous.id !== connection.id) {
|
|
361
|
+
pushEvent(connection, message, {
|
|
362
|
+
kind: 'reconnect',
|
|
363
|
+
label: 'Reconnect observed',
|
|
364
|
+
detail: message.transport,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
connectionByMessage.set(message.id, connection.id);
|
|
370
|
+
messageInfoFor(messageInfo, message.id).connectionId = connection.id;
|
|
371
|
+
|
|
372
|
+
if (message.lifecycleEvent) {
|
|
373
|
+
const label = lifecycleLabel(message.lifecycleEvent);
|
|
374
|
+
if (label && !mergedDuplicateOpen) {
|
|
375
|
+
pushEvent(connection, message, {
|
|
376
|
+
kind: message.lifecycleEvent,
|
|
377
|
+
label,
|
|
378
|
+
detail: message.lifecycleDetail || message.preview || '',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (message.lifecycleEvent === 'transport-open') {
|
|
382
|
+
connection.status = 'connected';
|
|
383
|
+
connection.transport = message.transport;
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
message.lifecycleEvent === 'transport-close' ||
|
|
387
|
+
message.lifecycleEvent === 'transport-error'
|
|
388
|
+
) {
|
|
389
|
+
connection.status =
|
|
390
|
+
message.lifecycleEvent === 'transport-error' ? 'error' : 'disconnected';
|
|
391
|
+
connection.endedAt = message.timestamp;
|
|
392
|
+
connection.closed = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const record of parsed?.records ?? []) {
|
|
397
|
+
const value = record?.value;
|
|
398
|
+
if (record.kind === 'Handshake') {
|
|
399
|
+
connection.handshakeRequested = true;
|
|
400
|
+
pushEvent(connection, message, {
|
|
401
|
+
kind: 'handshake',
|
|
402
|
+
label: 'Handshake requested',
|
|
403
|
+
detail: record.summary,
|
|
404
|
+
});
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (record.kind === 'Handshake response' || record.kind === 'Handshake error') {
|
|
408
|
+
connection.handshakeAccepted = record.kind === 'Handshake response';
|
|
409
|
+
connection.status = connection.handshakeAccepted ? 'connected' : 'error';
|
|
410
|
+
pushEvent(connection, message, {
|
|
411
|
+
kind: connection.handshakeAccepted ? 'handshake-accepted' : 'handshake-error',
|
|
412
|
+
label: connection.handshakeAccepted ? 'Handshake accepted' : 'Handshake failed',
|
|
413
|
+
detail: record.summary,
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (!value || typeof value !== 'object') {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const priorNonPingHubFrame = connection.sawNonPingHubFrame;
|
|
421
|
+
if (Number.isInteger(value.type) && value.type >= 1 && value.type <= 9) {
|
|
422
|
+
connection.sawHubFrames = true;
|
|
423
|
+
if (value.type !== 6) {
|
|
424
|
+
connection.sawNonPingHubFrame = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (value.type === 6) {
|
|
428
|
+
let stats = pingStatsByConnection.get(connection.id);
|
|
429
|
+
if (!stats) {
|
|
430
|
+
stats = {
|
|
431
|
+
count: 0,
|
|
432
|
+
gaps: [],
|
|
433
|
+
lastAt: null,
|
|
434
|
+
event: pushEvent(connection, message, {
|
|
435
|
+
kind: 'ping',
|
|
436
|
+
label: 'Keep-alive pings',
|
|
437
|
+
}),
|
|
438
|
+
};
|
|
439
|
+
pingStatsByConnection.set(connection.id, stats);
|
|
440
|
+
}
|
|
441
|
+
if (stats.lastAt !== null) {
|
|
442
|
+
stats.gaps.push(Math.max(0, message.timestamp - stats.lastAt));
|
|
443
|
+
}
|
|
444
|
+
stats.lastAt = message.timestamp;
|
|
445
|
+
stats.count += 1;
|
|
446
|
+
stats.event.detail =
|
|
447
|
+
stats.count === 1
|
|
448
|
+
? '1 ping observed'
|
|
449
|
+
: `${stats.count} pings · median gap ${formatDuration(median(stats.gaps))}`;
|
|
450
|
+
} else if (value.type === 7) {
|
|
451
|
+
connection.status = value.allowReconnect ? 'reconnect allowed' : 'closed';
|
|
452
|
+
connection.endedAt = message.timestamp;
|
|
453
|
+
connection.closed = true;
|
|
454
|
+
// A Close frame ends the logical connection; a later Sequence on this endpoint is a
|
|
455
|
+
// different connection, never a stateful resume of this one.
|
|
456
|
+
connection.gracefulClose = true;
|
|
457
|
+
pushEvent(connection, message, {
|
|
458
|
+
kind: 'close',
|
|
459
|
+
label: value.allowReconnect
|
|
460
|
+
? 'Connection closed; reconnect allowed'
|
|
461
|
+
: 'Connection closed',
|
|
462
|
+
detail: value.error || '',
|
|
463
|
+
});
|
|
464
|
+
} else if (value.type === 8) {
|
|
465
|
+
const validSequenceId = Number.isInteger(value.sequenceId);
|
|
466
|
+
const channel =
|
|
467
|
+
message.direction === 'incoming' ? connection.outbound : connection.inbound;
|
|
468
|
+
if (validSequenceId) {
|
|
469
|
+
channel.acknowledgedThrough = value.sequenceId;
|
|
470
|
+
}
|
|
471
|
+
pushEvent(connection, message, {
|
|
472
|
+
kind: 'ack',
|
|
473
|
+
label: 'Stateful reconnect acknowledgement',
|
|
474
|
+
detail: `${message.direction === 'incoming' ? 'Outbound' : 'Inbound'} delivered through ${validSequenceId ? `#${value.sequenceId}` : '(invalid sequenceId)'}`,
|
|
475
|
+
});
|
|
476
|
+
} else if (value.type === 9) {
|
|
477
|
+
// A resume's Sequence must be the first non-ping hub frame on the transport; after
|
|
478
|
+
// any other hub traffic this Sequence cannot open a stateful resume.
|
|
479
|
+
if (!priorNonPingHubFrame) {
|
|
480
|
+
const resumedConnection = mergeStatefulResume(connection, message);
|
|
481
|
+
if (resumedConnection) {
|
|
482
|
+
connection = resumedConnection;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const validSequenceId = Number.isInteger(value.sequenceId);
|
|
486
|
+
const channel =
|
|
487
|
+
message.direction === 'outgoing' ? connection.outbound : connection.inbound;
|
|
488
|
+
const previous = channel.resumesAt;
|
|
489
|
+
if (validSequenceId) {
|
|
490
|
+
channel.resumesAt = value.sequenceId;
|
|
491
|
+
}
|
|
492
|
+
const detail = `${message.direction === 'outgoing' ? 'Outbound' : 'Inbound'} resumes at ${validSequenceId ? `#${value.sequenceId}` : '(invalid sequenceId)'}${validSequenceId && previous !== null ? ` (previously #${previous})` : ''}`;
|
|
493
|
+
pushEvent(connection, message, {
|
|
494
|
+
kind: 'sequence',
|
|
495
|
+
label: 'Stateful reconnect sequence',
|
|
496
|
+
detail,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return { connectionByMessage, connections, timeline };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function analyzeFlows(messages, parsedByMessage, connectionByMessage, messageInfo) {
|
|
506
|
+
const pending = new Map();
|
|
507
|
+
|
|
508
|
+
function flowKey(connectionId, direction, invocationId) {
|
|
509
|
+
return `${connectionId}\n${direction}\n${invocationId}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
for (const message of messages) {
|
|
513
|
+
const connectionId = connectionByMessage.get(message.id);
|
|
514
|
+
const info = messageInfoFor(messageInfo, message.id);
|
|
515
|
+
for (const value of recordValues(parsedByMessage.get(message))) {
|
|
516
|
+
if (INVOCATION_TYPES.has(value.type) && value.invocationId !== undefined) {
|
|
517
|
+
pending.set(flowKey(connectionId, message.direction, value.invocationId), {
|
|
518
|
+
connectionId,
|
|
519
|
+
direction: message.direction,
|
|
520
|
+
invocationId: value.invocationId,
|
|
521
|
+
messageId: message.id,
|
|
522
|
+
startedAt: message.timestamp,
|
|
523
|
+
target: value.target,
|
|
524
|
+
type: value.type,
|
|
525
|
+
items: [],
|
|
526
|
+
completion: null,
|
|
527
|
+
cancelled: false,
|
|
528
|
+
});
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (value.type === 2 && value.invocationId !== undefined) {
|
|
533
|
+
const flow = pending.get(
|
|
534
|
+
flowKey(connectionId, oppositeDirection(message.direction), value.invocationId),
|
|
535
|
+
);
|
|
536
|
+
if (flow?.type === 4) {
|
|
537
|
+
flow.items.push({ messageId: message.id, timestamp: message.timestamp });
|
|
538
|
+
info.streamParentId = flow.messageId;
|
|
539
|
+
const parentInfo = messageInfoFor(messageInfo, flow.messageId);
|
|
540
|
+
if (!parentInfo.streamChildren.includes(message.id)) {
|
|
541
|
+
parentInfo.streamChildren.push(message.id);
|
|
542
|
+
}
|
|
543
|
+
addRelated(info, flow.messageId);
|
|
544
|
+
}
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (value.type === 3 && value.invocationId !== undefined) {
|
|
549
|
+
const flow = pending.get(
|
|
550
|
+
flowKey(connectionId, oppositeDirection(message.direction), value.invocationId),
|
|
551
|
+
);
|
|
552
|
+
if (flow) {
|
|
553
|
+
flow.completion = {
|
|
554
|
+
messageId: message.id,
|
|
555
|
+
timestamp: message.timestamp,
|
|
556
|
+
error: value.error,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (value.type === 5 && value.invocationId !== undefined) {
|
|
563
|
+
const flow = pending.get(flowKey(connectionId, message.direction, value.invocationId));
|
|
564
|
+
if (flow) {
|
|
565
|
+
flow.cancelled = true;
|
|
566
|
+
flow.completion = { messageId: message.id, timestamp: message.timestamp };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (const flow of pending.values()) {
|
|
573
|
+
const invocationInfo = messageInfoFor(messageInfo, flow.messageId);
|
|
574
|
+
const completion = flow.completion;
|
|
575
|
+
const duration = completion ? completion.timestamp - flow.startedAt : null;
|
|
576
|
+
let label;
|
|
577
|
+
|
|
578
|
+
if (flow.type === 4) {
|
|
579
|
+
const itemCount = flow.items.length;
|
|
580
|
+
const itemLabel = `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
|
|
581
|
+
let rateLabel = '';
|
|
582
|
+
if (itemCount > 1) {
|
|
583
|
+
const interval = flow.items.at(-1).timestamp - flow.items[0].timestamp;
|
|
584
|
+
if (interval > 0) {
|
|
585
|
+
rateLabel = ` · ${((itemCount - 1) / (interval / 1_000)).toFixed(1)}/s`;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
label = completion
|
|
589
|
+
? `${flow.cancelled ? 'Cancelled' : completion.error ? 'Error' : 'Completed'} · ${itemLabel}${rateLabel} · ${formatDuration(duration)}`
|
|
590
|
+
: `Streaming · ${itemLabel}${rateLabel}`;
|
|
591
|
+
} else if (!completion) {
|
|
592
|
+
label = `Pending #${flow.invocationId}`;
|
|
593
|
+
} else if (flow.cancelled) {
|
|
594
|
+
label = `Cancelled · ${formatDuration(duration)}`;
|
|
595
|
+
} else if (completion.error) {
|
|
596
|
+
label = `Error · ${formatDuration(duration)}`;
|
|
597
|
+
} else {
|
|
598
|
+
label = `Completed · ${formatDuration(duration)}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
invocationInfo.flowLabels.push(label);
|
|
602
|
+
if (completion) {
|
|
603
|
+
const completionInfo = messageInfoFor(messageInfo, completion.messageId);
|
|
604
|
+
completionInfo.flowLabels.push(`↩ ${flow.target || 'Invocation'} #${flow.invocationId}`);
|
|
605
|
+
addRelated(invocationInfo, completion.messageId);
|
|
606
|
+
addRelated(completionInfo, flow.messageId);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return [...pending.values()];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function analyzeInsights({ messages, parsedByMessage, connections, flows, messageInfo }) {
|
|
613
|
+
const methodCounts = new Map();
|
|
614
|
+
const hubMessages = [];
|
|
615
|
+
let capturedBytes = 0;
|
|
616
|
+
|
|
617
|
+
for (const message of messages) {
|
|
618
|
+
const values = recordValues(parsedByMessage.get(message)).filter((value) =>
|
|
619
|
+
Number.isInteger(value.type),
|
|
620
|
+
);
|
|
621
|
+
if (values.length === 0) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
hubMessages.push(...values.map((value) => ({ message, value })));
|
|
625
|
+
if (Number.isFinite(message.size)) {
|
|
626
|
+
capturedBytes += Math.max(0, message.size);
|
|
627
|
+
}
|
|
628
|
+
for (const value of values) {
|
|
629
|
+
if (INVOCATION_TYPES.has(value.type) && typeof value.target === 'string' && value.target) {
|
|
630
|
+
methodCounts.set(value.target, (methodCounts.get(value.target) ?? 0) + 1);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const firstTimestamp = hubMessages[0]?.message.timestamp ?? null;
|
|
636
|
+
const lastTimestamp = hubMessages.at(-1)?.message.timestamp ?? null;
|
|
637
|
+
const durationMs =
|
|
638
|
+
firstTimestamp === null || lastTimestamp === null
|
|
639
|
+
? 0
|
|
640
|
+
: Math.max(0, lastTimestamp - firstTimestamp);
|
|
641
|
+
const durationSeconds = durationMs / 1_000;
|
|
642
|
+
const methods = [...methodCounts]
|
|
643
|
+
.map(([target, count]) => ({
|
|
644
|
+
target,
|
|
645
|
+
count,
|
|
646
|
+
percentage: hubMessages.length === 0 ? 0 : (count / hubMessages.length) * 100,
|
|
647
|
+
}))
|
|
648
|
+
.sort((left, right) => right.count - left.count || left.target.localeCompare(right.target));
|
|
649
|
+
const warnings = [];
|
|
650
|
+
|
|
651
|
+
for (const message of messages) {
|
|
652
|
+
const hubRecords = recordValues(parsedByMessage.get(message)).filter((value) =>
|
|
653
|
+
Number.isInteger(value.type),
|
|
654
|
+
);
|
|
655
|
+
if (
|
|
656
|
+
message.direction === 'outgoing' &&
|
|
657
|
+
Number.isFinite(message.size) &&
|
|
658
|
+
message.size >= LARGE_PAYLOAD_WARNING_SIZE &&
|
|
659
|
+
hubRecords.length === 1
|
|
660
|
+
) {
|
|
661
|
+
warnings.push({
|
|
662
|
+
id: `large-payload:${message.id}`,
|
|
663
|
+
kind: 'large-payload',
|
|
664
|
+
severity: message.size > DEFAULT_MAX_RECEIVE_MESSAGE_SIZE ? 'high' : 'warning',
|
|
665
|
+
messageId: message.id,
|
|
666
|
+
timestamp: message.timestamp,
|
|
667
|
+
title: 'Large outbound payload',
|
|
668
|
+
detail: `${message.size} bytes is ${message.size > DEFAULT_MAX_RECEIVE_MESSAGE_SIZE ? 'above' : 'close to'} ASP.NET Core SignalR's default 32 KiB receive limit.`,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const observationEnd = messages.at(-1)?.timestamp ?? 0;
|
|
674
|
+
const connectionsById = new Map(connections.map((connection) => [connection.id, connection]));
|
|
675
|
+
for (const flow of flows) {
|
|
676
|
+
if (flow.completion) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const connection = connectionsById.get(flow.connectionId);
|
|
680
|
+
const connectionEnded = connection?.endedAt !== null && connection?.endedAt !== undefined;
|
|
681
|
+
const age = Math.max(
|
|
682
|
+
0,
|
|
683
|
+
(connectionEnded ? connection.endedAt : observationEnd) - flow.startedAt,
|
|
684
|
+
);
|
|
685
|
+
if (!connectionEnded && (flow.type === 4 || age < PENDING_INVOCATION_GRACE_MS)) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
warnings.push({
|
|
689
|
+
id: `pending-invocation:${flow.messageId}`,
|
|
690
|
+
kind: 'pending-invocation',
|
|
691
|
+
severity: 'warning',
|
|
692
|
+
messageId: flow.messageId,
|
|
693
|
+
timestamp: flow.startedAt,
|
|
694
|
+
title: 'Invocation without Completion',
|
|
695
|
+
detail: `${flow.target || 'Invocation'} #${flow.invocationId} remained pending for ${formatDuration(age)}${connectionEnded ? ' before the connection ended' : ''}.`,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const pingHistory = new Map();
|
|
700
|
+
for (const message of messages) {
|
|
701
|
+
const connectionId = messageInfo.get(message.id)?.connectionId;
|
|
702
|
+
if (!connectionId) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
for (const value of recordValues(parsedByMessage.get(message))) {
|
|
706
|
+
if (value.type !== 6) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const history = pingHistory.get(connectionId) ?? { lastAt: null, gaps: [] };
|
|
710
|
+
if (history.lastAt !== null) {
|
|
711
|
+
const gap = Math.max(0, message.timestamp - history.lastAt);
|
|
712
|
+
const baseline = history.gaps.length >= 2 ? median(history.gaps) : null;
|
|
713
|
+
const warningThreshold =
|
|
714
|
+
baseline === null
|
|
715
|
+
? DEFAULT_KEEP_ALIVE_GAP_WARNING_MS
|
|
716
|
+
: Math.max(20_000, baseline * 1.75);
|
|
717
|
+
if (gap > warningThreshold) {
|
|
718
|
+
warnings.push({
|
|
719
|
+
id: `keep-alive-gap:${message.id}`,
|
|
720
|
+
kind: 'keep-alive-gap',
|
|
721
|
+
severity: 'warning',
|
|
722
|
+
messageId: message.id,
|
|
723
|
+
timestamp: message.timestamp,
|
|
724
|
+
title: 'Long keep-alive gap',
|
|
725
|
+
detail:
|
|
726
|
+
baseline === null
|
|
727
|
+
? `${formatDuration(gap)} elapsed between pings; this exceeds the 30 s fallback threshold.`
|
|
728
|
+
: `${formatDuration(gap)} elapsed between pings; the observed median was ${formatDuration(baseline)}.`,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
history.gaps.push(gap);
|
|
732
|
+
}
|
|
733
|
+
history.lastAt = message.timestamp;
|
|
734
|
+
pingHistory.set(connectionId, history);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
warnings.sort(
|
|
739
|
+
(left, right) => left.timestamp - right.timestamp || left.messageId - right.messageId,
|
|
740
|
+
);
|
|
741
|
+
return {
|
|
742
|
+
summary: {
|
|
743
|
+
hubMessages: hubMessages.length,
|
|
744
|
+
capturedBytes,
|
|
745
|
+
durationMs,
|
|
746
|
+
messagesPerSecond: durationSeconds > 0 ? hubMessages.length / durationSeconds : null,
|
|
747
|
+
bytesPerSecond: durationSeconds > 0 ? capturedBytes / durationSeconds : null,
|
|
748
|
+
azureConnections: connections.filter((connection) => connection.azureEndpoint).length,
|
|
749
|
+
},
|
|
750
|
+
methods,
|
|
751
|
+
warnings,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function analyze(messages, parsePayload) {
|
|
756
|
+
const ordered = [...messages].sort(
|
|
757
|
+
(left, right) => left.timestamp - right.timestamp || left.id - right.id,
|
|
758
|
+
);
|
|
759
|
+
const parsedByMessage = new Map(ordered.map((message) => [message, parsePayload(message)]));
|
|
760
|
+
const messageInfo = new Map();
|
|
761
|
+
for (const message of ordered) {
|
|
762
|
+
messageInfoFor(messageInfo, message.id);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const connectionAnalysis = analyzeConnections(ordered, parsedByMessage, messageInfo);
|
|
766
|
+
const flows = analyzeFlows(
|
|
767
|
+
ordered,
|
|
768
|
+
parsedByMessage,
|
|
769
|
+
connectionAnalysis.connectionByMessage,
|
|
770
|
+
messageInfo,
|
|
771
|
+
);
|
|
772
|
+
const insights = analyzeInsights({
|
|
773
|
+
messages: ordered,
|
|
774
|
+
parsedByMessage,
|
|
775
|
+
connections: connectionAnalysis.connections,
|
|
776
|
+
flows,
|
|
777
|
+
messageInfo,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
connections: connectionAnalysis.connections,
|
|
782
|
+
insights,
|
|
783
|
+
messageInfo,
|
|
784
|
+
timeline: connectionAnalysis.timeline.sort(
|
|
785
|
+
(left, right) => left.timestamp - right.timestamp || left.messageId - right.messageId,
|
|
786
|
+
),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const api = { analyze, formatDuration };
|
|
791
|
+
root.SignalRAnalysis = api;
|
|
792
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
793
|
+
module.exports = api;
|
|
794
|
+
// Keep these explicit assignments detectable as synthetic named exports for Node ESM.
|
|
795
|
+
module.exports.analyze = analyze;
|
|
796
|
+
module.exports.formatDuration = formatDuration;
|
|
797
|
+
}
|
|
798
|
+
})(globalThis);
|