@webex/internal-plugin-metrics 3.12.0-next.5 → 3.12.0-next.51
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/README.md +274 -0
- package/dist/automated-user.js +19 -0
- package/dist/automated-user.js.map +1 -0
- package/dist/batcher.js +3 -0
- package/dist/batcher.js.map +1 -1
- package/dist/call-diagnostic/call-diagnostic-metrics-latencies.js +635 -62
- package/dist/call-diagnostic/call-diagnostic-metrics-latencies.js.map +1 -1
- package/dist/call-diagnostic/call-diagnostic-metrics.js +188 -78
- package/dist/call-diagnostic/call-diagnostic-metrics.js.map +1 -1
- package/dist/call-diagnostic/call-diagnostic-metrics.util.js +5 -0
- package/dist/call-diagnostic/call-diagnostic-metrics.util.js.map +1 -1
- package/dist/call-diagnostic/config.js +16 -3
- package/dist/call-diagnostic/config.js.map +1 -1
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/metrics.js +118 -5
- package/dist/metrics.js.map +1 -1
- package/dist/metrics.types.js +4 -0
- package/dist/metrics.types.js.map +1 -1
- package/dist/network-telemetry.js +610 -0
- package/dist/network-telemetry.js.map +1 -0
- package/dist/new-metrics.js +63 -17
- package/dist/new-metrics.js.map +1 -1
- package/dist/privacy-and-security-permission-enricher/constants.js +18 -0
- package/dist/privacy-and-security-permission-enricher/constants.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/index.js +143 -0
- package/dist/privacy-and-security-permission-enricher/index.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/types.js +7 -0
- package/dist/privacy-and-security-permission-enricher/types.js.map +1 -0
- package/dist/privacy-and-security-permission-enricher/utils.js +71 -0
- package/dist/privacy-and-security-permission-enricher/utils.js.map +1 -0
- package/dist/types/automated-user.d.ts +2 -0
- package/dist/types/call-diagnostic/call-diagnostic-metrics-latencies.d.ts +208 -5
- package/dist/types/call-diagnostic/call-diagnostic-metrics.d.ts +73 -14
- package/dist/types/call-diagnostic/config.d.ts +4 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/index.d.ts +5 -3
- package/dist/types/metrics.types.d.ts +7 -2
- package/dist/types/network-telemetry.d.ts +108 -0
- package/dist/types/new-metrics.d.ts +17 -1
- package/dist/types/privacy-and-security-permission-enricher/constants.d.ts +6 -0
- package/dist/types/privacy-and-security-permission-enricher/index.d.ts +40 -0
- package/dist/types/privacy-and-security-permission-enricher/types.d.ts +14 -0
- package/dist/types/privacy-and-security-permission-enricher/utils.d.ts +5 -0
- package/dist/types/unhandled-exception-telemetry/index.d.ts +60 -0
- package/dist/types/unhandled-exception-telemetry/utils.d.ts +6 -0
- package/dist/unhandled-exception-telemetry/index.js +330 -0
- package/dist/unhandled-exception-telemetry/index.js.map +1 -0
- package/dist/unhandled-exception-telemetry/utils.js +105 -0
- package/dist/unhandled-exception-telemetry/utils.js.map +1 -0
- package/package.json +12 -11
- package/src/automated-user.ts +16 -0
- package/src/batcher.js +4 -0
- package/src/call-diagnostic/call-diagnostic-metrics-latencies.ts +781 -71
- package/src/call-diagnostic/call-diagnostic-metrics.ts +119 -16
- package/src/call-diagnostic/call-diagnostic-metrics.util.ts +5 -0
- package/src/call-diagnostic/config.ts +14 -0
- package/src/config.js +8 -0
- package/src/index.ts +16 -0
- package/src/metrics.js +123 -5
- package/src/metrics.types.ts +25 -3
- package/src/network-telemetry.ts +757 -0
- package/src/new-metrics.ts +56 -6
- package/src/privacy-and-security-permission-enricher/constants.ts +33 -0
- package/src/privacy-and-security-permission-enricher/index.ts +142 -0
- package/src/privacy-and-security-permission-enricher/types.ts +21 -0
- package/src/privacy-and-security-permission-enricher/utils.ts +82 -0
- package/src/unhandled-exception-telemetry/index.ts +403 -0
- package/src/unhandled-exception-telemetry/utils.ts +101 -0
- package/test/unit/spec/automated-user.ts +43 -0
- package/test/unit/spec/batcher.js +43 -0
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics-batcher.ts +14 -2
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics-latencies.ts +1424 -293
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +906 -159
- package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.util.ts +27 -0
- package/test/unit/spec/metrics.js +14 -5
- package/test/unit/spec/network-telemetry.ts +610 -0
- package/test/unit/spec/new-metrics.ts +203 -50
- package/test/unit/spec/prelogin-metrics-batcher.ts +71 -36
- package/test/unit/spec/privacy-and-security-permission-enricher.ts +318 -0
- package/test/unit/spec/unhandled-exception-telemetry/utils.ts +109 -0
- package/test/unit/spec/unhandled-exception-telemetry.ts +688 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
// TypeScript signatures own the types; JSDoc contains descriptions only.
|
|
2
|
+
/* eslint valid-jsdoc: ["error", {"requireParamType": false, "requireReturnType": false}] */
|
|
3
|
+
|
|
4
|
+
import {safeSetInterval} from '@webex/common-timers';
|
|
5
|
+
|
|
6
|
+
export const NETWORK_REQUEST_SUMMARY_METRIC = 'JS_SDK_NETWORK_REQUEST_SUMMARY';
|
|
7
|
+
export const NETWORK_TELEMETRY_INTERVAL_MS = 10 * 60 * 1_000;
|
|
8
|
+
|
|
9
|
+
// Only segments that follow the SDK's static route naming convention are kept.
|
|
10
|
+
// Numeric, mixed-case, encoded, and otherwise dynamic-looking values are redacted.
|
|
11
|
+
// Lowercase alphabetic identifiers remain ambiguous because requests do not provide route templates.
|
|
12
|
+
// Examples: `rooms/12345/messages` becomes `rooms/:id/messages`, and
|
|
13
|
+
// `v1/rooms/65f81b6e-19dc-4a99-9175-59e9b34a5d42` becomes `v1/rooms/:id`.
|
|
14
|
+
// `reports/a%2Fb/download` becomes `reports/:id/download`; `rooms`, `meeting-info`, and `v1` stay unchanged.
|
|
15
|
+
const SAFE_ROUTE_SEGMENT = /^(?:[a-z]+(?:-[a-z]+)*|v[1-9]\d?)$/;
|
|
16
|
+
const MAX_TRACKING_IDS = 10;
|
|
17
|
+
const UNKNOWN = 'unknown';
|
|
18
|
+
|
|
19
|
+
type Headers = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
type MetricsSummary = {
|
|
22
|
+
totalSendRequest: number;
|
|
23
|
+
totalFailedRequest: number;
|
|
24
|
+
totalRecvdResponse: number;
|
|
25
|
+
totalFailedResponse: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type RequestMetric = {
|
|
29
|
+
host: string;
|
|
30
|
+
endPoint: string;
|
|
31
|
+
countSendRequest: number;
|
|
32
|
+
countFailedRequest: number;
|
|
33
|
+
countRecvdResponse: number;
|
|
34
|
+
countFailedResponse: number;
|
|
35
|
+
averageNetworkDurationMs: number;
|
|
36
|
+
maxNetworkDurationP90Ms: number;
|
|
37
|
+
maxNetworkDurationP99Ms: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type RequestIdentity = Pick<RequestMetric, 'host' | 'endPoint'>;
|
|
41
|
+
|
|
42
|
+
export type NetworkErrorMetric = {
|
|
43
|
+
host: string;
|
|
44
|
+
endPoint: string;
|
|
45
|
+
statusCode: number;
|
|
46
|
+
errorCode: string;
|
|
47
|
+
method: string;
|
|
48
|
+
errorType: string;
|
|
49
|
+
errorMessage: string;
|
|
50
|
+
trackingIds: string[];
|
|
51
|
+
countError: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type NetworkTelemetry = {
|
|
55
|
+
metricsSummary: MetricsSummary;
|
|
56
|
+
metrics: RequestMetric[];
|
|
57
|
+
errorMetrics: NetworkErrorMetric[];
|
|
58
|
+
errorMetricsSummary: Record<string, {count: number}>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type RequestMetricState = Omit<
|
|
62
|
+
RequestMetric,
|
|
63
|
+
'averageNetworkDurationMs' | 'maxNetworkDurationP90Ms' | 'maxNetworkDurationP99Ms'
|
|
64
|
+
> & {
|
|
65
|
+
totalNetworkDurationMs: number;
|
|
66
|
+
networkDurationsMs: number[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type NetworkTelemetryState = Omit<NetworkTelemetry, 'metrics'> & {
|
|
70
|
+
metrics: RequestMetricState[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type ErrorDetails = {
|
|
74
|
+
code?: number | string;
|
|
75
|
+
errorCode?: number | string;
|
|
76
|
+
message?: string;
|
|
77
|
+
name?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Request events come from JavaScript in webex-core and have no shared TypeScript
|
|
81
|
+
// contract. This local type intentionally contains only fields used by telemetry.
|
|
82
|
+
type RequestOptions = {
|
|
83
|
+
api?: string;
|
|
84
|
+
headers?: Headers;
|
|
85
|
+
method?: string;
|
|
86
|
+
resource?: string;
|
|
87
|
+
service?: string;
|
|
88
|
+
timeout?: number;
|
|
89
|
+
uri?: string;
|
|
90
|
+
url?: string;
|
|
91
|
+
// The network timing interceptor adds internal fields with a `$` prefix.
|
|
92
|
+
$timings?: {
|
|
93
|
+
networkEnd?: number;
|
|
94
|
+
networkStart?: number;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type RequestFailure = ErrorDetails & {
|
|
99
|
+
body?: ErrorDetails | string;
|
|
100
|
+
status?: number;
|
|
101
|
+
statusCode?: number;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type NetworkMetricProperties = {
|
|
105
|
+
type: 'operational';
|
|
106
|
+
tags: Record<string, string>;
|
|
107
|
+
fields: MetricsSummary;
|
|
108
|
+
eventPayload: NetworkTelemetry;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type NetworkTelemetryCollectorOptions = {
|
|
112
|
+
intervalMs?: number;
|
|
113
|
+
onSubmissionFailure: () => void;
|
|
114
|
+
submitMetric: (name: string, properties: NetworkMetricProperties) => unknown;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type NetworkTelemetryCollector = {
|
|
118
|
+
recordRequest: (options?: RequestOptions) => void;
|
|
119
|
+
recordResponse: (options?: RequestOptions) => void;
|
|
120
|
+
recordFailure: (options?: RequestOptions, reason?: RequestFailure) => void;
|
|
121
|
+
flush: () => Promise<void>;
|
|
122
|
+
stop: () => Promise<void>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Converts a non-empty string or number to a string.
|
|
127
|
+
* @param value Value to convert.
|
|
128
|
+
* @returns The converted string, or undefined for unsupported values.
|
|
129
|
+
*/
|
|
130
|
+
function toString(value: unknown): string | undefined {
|
|
131
|
+
if (typeof value === 'number') {
|
|
132
|
+
return String(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Returns the normalized SDK service name.
|
|
140
|
+
* @param options Request options containing the service or API name.
|
|
141
|
+
* @returns The normalized service name.
|
|
142
|
+
*/
|
|
143
|
+
function getService(options: RequestOptions): string {
|
|
144
|
+
const service = options.service || options.api;
|
|
145
|
+
|
|
146
|
+
return typeof service === 'string' ? service.trim().toLowerCase() : '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Replaces values that may be resource identifiers with a stable placeholder.
|
|
151
|
+
* @param segment Endpoint path segment.
|
|
152
|
+
* @returns The original static segment or the identifier placeholder.
|
|
153
|
+
*/
|
|
154
|
+
function normalizeRouteSegment(segment: string): string {
|
|
155
|
+
return SAFE_ROUTE_SEGMENT.test(segment) ? segment : ':id';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Removes query data and redacts dynamic-looking endpoint segments.
|
|
160
|
+
* @param resource SDK resource path.
|
|
161
|
+
* @returns The normalized endpoint.
|
|
162
|
+
*/
|
|
163
|
+
export function getOperation(resource?: string): string {
|
|
164
|
+
if (typeof resource !== 'string' || resource.length === 0) {
|
|
165
|
+
return UNKNOWN;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const path = resource.split(/[?#]/, 1)[0];
|
|
169
|
+
const segments = path.split('/').filter(Boolean).map(normalizeRouteSegment);
|
|
170
|
+
|
|
171
|
+
return segments.length > 0 ? segments.join('/') : 'root';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Extracts only the direct URL parts allowed in telemetry. URL credentials,
|
|
176
|
+
* query parameters, and fragments are not returned, while the pathname is
|
|
177
|
+
* converted to a sanitized endpoint.
|
|
178
|
+
* @param options Request options containing a direct URL.
|
|
179
|
+
* @returns The sanitized request identity, or undefined without a valid URL.
|
|
180
|
+
*/
|
|
181
|
+
function getDirectRequestIdentity(options: RequestOptions): RequestIdentity | undefined {
|
|
182
|
+
const value = options.uri || options.url;
|
|
183
|
+
|
|
184
|
+
if (typeof value !== 'string') {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const requestUrl = new URL(value);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
host: requestUrl.host.toLowerCase() || UNKNOWN,
|
|
193
|
+
endPoint: getOperation(requestUrl.pathname),
|
|
194
|
+
};
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Uses the logical service as the host because request:start may run before the
|
|
202
|
+
* service interceptor resolves a physical URL. Direct-URL requests use URL.host.
|
|
203
|
+
* @param options Request options used to resolve the identity.
|
|
204
|
+
* @returns The request host and endpoint.
|
|
205
|
+
*/
|
|
206
|
+
function getRequestIdentity(options: RequestOptions): RequestIdentity {
|
|
207
|
+
const directRequestIdentity = getDirectRequestIdentity(options);
|
|
208
|
+
const service = getService(options);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
host: service || directRequestIdentity?.host || UNKNOWN,
|
|
212
|
+
endPoint: options.resource
|
|
213
|
+
? getOperation(options.resource)
|
|
214
|
+
: directRequestIdentity?.endPoint || UNKNOWN,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Returns the measured network duration when both timing points are valid.
|
|
220
|
+
* @param options Request options containing network timing metadata.
|
|
221
|
+
* @returns The duration in milliseconds, or undefined when timing is unavailable.
|
|
222
|
+
*/
|
|
223
|
+
function getNetworkDuration(options: RequestOptions): number | undefined {
|
|
224
|
+
const start = options.$timings?.networkStart;
|
|
225
|
+
const end = options.$timings?.networkEnd;
|
|
226
|
+
|
|
227
|
+
return typeof start === 'number' &&
|
|
228
|
+
Number.isFinite(start) &&
|
|
229
|
+
typeof end === 'number' &&
|
|
230
|
+
Number.isFinite(end) &&
|
|
231
|
+
end >= start
|
|
232
|
+
? end - start
|
|
233
|
+
: undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Adds a completed request's network duration to its endpoint aggregate.
|
|
238
|
+
* @param metric Endpoint aggregate to update.
|
|
239
|
+
* @param options Request options containing network timing metadata.
|
|
240
|
+
* @returns Nothing.
|
|
241
|
+
*/
|
|
242
|
+
function recordNetworkDuration(metric: RequestMetricState, options: RequestOptions): void {
|
|
243
|
+
const duration = getNetworkDuration(options);
|
|
244
|
+
|
|
245
|
+
if (duration === undefined) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
metric.totalNetworkDurationMs += duration;
|
|
250
|
+
metric.networkDurationsMs.push(duration);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Returns a normalized uppercase HTTP method.
|
|
255
|
+
* @param method HTTP method from the request options.
|
|
256
|
+
* @returns The normalized method.
|
|
257
|
+
*/
|
|
258
|
+
function getMethod(method?: string): string {
|
|
259
|
+
return typeof method === 'string' && method.length > 0 ? method.toUpperCase() : 'UNKNOWN';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Status zero represents a failure for which no valid HTTP response was received.
|
|
264
|
+
* @param reason Request failure.
|
|
265
|
+
* @returns A valid HTTP status code, or zero when no response was received.
|
|
266
|
+
*/
|
|
267
|
+
function getStatusCode(reason: RequestFailure): number {
|
|
268
|
+
const statusCode = reason.statusCode ?? reason.status;
|
|
269
|
+
|
|
270
|
+
return typeof statusCode === 'number' &&
|
|
271
|
+
Number.isInteger(statusCode) &&
|
|
272
|
+
statusCode >= 100 &&
|
|
273
|
+
statusCode <= 599
|
|
274
|
+
? statusCode
|
|
275
|
+
: 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Returns an error nested in a Webex HTTP error body.
|
|
280
|
+
* @param reason Request failure.
|
|
281
|
+
* @returns The wrapped error, when present.
|
|
282
|
+
*/
|
|
283
|
+
function getWrappedError(reason: RequestFailure): ErrorDetails | undefined {
|
|
284
|
+
return reason.body && typeof reason.body === 'object' ? reason.body : undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Checks the timeout forms emitted by browser and Node transports.
|
|
289
|
+
* @param value Possible timeout signal.
|
|
290
|
+
* @returns Whether the value represents a timeout.
|
|
291
|
+
*/
|
|
292
|
+
function isTimeoutSignal(value: unknown): boolean {
|
|
293
|
+
if (typeof value !== 'string') {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const signal = value.toLowerCase();
|
|
298
|
+
|
|
299
|
+
return signal.includes('timeout') || signal === 'etimedout' || signal === 'esockettimedout';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Detects direct errors, transport errors wrapped in body, and requests whose
|
|
304
|
+
* measured network duration reached the configured timeout.
|
|
305
|
+
* @param reason Request failure.
|
|
306
|
+
* @param options Request options containing timeout metadata.
|
|
307
|
+
* @returns Whether the request timed out.
|
|
308
|
+
*/
|
|
309
|
+
function isTimeoutFailure(reason: RequestFailure, options: RequestOptions): boolean {
|
|
310
|
+
const wrappedError = getWrappedError(reason);
|
|
311
|
+
|
|
312
|
+
if (
|
|
313
|
+
isTimeoutSignal(reason.name) ||
|
|
314
|
+
isTimeoutSignal(reason.code) ||
|
|
315
|
+
isTimeoutSignal(reason.message) ||
|
|
316
|
+
isTimeoutSignal(reason.body) ||
|
|
317
|
+
isTimeoutSignal(wrappedError?.name) ||
|
|
318
|
+
isTimeoutSignal(wrappedError?.code) ||
|
|
319
|
+
isTimeoutSignal(wrappedError?.message)
|
|
320
|
+
) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const start = options.$timings?.networkStart;
|
|
325
|
+
const end = options.$timings?.networkEnd;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
typeof options.timeout === 'number' &&
|
|
329
|
+
options.timeout > 0 &&
|
|
330
|
+
typeof start === 'number' &&
|
|
331
|
+
typeof end === 'number' &&
|
|
332
|
+
end >= start &&
|
|
333
|
+
end - start >= options.timeout
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Converts errors into a bounded set of categories for aggregation.
|
|
339
|
+
* @param reason Request failure.
|
|
340
|
+
* @param options Request options.
|
|
341
|
+
* @param statusCode Normalized status code.
|
|
342
|
+
* @returns The aggregate error category.
|
|
343
|
+
*/
|
|
344
|
+
function getErrorType(reason: RequestFailure, options: RequestOptions, statusCode: number): string {
|
|
345
|
+
const name = typeof reason.name === 'string' ? reason.name.toLowerCase() : '';
|
|
346
|
+
|
|
347
|
+
if (name === 'aborterror') {
|
|
348
|
+
return 'aborted';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (statusCode === 408 || statusCode === 504 || isTimeoutFailure(reason, options)) {
|
|
352
|
+
return 'timeout';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (statusCode === 429) {
|
|
356
|
+
return 'rate_limited';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (statusCode >= 500) {
|
|
360
|
+
return 'server_error';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (statusCode >= 400) {
|
|
364
|
+
return 'client_error';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return statusCode === 0 || name?.includes('network') ? 'network_error' : 'request_error';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Returns the most specific available error code.
|
|
372
|
+
* @param reason Request failure.
|
|
373
|
+
* @returns The error code or the unknown fallback.
|
|
374
|
+
*/
|
|
375
|
+
function getErrorCode(reason: RequestFailure): string {
|
|
376
|
+
const wrappedError = getWrappedError(reason);
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
toString(wrappedError?.errorCode) ||
|
|
380
|
+
toString(wrappedError?.code) ||
|
|
381
|
+
toString(reason.errorCode) ||
|
|
382
|
+
toString(reason.code) ||
|
|
383
|
+
UNKNOWN
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Returns the most specific available error message.
|
|
389
|
+
* @param reason Request failure.
|
|
390
|
+
* @returns The error message or the unknown fallback.
|
|
391
|
+
*/
|
|
392
|
+
function getErrorMessage(reason: RequestFailure): string {
|
|
393
|
+
const wrappedError = getWrappedError(reason);
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
toString(wrappedError?.message) || toString(reason.body) || toString(reason.message) || UNKNOWN
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Excludes the transports used to submit telemetry, preventing self-reporting.
|
|
402
|
+
* @param options Request options.
|
|
403
|
+
* @returns Whether the request should be tracked.
|
|
404
|
+
*/
|
|
405
|
+
export function shouldTrackNetworkRequest(options: RequestOptions = {}): boolean {
|
|
406
|
+
const service = getService(options);
|
|
407
|
+
|
|
408
|
+
return service !== 'metrics' && service !== 'unifiedtelemetry';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Creates the error entry produced by one failed request.
|
|
413
|
+
* @param options Request options.
|
|
414
|
+
* @param reason Request failure.
|
|
415
|
+
* @returns The normalized error metric.
|
|
416
|
+
*/
|
|
417
|
+
export function createNetworkErrorMetric(
|
|
418
|
+
options: RequestOptions = {},
|
|
419
|
+
reason: RequestFailure = {}
|
|
420
|
+
): NetworkErrorMetric {
|
|
421
|
+
const identity = getRequestIdentity(options);
|
|
422
|
+
const statusCode = getStatusCode(reason);
|
|
423
|
+
// WebexTrackingIdInterceptor stores the request ID under this lowercase key.
|
|
424
|
+
const trackingId = toString(options.headers?.trackingid);
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
...identity,
|
|
428
|
+
statusCode,
|
|
429
|
+
errorCode: getErrorCode(reason),
|
|
430
|
+
method: getMethod(options.method),
|
|
431
|
+
errorType: getErrorType(reason, options, statusCode),
|
|
432
|
+
errorMessage: getErrorMessage(reason),
|
|
433
|
+
trackingIds: trackingId ? [trackingId] : [],
|
|
434
|
+
countError: 1,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Creates an empty ten-minute telemetry window.
|
|
440
|
+
* @returns An empty telemetry aggregate.
|
|
441
|
+
*/
|
|
442
|
+
function createEmptyNetworkTelemetry(): NetworkTelemetryState {
|
|
443
|
+
return {
|
|
444
|
+
metricsSummary: {
|
|
445
|
+
totalSendRequest: 0,
|
|
446
|
+
totalFailedRequest: 0,
|
|
447
|
+
totalRecvdResponse: 0,
|
|
448
|
+
totalFailedResponse: 0,
|
|
449
|
+
},
|
|
450
|
+
metrics: [],
|
|
451
|
+
errorMetrics: [],
|
|
452
|
+
errorMetricsSummary: {},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Finds or creates the request counters for one host and endpoint.
|
|
458
|
+
* @param telemetry Current telemetry aggregate.
|
|
459
|
+
* @param options Request options.
|
|
460
|
+
* @returns The matching endpoint counters.
|
|
461
|
+
*/
|
|
462
|
+
function getRequestMetric(
|
|
463
|
+
telemetry: NetworkTelemetryState,
|
|
464
|
+
options: RequestOptions
|
|
465
|
+
): RequestMetricState {
|
|
466
|
+
const identity = getRequestIdentity(options);
|
|
467
|
+
|
|
468
|
+
for (const metric of telemetry.metrics) {
|
|
469
|
+
if (metric.host === identity.host && metric.endPoint === identity.endPoint) {
|
|
470
|
+
return metric;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const metric = {
|
|
475
|
+
...identity,
|
|
476
|
+
countSendRequest: 0,
|
|
477
|
+
countFailedRequest: 0,
|
|
478
|
+
countRecvdResponse: 0,
|
|
479
|
+
countFailedResponse: 0,
|
|
480
|
+
totalNetworkDurationMs: 0,
|
|
481
|
+
networkDurationsMs: [],
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
telemetry.metrics.push(metric);
|
|
485
|
+
|
|
486
|
+
return metric;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Error entries aggregate only when every identifying field matches.
|
|
491
|
+
* @param first First error metric.
|
|
492
|
+
* @param second Second error metric.
|
|
493
|
+
* @returns Whether both errors belong to the same aggregate.
|
|
494
|
+
*/
|
|
495
|
+
function isSameError(first: NetworkErrorMetric, second: NetworkErrorMetric): boolean {
|
|
496
|
+
return (
|
|
497
|
+
first.host === second.host &&
|
|
498
|
+
first.endPoint === second.endPoint &&
|
|
499
|
+
first.statusCode === second.statusCode &&
|
|
500
|
+
first.errorCode === second.errorCode &&
|
|
501
|
+
first.method === second.method &&
|
|
502
|
+
first.errorType === second.errorType &&
|
|
503
|
+
first.errorMessage === second.errorMessage
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Adds a failure to its error aggregate and retains at most ten unique IDs.
|
|
509
|
+
* @param telemetry Current telemetry aggregate.
|
|
510
|
+
* @param errorMetric Error metric to record.
|
|
511
|
+
* @returns Nothing.
|
|
512
|
+
*/
|
|
513
|
+
function recordError(telemetry: NetworkTelemetryState, errorMetric: NetworkErrorMetric): void {
|
|
514
|
+
for (const existingMetric of telemetry.errorMetrics) {
|
|
515
|
+
if (isSameError(existingMetric, errorMetric)) {
|
|
516
|
+
existingMetric.countError += 1;
|
|
517
|
+
|
|
518
|
+
const trackingId = errorMetric.trackingIds[0];
|
|
519
|
+
|
|
520
|
+
if (
|
|
521
|
+
trackingId &&
|
|
522
|
+
existingMetric.trackingIds.length < MAX_TRACKING_IDS &&
|
|
523
|
+
!existingMetric.trackingIds.includes(trackingId)
|
|
524
|
+
) {
|
|
525
|
+
existingMetric.trackingIds.push(trackingId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
telemetry.errorMetrics.push(errorMetric);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Returns a nearest-rank percentile from the measured durations.
|
|
537
|
+
* @param durations Measured network durations in milliseconds.
|
|
538
|
+
* @param percentile Percentile to calculate, between zero and one.
|
|
539
|
+
* @returns The percentile duration, or zero when no durations were measured.
|
|
540
|
+
*/
|
|
541
|
+
function getDurationPercentile(durations: number[], percentile: number): number {
|
|
542
|
+
if (durations.length === 0) {
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const sortedDurations = [...durations].sort((first, second) => first - second);
|
|
547
|
+
const rank = Math.max(1, Math.ceil(sortedDurations.length * percentile));
|
|
548
|
+
|
|
549
|
+
return sortedDurations[rank - 1];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Converts internal duration samples into the public endpoint metric shape.
|
|
554
|
+
* @param metric Internal endpoint aggregate.
|
|
555
|
+
* @returns Endpoint aggregate with average and p90 duration statistics.
|
|
556
|
+
*/
|
|
557
|
+
function serializeRequestMetric(metric: RequestMetricState): RequestMetric {
|
|
558
|
+
const measuredRequestCount = metric.networkDurationsMs.length;
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
host: metric.host,
|
|
562
|
+
endPoint: metric.endPoint,
|
|
563
|
+
countSendRequest: metric.countSendRequest,
|
|
564
|
+
countFailedRequest: metric.countFailedRequest,
|
|
565
|
+
countRecvdResponse: metric.countRecvdResponse,
|
|
566
|
+
countFailedResponse: metric.countFailedResponse,
|
|
567
|
+
averageNetworkDurationMs:
|
|
568
|
+
measuredRequestCount > 0 ? metric.totalNetworkDurationMs / measuredRequestCount : 0,
|
|
569
|
+
maxNetworkDurationP90Ms: getDurationPercentile(metric.networkDurationsMs, 0.9),
|
|
570
|
+
maxNetworkDurationP99Ms: getDurationPercentile(metric.networkDurationsMs, 0.99),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Converts the internal telemetry state into the submitted payload shape.
|
|
576
|
+
* @param telemetry Internal telemetry aggregate.
|
|
577
|
+
* @returns Telemetry payload with no internal duration samples.
|
|
578
|
+
*/
|
|
579
|
+
function serializeNetworkTelemetry(telemetry: NetworkTelemetryState): NetworkTelemetry {
|
|
580
|
+
return {
|
|
581
|
+
metricsSummary: telemetry.metricsSummary,
|
|
582
|
+
metrics: telemetry.metrics.map(serializeRequestMetric),
|
|
583
|
+
errorMetrics: telemetry.errorMetrics,
|
|
584
|
+
errorMetricsSummary: telemetry.errorMetricsSummary,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Checks whether the current window contains anything worth flushing.
|
|
590
|
+
* @param telemetry Current telemetry aggregate.
|
|
591
|
+
* @returns Whether the aggregate contains request data.
|
|
592
|
+
*/
|
|
593
|
+
function hasNetworkTelemetry(telemetry: NetworkTelemetryState): boolean {
|
|
594
|
+
return telemetry.metrics.length > 0 || telemetry.errorMetrics.length > 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Creates a network telemetry collector and starts its periodic timer.
|
|
599
|
+
* @param options Submission callbacks.
|
|
600
|
+
* @returns The network telemetry collector.
|
|
601
|
+
*/
|
|
602
|
+
export function createNetworkTelemetryCollector({
|
|
603
|
+
intervalMs,
|
|
604
|
+
onSubmissionFailure,
|
|
605
|
+
submitMetric,
|
|
606
|
+
}: NetworkTelemetryCollectorOptions): NetworkTelemetryCollector {
|
|
607
|
+
let telemetry = createEmptyNetworkTelemetry();
|
|
608
|
+
const inFlightSubmissions = new Set<Promise<void>>();
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Tracks a submission until it settles so shutdown can wait for requests
|
|
612
|
+
* whose telemetry window has already been reset.
|
|
613
|
+
* @param submission Submission promise.
|
|
614
|
+
* @returns The tracked submission promise.
|
|
615
|
+
*/
|
|
616
|
+
function trackSubmission(submission: Promise<void>): Promise<void> {
|
|
617
|
+
inFlightSubmissions.add(submission);
|
|
618
|
+
submission.then(
|
|
619
|
+
() => inFlightSubmissions.delete(submission),
|
|
620
|
+
() => inFlightSubmissions.delete(submission)
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
return submission;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Records a request being sent.
|
|
628
|
+
* @param options Request options.
|
|
629
|
+
* @returns Nothing.
|
|
630
|
+
*/
|
|
631
|
+
function recordRequest(options: RequestOptions = {}): void {
|
|
632
|
+
if (!shouldTrackNetworkRequest(options)) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
telemetry.metricsSummary.totalSendRequest += 1;
|
|
637
|
+
getRequestMetric(telemetry, options).countSendRequest += 1;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Records a successful response.
|
|
642
|
+
* @param options Request options.
|
|
643
|
+
* @returns Nothing.
|
|
644
|
+
*/
|
|
645
|
+
function recordResponse(options: RequestOptions = {}): void {
|
|
646
|
+
if (!shouldTrackNetworkRequest(options)) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
telemetry.metricsSummary.totalRecvdResponse += 1;
|
|
651
|
+
const requestMetric = getRequestMetric(telemetry, options);
|
|
652
|
+
requestMetric.countRecvdResponse += 1;
|
|
653
|
+
recordNetworkDuration(requestMetric, options);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* A real HTTP status is a failed response. Status zero means the request
|
|
658
|
+
* failed before the SDK received a valid HTTP response.
|
|
659
|
+
* @param options Request options.
|
|
660
|
+
* @param reason Request failure.
|
|
661
|
+
* @returns Nothing.
|
|
662
|
+
*/
|
|
663
|
+
function recordFailure(options: RequestOptions = {}, reason: RequestFailure = {}): void {
|
|
664
|
+
if (!shouldTrackNetworkRequest(options)) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const errorMetric = createNetworkErrorMetric(options, reason);
|
|
669
|
+
const requestMetric = getRequestMetric(telemetry, options);
|
|
670
|
+
recordNetworkDuration(requestMetric, options);
|
|
671
|
+
|
|
672
|
+
if (errorMetric.statusCode === 0) {
|
|
673
|
+
telemetry.metricsSummary.totalFailedRequest += 1;
|
|
674
|
+
requestMetric.countFailedRequest += 1;
|
|
675
|
+
} else {
|
|
676
|
+
telemetry.metricsSummary.totalRecvdResponse += 1;
|
|
677
|
+
telemetry.metricsSummary.totalFailedResponse += 1;
|
|
678
|
+
requestMetric.countRecvdResponse += 1;
|
|
679
|
+
requestMetric.countFailedResponse += 1;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
recordError(telemetry, errorMetric);
|
|
683
|
+
|
|
684
|
+
const statusCode = String(errorMetric.statusCode);
|
|
685
|
+
const statusSummary = telemetry.errorMetricsSummary[statusCode];
|
|
686
|
+
|
|
687
|
+
if (statusSummary) {
|
|
688
|
+
statusSummary.count += 1;
|
|
689
|
+
} else {
|
|
690
|
+
telemetry.errorMetricsSummary[statusCode] = {count: 1};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Submits the completed window and starts collecting the next one.
|
|
696
|
+
* @returns A promise that settles after submission succeeds or is reported as failed.
|
|
697
|
+
*/
|
|
698
|
+
function submitSummary(): Promise<void> {
|
|
699
|
+
const completedTelemetry = telemetry;
|
|
700
|
+
const payload = serializeNetworkTelemetry(completedTelemetry);
|
|
701
|
+
|
|
702
|
+
// Reset before submission so requests emitted by the telemetry transport,
|
|
703
|
+
// or while it is pending, cannot mutate the completed window.
|
|
704
|
+
telemetry = createEmptyNetworkTelemetry();
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
const submission = Promise.resolve(
|
|
708
|
+
submitMetric(NETWORK_REQUEST_SUMMARY_METRIC, {
|
|
709
|
+
type: 'operational',
|
|
710
|
+
tags: {},
|
|
711
|
+
fields: completedTelemetry.metricsSummary,
|
|
712
|
+
eventPayload: payload,
|
|
713
|
+
})
|
|
714
|
+
).then(
|
|
715
|
+
() => undefined,
|
|
716
|
+
() => {
|
|
717
|
+
onSubmissionFailure();
|
|
718
|
+
}
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
return trackSubmission(submission);
|
|
722
|
+
} catch {
|
|
723
|
+
// submitMetric may throw before returning a promise.
|
|
724
|
+
onSubmissionFailure();
|
|
725
|
+
|
|
726
|
+
return Promise.resolve();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const telemetryIntervalMs =
|
|
731
|
+
typeof intervalMs === 'number' && Number.isFinite(intervalMs) && intervalMs > 0
|
|
732
|
+
? intervalMs
|
|
733
|
+
: NETWORK_TELEMETRY_INTERVAL_MS;
|
|
734
|
+
const telemetryInterval = safeSetInterval(submitSummary, telemetryIntervalMs);
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Submits the current non-empty window immediately.
|
|
738
|
+
* @returns A promise that settles after submission succeeds or is reported as failed.
|
|
739
|
+
*/
|
|
740
|
+
function flush(): Promise<void> {
|
|
741
|
+
return hasNetworkTelemetry(telemetry) ? submitSummary() : Promise.resolve();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Stops periodic submission and releases data from the current window.
|
|
746
|
+
* @returns A promise that settles after the final window is submitted or failure is reported.
|
|
747
|
+
*/
|
|
748
|
+
function stop(): Promise<void> {
|
|
749
|
+
clearInterval(telemetryInterval);
|
|
750
|
+
|
|
751
|
+
flush();
|
|
752
|
+
|
|
753
|
+
return Promise.all(inFlightSubmissions).then(() => undefined);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return {recordRequest, recordResponse, recordFailure, flush, stop};
|
|
757
|
+
}
|