@vpalmisano/webrtcperf 4.0.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.
Files changed (53) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +296 -0
  3. package/app.min.js +2 -0
  4. package/build/src/app.d.ts +6 -0
  5. package/build/src/app.js +207 -0
  6. package/build/src/app.js.map +1 -0
  7. package/build/src/config.d.ts +104 -0
  8. package/build/src/config.js +880 -0
  9. package/build/src/config.js.map +1 -0
  10. package/build/src/generate-config-docs.d.ts +1 -0
  11. package/build/src/generate-config-docs.js +41 -0
  12. package/build/src/generate-config-docs.js.map +1 -0
  13. package/build/src/index.d.ts +9 -0
  14. package/build/src/index.js +26 -0
  15. package/build/src/index.js.map +1 -0
  16. package/build/src/media.d.ts +33 -0
  17. package/build/src/media.js +113 -0
  18. package/build/src/media.js.map +1 -0
  19. package/build/src/rtcstats.d.ts +302 -0
  20. package/build/src/rtcstats.js +418 -0
  21. package/build/src/rtcstats.js.map +1 -0
  22. package/build/src/server.d.ts +173 -0
  23. package/build/src/server.js +639 -0
  24. package/build/src/server.js.map +1 -0
  25. package/build/src/session.d.ts +277 -0
  26. package/build/src/session.js +1552 -0
  27. package/build/src/session.js.map +1 -0
  28. package/build/src/stats.d.ts +243 -0
  29. package/build/src/stats.js +1383 -0
  30. package/build/src/stats.js.map +1 -0
  31. package/build/src/utils.d.ts +249 -0
  32. package/build/src/utils.js +1220 -0
  33. package/build/src/utils.js.map +1 -0
  34. package/build/src/visqol.d.ts +6 -0
  35. package/build/src/visqol.js +61 -0
  36. package/build/src/visqol.js.map +1 -0
  37. package/build/src/vmaf.d.ts +83 -0
  38. package/build/src/vmaf.js +624 -0
  39. package/build/src/vmaf.js.map +1 -0
  40. package/build/tsconfig.tsbuildinfo +1 -0
  41. package/package.json +129 -0
  42. package/src/app.ts +241 -0
  43. package/src/config.ts +852 -0
  44. package/src/generate-config-docs.ts +47 -0
  45. package/src/index.ts +9 -0
  46. package/src/media.ts +151 -0
  47. package/src/rtcstats.ts +507 -0
  48. package/src/server.ts +645 -0
  49. package/src/session.ts +1908 -0
  50. package/src/stats.ts +1668 -0
  51. package/src/utils.ts +1295 -0
  52. package/src/visqol.ts +62 -0
  53. package/src/vmaf.ts +771 -0
package/src/stats.ts ADDED
@@ -0,0 +1,1668 @@
1
+ import axios from 'axios'
2
+ import chalk from 'chalk'
3
+ import * as events from 'events'
4
+ import { Stats as FastStats } from 'fast-stats'
5
+ import * as fs from 'fs'
6
+ import * as http from 'http'
7
+ import * as https from 'https'
8
+ import json5 from 'json5'
9
+ import * as path from 'path'
10
+ import * as promClient from 'prom-client'
11
+ import { PrometheusContentType } from 'prom-client'
12
+ import { sprintf } from 'sprintf-js'
13
+ import * as zlib from 'zlib'
14
+
15
+ import { PageStatsNames, RtcStatsMetricNames, parseRtStatKey } from './rtcstats'
16
+ import { Session } from './session'
17
+ import { Scheduler, enabledForSession, hideAuth, logger, toPrecision } from './utils'
18
+
19
+ export { FastStats }
20
+
21
+ const log = logger('webrtcperf:stats')
22
+
23
+ function calculateFailAmountPercentile(stat: FastStats, percentile = 95): number {
24
+ return Math.round(stat.percentile(percentile))
25
+ }
26
+
27
+ /**
28
+ * StatsWriter
29
+ */
30
+ class StatsWriter {
31
+ fname: string
32
+ columns: string[]
33
+ private _header_written = false
34
+
35
+ constructor(fname = 'stats.log', columns: string[]) {
36
+ this.fname = fname
37
+ this.columns = columns
38
+ }
39
+
40
+ /**
41
+ * push
42
+ * @param dataColumns
43
+ */
44
+ async push(dataColumns: string[]): Promise<void> {
45
+ if (!this._header_written) {
46
+ const data = ['datetime', ...this.columns].join(',') + '\n'
47
+ await fs.promises.mkdir(path.dirname(this.fname), { recursive: true })
48
+ await fs.promises.writeFile(this.fname, data)
49
+ this._header_written = true
50
+ }
51
+ //
52
+ const data = [Date.now(), ...dataColumns].join(',') + '\n'
53
+ return fs.promises.appendFile(this.fname, data)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * formatStatsColumns
59
+ * @param column
60
+ */
61
+ function formatStatsColumns(column: string): string[] {
62
+ return [
63
+ `${column}_length`,
64
+ `${column}_sum`,
65
+ `${column}_mean`,
66
+ `${column}_stdev`,
67
+ `${column}_5p`,
68
+ `${column}_95p`,
69
+ `${column}_min`,
70
+ `${column}_max`,
71
+ ]
72
+ }
73
+
74
+ /** The Stats data collected for each metric. */
75
+ interface StatsData {
76
+ /** The total samples collected. */
77
+ length: number
78
+ /** The sum of all the samples. */
79
+ sum: number
80
+ /** The average value. */
81
+ mean: number
82
+ /** The standard deviation. */
83
+ stddev: number
84
+ /** The 5th percentile. */
85
+ p5: number
86
+ /** The 95th percentile. */
87
+ p95: number
88
+ /** The minimum value. */
89
+ min: number
90
+ /** The maximum value. */
91
+ max: number
92
+ }
93
+
94
+ type StatsDataKey = keyof StatsData
95
+
96
+ export interface CollectedStats {
97
+ all: FastStats
98
+ byHost: Record<string, FastStats>
99
+ byCodec: Record<string, FastStats>
100
+ byParticipantAndTrack: Record<string, number>
101
+ }
102
+
103
+ export interface CollectedStatsRaw {
104
+ all: number[]
105
+ byHost: Record<string, number[]>
106
+ byCodec: Record<string, number[]>
107
+ byParticipantAndTrack: Record<string, number>
108
+ }
109
+
110
+ /**
111
+ * Formats the stats for console or for file output.
112
+ * @param s The stats object.
113
+ * @param forWriter If true, format the stats to be written on file.
114
+ */
115
+ function formatStats(s: FastStats, forWriter = false): StatsData | string[] {
116
+ if (forWriter) {
117
+ return [
118
+ toPrecision(s.length || 0, 0),
119
+ toPrecision(s.sum || 0),
120
+ toPrecision(s.amean() || 0),
121
+ toPrecision(s.stddev() || 0),
122
+ toPrecision(s.percentile(5) || 0),
123
+ toPrecision(s.percentile(95) || 0),
124
+ toPrecision(s.min || 0),
125
+ toPrecision(s.max || 0),
126
+ ]
127
+ }
128
+ return {
129
+ length: s.length || 0,
130
+ sum: s.sum || 0,
131
+ mean: s.amean() || 0,
132
+ stddev: s.stddev() || 0,
133
+ p5: s.percentile(5) || 0,
134
+ p95: s.percentile(95) || 0,
135
+ min: s.min || 0,
136
+ max: s.max || 0,
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Formats the console stats title.
142
+ * @param name
143
+ */
144
+ function sprintfStatsTitle(name: string): string {
145
+ return sprintf(chalk`-- {bold %(name)s} %(fill)s\n`, {
146
+ name,
147
+ fill: '-'.repeat(100 - name.length - 4),
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Formats the console stats header.
153
+ */
154
+ function sprintfStatsHeader(): string {
155
+ return (
156
+ sprintfStatsTitle(new Date().toUTCString()) +
157
+ sprintf(
158
+ chalk`{bold %(name)\' 30s} {bold %(length)\' 8s} {bold %(sum)\' 8s} {bold %(mean)\' 8s} {bold %(stddev)\' 8s} {bold %(p5)\' 8s} {bold %(p95)\' 8s} {bold %(min)\' 8s} {bold %(max)\' 8s}\n`,
159
+ {
160
+ name: 'name',
161
+ length: 'count',
162
+ sum: 'sum',
163
+ mean: 'mean',
164
+ stddev: 'stddev',
165
+ p5: '5p',
166
+ p95: '95p',
167
+ min: 'min',
168
+ max: 'max',
169
+ },
170
+ )
171
+ )
172
+ }
173
+
174
+ /**
175
+ * Format the stats for console output.
176
+ */
177
+ function sprintfStats(
178
+ name: string,
179
+ stats: CollectedStats,
180
+ format = '.2f',
181
+ unit = '',
182
+ scale = 1,
183
+ hideSum = false,
184
+ ): string {
185
+ if (!stats?.all.length) {
186
+ return ''
187
+ }
188
+ if (!scale) {
189
+ scale = 1
190
+ }
191
+ const statsData = formatStats(stats.all) as StatsData
192
+ return sprintf(
193
+ chalk`{red {bold %(name)\' 30s}}` +
194
+ chalk` {bold %(length)\' 8d}` +
195
+ (hideSum ? ' ' : chalk` {bold %(sum)\' 8${format}}`) +
196
+ chalk` {bold %(mean)\' 8${format}}` +
197
+ chalk` {bold %(stddev)\' 8${format}}` +
198
+ chalk` {bold %(p5)\' 8${format}}` +
199
+ chalk` {bold %(p95)\' 8${format}}` +
200
+ chalk` {bold %(min)\' 8${format}}` +
201
+ chalk` {bold %(max)\' 8${format}}%(unit)s\n`,
202
+ {
203
+ name,
204
+ length: statsData.length,
205
+ sum: statsData.sum * scale,
206
+ mean: statsData.mean * scale,
207
+ stddev: statsData.stddev * scale,
208
+ p5: statsData.p5 * scale,
209
+ p95: statsData.p95 * scale,
210
+ min: statsData.min * scale,
211
+ max: statsData.max * scale,
212
+ unit: unit ? chalk` {red {bold ${unit}}}` : '',
213
+ },
214
+ )
215
+ }
216
+
217
+ const promPrefix = 'wst_'
218
+
219
+ const promCreateGauge = (
220
+ register: promClient.Registry,
221
+ name: string,
222
+ suffix = '',
223
+ labelNames: string[] = [],
224
+ collect?: () => void,
225
+ ): promClient.Gauge<string> => {
226
+ return new promClient.Gauge({
227
+ name: `${promPrefix}${name}${suffix && '_' + suffix}`,
228
+ help: `${name} ${suffix}`,
229
+ labelNames,
230
+ registers: [register],
231
+ collect,
232
+ })
233
+ }
234
+
235
+ /**
236
+ * The alert rule description.
237
+ *
238
+ * Example:
239
+ * ```
240
+ cpu:
241
+ tags:
242
+ - performance
243
+ failPercentile: 90
244
+ p95:
245
+ $gt: 10
246
+ $lt: 100
247
+ $after: 60
248
+ * ```
249
+ * It will check if the `cpu` 95th percentile is lower than 100% and greater than 10%,
250
+ * starting the check after 60s from the test start. The alert results will be
251
+ * grouped into the `performance` category.
252
+ */
253
+ export type AlertRule = AlertRuleOption & AlertRuleKey
254
+
255
+ /**
256
+ * The alert rule options.
257
+ */
258
+ export interface AlertRuleOption {
259
+ /** The alert results will be grouped into the specified categories. */
260
+ tags: string[]
261
+ /** The alert will pass when at least `failPercentile` of the checks (95 by default) are successful. */
262
+ failPercentile?: number
263
+ }
264
+
265
+ /**
266
+ * The supported alert rule checks.
267
+ */
268
+ export interface AlertRuleKey {
269
+ /** The total collected samples. */
270
+ length?: AlertRuleValue | AlertRuleValue[]
271
+ /** The sum of the collected samples. */
272
+ sum?: AlertRuleValue | AlertRuleValue[]
273
+ /** The 95th percentile of the collected samples. */
274
+ p95?: AlertRuleValue | AlertRuleValue[]
275
+ /** The 5th percentile of the collected samples. */
276
+ p5?: AlertRuleValue | AlertRuleValue[]
277
+ /** The minimum of the collected samples. */
278
+ min?: AlertRuleValue | AlertRuleValue[]
279
+ /** The maximum of the collected samples. */
280
+ max?: AlertRuleValue | AlertRuleValue[]
281
+ }
282
+
283
+ /**
284
+ * The alert check operators.
285
+ */
286
+ export interface AlertRuleValue {
287
+ $eq?: number
288
+ $gt?: number
289
+ $lt?: number
290
+ $gte?: number
291
+ $lte?: number
292
+ $after?: number
293
+ $before?: number
294
+ $skip_lt?: number
295
+ $skip_lte?: number
296
+ $skip_gt?: number
297
+ $skip_gte?: number
298
+ }
299
+
300
+ const calculateFailAmount = (checkValue: number, ruleValue: number): number => {
301
+ if (ruleValue) {
302
+ return 100 * Math.min(1, Math.abs(checkValue - ruleValue) / ruleValue)
303
+ } else {
304
+ return 100 * Math.min(1, Math.abs(checkValue))
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The Stats collector class.
310
+ */
311
+ export class Stats extends events.EventEmitter {
312
+ readonly statsPath: string
313
+ readonly detailedStatsPath: string
314
+ readonly prometheusPushgateway: string
315
+ readonly prometheusPushgatewayJobName: string
316
+ readonly prometheusPushgatewayAuth?: string
317
+ readonly prometheusPushgatewayGzip?: boolean
318
+ readonly showStats: boolean
319
+ readonly showPageLog: boolean
320
+ readonly statsInterval: number
321
+ readonly rtcStatsTimeout: number
322
+ readonly customMetrics: Record<string, { labels?: string[] }> = {}
323
+ readonly startTimestamp: number
324
+ readonly enableDetailedStats: boolean | string | number
325
+ private readonly startTimestampString: string
326
+
327
+ readonly sessions = new Map<number, Session>()
328
+ nextSessionId: number
329
+ statsWriter: StatsWriter | null
330
+ detailedStatsWriter: StatsWriter | null
331
+ private scheduler?: Scheduler
332
+
333
+ private alertRules: Record<string, AlertRule> | null = null
334
+ readonly alertRulesFilename: string
335
+ private readonly alertRulesFailPercentile: number
336
+ private readonly pushStatsUrl: string
337
+ private readonly pushStatsId: string
338
+ private readonly serverSecret: string
339
+
340
+ private readonly alertRulesReport = new Map<
341
+ string,
342
+ Map<
343
+ string,
344
+ {
345
+ totalFails: number
346
+ totalFailsTime: number
347
+ totalFailsTimePerc: number
348
+ lastFailed: number
349
+ valueStats: FastStats
350
+ failAmountStats: FastStats
351
+ failAmountPercentile: number
352
+ }
353
+ >
354
+ >()
355
+ private gateway: promClient.Pushgateway<PrometheusContentType> | null = null
356
+
357
+ /* metricConfigGauge: promClient.Gauge<string> | null = null */
358
+ private elapsedTimeMetric: promClient.Gauge<string> | null = null
359
+ private metrics: Record<
360
+ string,
361
+ {
362
+ length: promClient.Gauge<string>
363
+ sum: promClient.Gauge<string>
364
+ mean: promClient.Gauge<string>
365
+ stddev: promClient.Gauge<string>
366
+ p5: promClient.Gauge<string>
367
+ p95: promClient.Gauge<string>
368
+ min: promClient.Gauge<string>
369
+ max: promClient.Gauge<string>
370
+ value?: promClient.Gauge<string>
371
+ alertRules: Record<
372
+ string,
373
+ {
374
+ report: promClient.Gauge<string>
375
+ rule: promClient.Gauge<string>
376
+ mean: promClient.Gauge<string>
377
+ }
378
+ >
379
+ }
380
+ > = {}
381
+
382
+ private alertTagsMetrics?: promClient.Gauge<string>
383
+ private readonly customMetricsLabels: Record<string, string | undefined>
384
+
385
+ collectedStats: Record<string, CollectedStats>
386
+
387
+ collectedStatsConfig = {
388
+ url: '',
389
+ pages: 0,
390
+ startTime: 0,
391
+ }
392
+ externalCollectedStats = new Map<
393
+ string,
394
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
395
+ { addedTime: number; externalStats: any; config: any }
396
+ >()
397
+ pushStatsInstance: axios.AxiosInstance | null = null
398
+
399
+ private running = false
400
+
401
+ /**
402
+ * Stats aggregator class.
403
+ */
404
+ constructor({
405
+ statsPath,
406
+ detailedStatsPath,
407
+ prometheusPushgateway,
408
+ prometheusPushgatewayJobName,
409
+ prometheusPushgatewayAuth,
410
+ prometheusPushgatewayGzip,
411
+ showStats,
412
+ showPageLog,
413
+ statsInterval,
414
+ rtcStatsTimeout,
415
+ customMetrics,
416
+ alertRules,
417
+ alertRulesFilename,
418
+ alertRulesFailPercentile,
419
+ pushStatsUrl,
420
+ pushStatsId,
421
+ serverSecret,
422
+ startSessionId,
423
+ startTimestamp,
424
+ enableDetailedStats,
425
+ customMetricsLabels,
426
+ }: {
427
+ statsPath: string
428
+ detailedStatsPath: string
429
+ prometheusPushgateway: string
430
+ prometheusPushgatewayJobName: string
431
+ prometheusPushgatewayAuth: string
432
+ prometheusPushgatewayGzip: boolean
433
+ showStats: boolean
434
+ showPageLog: boolean
435
+ statsInterval: number
436
+ rtcStatsTimeout: number
437
+ customMetrics: string
438
+ alertRules: string
439
+ alertRulesFilename: string
440
+ alertRulesFailPercentile: number
441
+ pushStatsUrl: string
442
+ pushStatsId: string
443
+ serverSecret: string
444
+ startSessionId: number
445
+ startTimestamp: number
446
+ enableDetailedStats: boolean | string | number
447
+ customMetricsLabels?: string
448
+ }) {
449
+ super()
450
+ this.statsPath = statsPath
451
+ this.detailedStatsPath = detailedStatsPath
452
+ this.prometheusPushgateway = prometheusPushgateway
453
+ this.prometheusPushgatewayJobName = prometheusPushgatewayJobName || 'default'
454
+ this.prometheusPushgatewayAuth = prometheusPushgatewayAuth || undefined
455
+ this.prometheusPushgatewayGzip = prometheusPushgatewayGzip
456
+ this.showStats = showStats !== undefined ? showStats : true
457
+ this.showPageLog = !!showPageLog
458
+ this.statsInterval = statsInterval || 10
459
+ this.rtcStatsTimeout = Math.max(rtcStatsTimeout, this.statsInterval)
460
+ if (customMetrics.trim()) {
461
+ this.customMetrics = json5.parse(customMetrics)
462
+ log.debug(`using customMetrics: ${JSON.stringify(this.customMetrics, undefined, 2)}`)
463
+ }
464
+
465
+ this.collectedStats = this.initCollectedStats()
466
+ this.sessions = new Map()
467
+ this.nextSessionId = startSessionId
468
+ this.startTimestamp = startTimestamp || Date.now()
469
+ this.startTimestampString = new Date(this.startTimestamp).toISOString()
470
+ this.enableDetailedStats = enableDetailedStats
471
+ this.customMetricsLabels = customMetricsLabels
472
+ ? customMetricsLabels.split(',').reduce(
473
+ (p, label) => {
474
+ label = label.trim()
475
+ if (label) {
476
+ p[label] = undefined
477
+ }
478
+ return p
479
+ },
480
+ {} as typeof this.customMetricsLabels,
481
+ )
482
+ : {}
483
+
484
+ this.statsWriter = null
485
+ this.detailedStatsWriter = null
486
+ if (alertRules.trim()) {
487
+ this.alertRules = json5.parse(alertRules)
488
+ log.debug(`using alertRules: ${JSON.stringify(this.alertRules, undefined, 2)}`)
489
+ }
490
+ this.alertRulesFilename = alertRulesFilename
491
+ this.alertRulesFailPercentile = alertRulesFailPercentile
492
+ this.pushStatsUrl = pushStatsUrl
493
+ this.pushStatsId = pushStatsId
494
+ this.serverSecret = serverSecret
495
+
496
+ if (this.pushStatsUrl) {
497
+ const httpAgent = new http.Agent({ keepAlive: false })
498
+ const httpsAgent = new https.Agent({
499
+ keepAlive: false,
500
+ rejectUnauthorized: false,
501
+ })
502
+ this.pushStatsInstance = axios.create({
503
+ httpAgent,
504
+ httpsAgent,
505
+ baseURL: this.pushStatsUrl,
506
+ auth: {
507
+ username: 'admin',
508
+ password: this.serverSecret,
509
+ },
510
+ maxBodyLength: 20000000,
511
+ transformRequest: [
512
+ ...(axios.defaults.transformRequest as axios.AxiosRequestTransformer[]),
513
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
514
+ (data: any, headers?: axios.AxiosRequestHeaders): any => {
515
+ if (headers && typeof data === 'string' && data.length > 16 * 1024) {
516
+ headers['Content-Encoding'] = 'gzip'
517
+ return zlib.gzipSync(data)
518
+ } else {
519
+ return data
520
+ }
521
+ },
522
+ ],
523
+ })
524
+ }
525
+ }
526
+
527
+ private initCollectedStats(): Record<string, CollectedStats> {
528
+ return this.statsNames.reduce(
529
+ (prev, name: string) => {
530
+ prev[name] = {
531
+ all: new FastStats(),
532
+ byHost: {},
533
+ byCodec: {},
534
+ byParticipantAndTrack: {},
535
+ } as CollectedStats
536
+ return prev
537
+ },
538
+ {} as Record<string, CollectedStats>,
539
+ )
540
+ }
541
+
542
+ private get statsNames(): string[] {
543
+ return Object.keys(PageStatsNames).concat(Object.keys(RtcStatsMetricNames)).concat(Object.keys(this.customMetrics))
544
+ }
545
+
546
+ /**
547
+ * consumeSessionId
548
+ * @param tabs the number of tabs to allocate in the same session.
549
+ */
550
+ consumeSessionId(tabs = 1): number {
551
+ const id = this.nextSessionId
552
+ this.nextSessionId += tabs
553
+ return id
554
+ }
555
+
556
+ /**
557
+ * Adds the session to the list of monitored sessions.
558
+ */
559
+ addSession(session: Session): void {
560
+ log.debug(`addSession ${session.id}`)
561
+ if (this.sessions.has(session.id)) {
562
+ throw new Error(`session id ${session.id} already present`)
563
+ }
564
+ session.once('stop', id => {
565
+ log.debug(`Session ${id} stopped`)
566
+ this.sessions.delete(id)
567
+ })
568
+ this.sessions.set(session.id, session)
569
+ }
570
+
571
+ /**
572
+ * Removes the session from list of monitored sessions.
573
+ * @param id the Session id
574
+ */
575
+ removeSession(id: number): void {
576
+ log.debug(`removeSession ${id}`)
577
+ this.sessions.delete(id)
578
+ }
579
+
580
+ /**
581
+ * It updates the custom label value.
582
+ * @param label the custom metric label
583
+ * @param value the custom metric label value
584
+ */
585
+ setCustomMetricLabel(label: string, value: string | undefined): void {
586
+ if (!(label in this.customMetricsLabels)) {
587
+ throw new Error(`Unknown custom metric label: ${label}`)
588
+ }
589
+ this.customMetricsLabels[label] = value
590
+ }
591
+
592
+ /**
593
+ * start
594
+ */
595
+ async start(): Promise<void> {
596
+ if (this.running) {
597
+ log.warn('already running')
598
+ return
599
+ }
600
+ log.debug('start')
601
+ this.running = true
602
+
603
+ if (this.statsPath) {
604
+ log.debug(`Logging stats into ${this.statsPath}`)
605
+ const headers = this.statsNames.reduce((v: string[], name) => v.concat(formatStatsColumns(name)), [])
606
+ this.statsWriter = new StatsWriter(this.statsPath, headers)
607
+ }
608
+
609
+ if (this.detailedStatsPath) {
610
+ log.debug(`Logging stats into ${this.statsPath}`)
611
+ this.detailedStatsWriter = new StatsWriter(this.detailedStatsPath, [
612
+ 'participantName',
613
+ 'trackId',
614
+ ...this.statsNames,
615
+ ])
616
+ }
617
+
618
+ if (this.prometheusPushgateway) {
619
+ const register = new promClient.Registry()
620
+ const agent = this.prometheusPushgateway.startsWith('https://')
621
+ ? new https.Agent({
622
+ keepAlive: true,
623
+ keepAliveMsecs: 60000,
624
+ maxSockets: 5,
625
+ })
626
+ : new http.Agent({
627
+ keepAlive: true,
628
+ keepAliveMsecs: 60000,
629
+ maxSockets: 5,
630
+ })
631
+ this.gateway = new promClient.Pushgateway(
632
+ this.prometheusPushgateway,
633
+ {
634
+ timeout: 5000,
635
+ auth: this.prometheusPushgatewayAuth,
636
+ rejectUnauthorized: false,
637
+ agent,
638
+ headers: this.prometheusPushgatewayGzip
639
+ ? {
640
+ 'Content-Encoding': 'gzip',
641
+ }
642
+ : undefined,
643
+ },
644
+ register,
645
+ )
646
+
647
+ // promClient.collectDefaultMetrics({ prefix: promPrefix, register })
648
+
649
+ this.elapsedTimeMetric = promCreateGauge(
650
+ register,
651
+ 'elapsedTime',
652
+ '',
653
+ ['datetime', ...Object.keys(this.customMetricsLabels)],
654
+ () =>
655
+ this.elapsedTimeMetric?.set(
656
+ {
657
+ datetime: this.startTimestampString,
658
+ ...this.customMetricsLabels,
659
+ },
660
+ (Date.now() - this.startTimestamp) / 1000,
661
+ ),
662
+ )
663
+
664
+ // Export rtc stats.
665
+ this.statsNames.forEach(name => {
666
+ this.metrics[name] = {
667
+ length: promCreateGauge(register, name, 'length', [
668
+ 'host',
669
+ 'codec',
670
+ 'datetime',
671
+ ...Object.keys(this.customMetricsLabels),
672
+ ]),
673
+ sum: promCreateGauge(register, name, 'sum', [
674
+ 'host',
675
+ 'codec',
676
+ 'datetime',
677
+ ...Object.keys(this.customMetricsLabels),
678
+ ]),
679
+ mean: promCreateGauge(register, name, 'mean', [
680
+ 'host',
681
+ 'codec',
682
+ 'datetime',
683
+ ...Object.keys(this.customMetricsLabels),
684
+ ]),
685
+ stddev: promCreateGauge(register, name, 'stddev', [
686
+ 'host',
687
+ 'codec',
688
+ 'datetime',
689
+ ...Object.keys(this.customMetricsLabels),
690
+ ]),
691
+ p5: promCreateGauge(register, name, 'p5', [
692
+ 'host',
693
+ 'codec',
694
+ 'datetime',
695
+ ...Object.keys(this.customMetricsLabels),
696
+ ]),
697
+ p95: promCreateGauge(register, name, 'p95', [
698
+ 'host',
699
+ 'codec',
700
+ 'datetime',
701
+ ...Object.keys(this.customMetricsLabels),
702
+ ]),
703
+ min: promCreateGauge(register, name, 'min', [
704
+ 'host',
705
+ 'codec',
706
+ 'datetime',
707
+ ...Object.keys(this.customMetricsLabels),
708
+ ]),
709
+ max: promCreateGauge(register, name, 'max', [
710
+ 'host',
711
+ 'codec',
712
+ 'datetime',
713
+ ...Object.keys(this.customMetricsLabels),
714
+ ]),
715
+ alertRules: {},
716
+ }
717
+
718
+ if (this.enableDetailedStats !== false) {
719
+ this.metrics[name].value = promCreateGauge(register, name, '', [
720
+ 'participantName',
721
+ 'trackId',
722
+ 'datetime',
723
+ ...Object.keys(this.customMetricsLabels),
724
+ ])
725
+ }
726
+
727
+ if (this.alertRules && this.alertRules[name]) {
728
+ const rule = this.alertRules[name]
729
+ for (const ruleKey of Object.keys(rule)) {
730
+ const ruleName = `alert_${name}_${ruleKey}`
731
+ this.metrics[name].alertRules[ruleName] = {
732
+ report: promCreateGauge(register, ruleName, 'report', [
733
+ 'rule',
734
+ 'datetime',
735
+ ...Object.keys(this.customMetricsLabels),
736
+ ]),
737
+ rule: promCreateGauge(register, ruleName, '', [
738
+ 'rule',
739
+ 'datetime',
740
+ ...Object.keys(this.customMetricsLabels),
741
+ ]),
742
+ mean: promCreateGauge(register, ruleName, 'mean', [
743
+ 'rule',
744
+ 'datetime',
745
+ ...Object.keys(this.customMetricsLabels),
746
+ ]),
747
+ }
748
+ }
749
+ }
750
+ })
751
+
752
+ if (this.alertRules) {
753
+ this.alertTagsMetrics = promCreateGauge(register, `alert_report`, '', [
754
+ 'datetime',
755
+ 'tag',
756
+ ...Object.keys(this.customMetricsLabels),
757
+ ])
758
+ }
759
+
760
+ await this.deletePushgatewayStats()
761
+ }
762
+
763
+ this.scheduler = new Scheduler('stats', this.statsInterval, this.collectStats.bind(this))
764
+ this.scheduler.start()
765
+ }
766
+
767
+ async deletePushgatewayStats(): Promise<void> {
768
+ if (!this.gateway) {
769
+ return
770
+ }
771
+ try {
772
+ const { resp, body } = await this.gateway.delete({
773
+ jobName: this.prometheusPushgatewayJobName,
774
+ })
775
+ if ((body as string).length) {
776
+ log.warn(`Pushgateway delete error ${(resp as http.ServerResponse).statusCode}: ${body as string}`)
777
+ }
778
+ } catch (err) {
779
+ log.error(`Pushgateway delete error: ${(err as Error).stack}`)
780
+ }
781
+ }
782
+
783
+ /**
784
+ * collectStats
785
+ */
786
+ async collectStats(now: number): Promise<void> {
787
+ if (!this.running) {
788
+ return
789
+ }
790
+ // log.debug(`statsInterval ${this.sessions.size} sessions`);
791
+ if (!this.sessions.size && !this.externalCollectedStats.size) {
792
+ return
793
+ }
794
+ // Prepare config.
795
+ this.collectedStatsConfig.pages = 0
796
+ this.collectedStatsConfig.startTime = this.startTimestamp
797
+ // Reset collectedStats object.
798
+ Object.values(this.collectedStats).forEach(stats => {
799
+ stats.all.reset()
800
+ Object.values(stats.byHost).forEach(s => s.reset())
801
+ Object.values(stats.byCodec).forEach(s => s.reset())
802
+ stats.byParticipantAndTrack = {}
803
+ })
804
+ for (const [sessionId, session] of this.sessions.entries()) {
805
+ this.collectedStatsConfig.url = `${hideAuth(session.url)}?${session.urlQuery}`
806
+ this.collectedStatsConfig.pages += session.pages.size || 0
807
+ const sessionStats = await session.updateStats()
808
+ for (const [name, obj] of Object.entries(sessionStats)) {
809
+ if (obj === undefined) {
810
+ return
811
+ }
812
+ //log.log(name, obj)
813
+ try {
814
+ const collectedStats = this.collectedStats[name]
815
+ if (typeof obj === 'number' && isFinite(obj)) {
816
+ collectedStats.all.push(obj)
817
+ } else {
818
+ for (const [key, value] of Object.entries(obj)) {
819
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
820
+ if (typeof value === 'number' && isFinite(value as any)) {
821
+ collectedStats.all.push(value)
822
+ // Push host label.
823
+ const { trackId, hostName, participantName } = parseRtStatKey(key)
824
+ let stats = collectedStats.byHost[hostName]
825
+ if (!stats) {
826
+ stats = collectedStats.byHost[hostName] = new FastStats()
827
+ }
828
+ stats.push(value)
829
+ // Push participant and track values.
830
+ if (enabledForSession(sessionId, this.enableDetailedStats) && participantName) {
831
+ collectedStats.byParticipantAndTrack[`${participantName}:${trackId || ''}`] = value
832
+ }
833
+ } else if (typeof value === 'string') {
834
+ // Codec stats.
835
+ collectedStats.all.push(1)
836
+ let stats = collectedStats.byCodec[value]
837
+ if (!stats) {
838
+ stats = collectedStats.byCodec[value] = new FastStats()
839
+ }
840
+ stats.push(1)
841
+ }
842
+ }
843
+ }
844
+ } catch (err) {
845
+ log.error(`session getStats name: ${name} error: ${(err as Error).stack}`, err)
846
+ }
847
+ }
848
+ }
849
+ // Add external collected stats.
850
+ for (const [id, data] of this.externalCollectedStats.entries()) {
851
+ const { addedTime, externalStats, config } = data
852
+ if (now - addedTime > this.rtcStatsTimeout * 1000) {
853
+ log.debug(`remove externalCollectedStats from ${id}`)
854
+ this.externalCollectedStats.delete(id)
855
+ continue
856
+ }
857
+ log.debug(`add external stats from ${id}`)
858
+ // Add external config settings.
859
+ if (config.url) {
860
+ this.collectedStatsConfig.url = config.url
861
+ }
862
+ if (config.pages) {
863
+ this.collectedStatsConfig.pages += config.pages
864
+ }
865
+ // Add metrics.
866
+ this.statsNames.forEach(name => {
867
+ const stats = externalStats[name] as CollectedStatsRaw
868
+ if (!stats) {
869
+ return
870
+ }
871
+ const collectedStats = this.collectedStats[name]
872
+ collectedStats.all.push(stats.all)
873
+ Object.entries(stats.byHost).forEach(([host, values]) => {
874
+ if (!collectedStats.byHost[host]) {
875
+ collectedStats.byHost[host] = new FastStats()
876
+ }
877
+ collectedStats.byHost[host].push(values)
878
+ })
879
+ Object.entries(stats.byCodec).forEach(([codec, values]) => {
880
+ if (!collectedStats.byCodec[codec]) {
881
+ collectedStats.byCodec[codec] = new FastStats()
882
+ }
883
+ collectedStats.byCodec[codec].push(values)
884
+ })
885
+ Object.entries(stats.byParticipantAndTrack).forEach(([label, value]) => {
886
+ collectedStats.byParticipantAndTrack[label] = value
887
+ })
888
+ })
889
+ }
890
+ this.emit('stats', this.collectedStats)
891
+ // Push to an external instance.
892
+ if (this.pushStatsInstance) {
893
+ const pushStats: Record<string, CollectedStatsRaw> = {}
894
+ for (const [name, stats] of Object.entries(this.collectedStats)) {
895
+ pushStats[name] = {
896
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
897
+ all: (stats.all as any).data,
898
+ byHost: {},
899
+ byCodec: {},
900
+ byParticipantAndTrack: {},
901
+ }
902
+ Object.entries(stats.byHost).forEach(([host, stat]) => {
903
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
904
+ pushStats[name].byHost[host] = (stat as any).data
905
+ })
906
+ Object.entries(stats.byCodec).forEach(([codec, stat]) => {
907
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
908
+ pushStats[name].byCodec[codec] = (stat as any).data
909
+ })
910
+ Object.entries(stats.byParticipantAndTrack).forEach(([label, value]) => {
911
+ pushStats[name].byParticipantAndTrack[label] = value
912
+ })
913
+ }
914
+ try {
915
+ const res = await this.pushStatsInstance.put('/collected-stats', {
916
+ id: this.pushStatsId,
917
+ stats: pushStats,
918
+ config: this.collectedStatsConfig,
919
+ })
920
+ log.debug(`pushStats message=${res.data.message}`)
921
+ } catch (err) {
922
+ log.error(`pushStats error: ${(err as Error).stack}`)
923
+ }
924
+ }
925
+ // Check alerts.
926
+ this.checkAlertRules()
927
+ // Show to console.
928
+ this.consoleShowStats()
929
+
930
+ await Promise.allSettled([
931
+ this.writeStats(),
932
+ this.writeDetailedStats(),
933
+ this.sendToPushGateway(),
934
+ this.writeAlertRulesReport(),
935
+ ])
936
+ }
937
+
938
+ async writeStats() {
939
+ if (!this.statsWriter) return
940
+ const values = this.statsNames.reduce(
941
+ (v: string[], name) => v.concat(formatStats(this.collectedStats[name].all, true) as string[]),
942
+ [],
943
+ )
944
+ await this.statsWriter.push(values)
945
+ }
946
+
947
+ async writeDetailedStats() {
948
+ if (!this.detailedStatsWriter) return
949
+ const participantTrackStats = new Map<string, Record<string, string>>()
950
+ Object.entries(this.collectedStats).forEach(([name, stats]) => {
951
+ Object.entries(stats.byParticipantAndTrack).forEach(([label, value]) => {
952
+ let stats = participantTrackStats.get(label)
953
+ if (!stats) {
954
+ stats = {}
955
+ participantTrackStats.set(label, stats)
956
+ }
957
+ stats[name] = toPrecision(value, 6)
958
+ })
959
+ })
960
+ for (const [label, trackStats] of participantTrackStats.entries()) {
961
+ const [participantName, trackId] = label.split(':', 2)
962
+ const values = [participantName, trackId]
963
+ for (const name of this.statsNames) {
964
+ values.push(trackStats[name] ?? '')
965
+ }
966
+ await this.detailedStatsWriter.push(values)
967
+ }
968
+ }
969
+
970
+ /**
971
+ * addCollectedStats
972
+ * @param id
973
+ * @param externalStats
974
+ * @param config
975
+ */
976
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
977
+ addExternalCollectedStats(id: string, externalStats: any, config: any): void {
978
+ log.debug(`addExternalCollectedStats from ${id}`)
979
+ const addedTime = Date.now()
980
+ this.externalCollectedStats.set(id, { addedTime, externalStats, config })
981
+ }
982
+
983
+ /**
984
+ * It display stats on the console.
985
+ */
986
+ consoleShowStats(): void {
987
+ if (!this.showStats) {
988
+ return
989
+ }
990
+ const stats = this.collectedStats
991
+ let out =
992
+ sprintfStatsHeader() +
993
+ sprintfStats('System CPU', stats.usedCpu, '.2f', '%', undefined, true) +
994
+ sprintfStats('System GPU', stats.usedGpu, '.2f', '%', undefined, true) +
995
+ sprintfStats('System Memory', stats.usedMemory, '.2f', '%', undefined, true) +
996
+ sprintfStats('CPU/page', stats.cpu, '.2f', '%') +
997
+ sprintfStats('Memory/page', stats.memory, '.2f', 'MB') +
998
+ sprintfStats('Pages', stats.pages, 'd', '') +
999
+ sprintfStats('Errors', stats.errors, 'd', '') +
1000
+ sprintfStats('Warnings', stats.warnings, 'd', '') +
1001
+ sprintfStats('Peer Connections', stats.peerConnections, 'd', '') +
1002
+ sprintfStats('audioSubscribeDelay', stats.audioSubscribeDelay, 'd', 'ms', undefined, true) +
1003
+ sprintfStats('videoSubscribeDelay', stats.videoSubscribeDelay, 'd', 'ms', undefined, true) +
1004
+ // inbound audio
1005
+ sprintfStatsTitle('Inbound audio') +
1006
+ sprintfStats('received', stats.audioBytesReceived, '.2f', 'MB', 1e-6) +
1007
+ sprintfStats('rate', stats.audioRecvBitrates, '.2f', 'Kbps', 1e-3) +
1008
+ sprintfStats('lost', stats.audioRecvPacketsLost, '.2f', '%', undefined, true) +
1009
+ sprintfStats('jitter', stats.audioRecvJitter, '.2f', 's', undefined, true) +
1010
+ sprintfStats('avgJitterBufferDelay', stats.audioRecvAvgJitterBufferDelay, '.2f', 'ms', 1e3, true) +
1011
+ // inbound video
1012
+ sprintfStatsTitle('Inbound video') +
1013
+ sprintfStats('received', stats.videoRecvBytes, '.2f', 'MB', 1e-6) +
1014
+ sprintfStats('decoded', stats.videoFramesDecoded, 'd', 'frames') +
1015
+ sprintfStats('rate', stats.videoRecvBitrates, '.2f', 'Kbps', 1e-3) +
1016
+ sprintfStats('lost', stats.videoRecvPacketsLost, '.2f', '%', undefined, true) +
1017
+ sprintfStats('jitter', stats.videoRecvJitter, '.2f', 's', undefined, true) +
1018
+ sprintfStats('avgJitterBufferDelay', stats.videoRecvAvgJitterBufferDelay, '.2f', 'ms', 1e3, true) +
1019
+ sprintfStats('width', stats.videoRecvWidth, 'd', 'px', undefined, true) +
1020
+ sprintfStats('height', stats.videoRecvHeight, 'd', 'px', undefined, true) +
1021
+ sprintfStats('fps', stats.videoRecvFps, 'd', 'fps', undefined, true) +
1022
+ sprintfStats('firCountSent', stats.firCountSent, 'd', '', undefined, true) +
1023
+ sprintfStats('pliCountSent', stats.pliCountSent, 'd', '', undefined, true) +
1024
+ // outbound audio
1025
+ sprintfStatsTitle('Outbound audio') +
1026
+ sprintfStats('sent', stats.audioBytesSent, '.2f', 'MB', 1e-6) +
1027
+ sprintfStats('retransmitted', stats.audioRetransmittedBytesSent, '.2f', 'MB', 1e-6) +
1028
+ sprintfStats('rate', stats.audioSentBitrates, '.2f', 'Kbps', 1e-3) +
1029
+ sprintfStats('lost', stats.audioSentPacketsLost, '.2f', '%', undefined, true) +
1030
+ sprintfStats('roundTripTime', stats.audioSentRoundTripTime, '.3f', 's', undefined, true) +
1031
+ // outbound video
1032
+ sprintfStatsTitle('Outbound video') +
1033
+ sprintfStats('sent', stats.videoSentBytes, '.2f', 'MB', 1e-6) +
1034
+ sprintfStats('retransmitted', stats.videoSentRetransmittedBytes, '.2f', 'MB', 1e-6) +
1035
+ sprintfStats('rate', stats.videoSentBitrates, '.2f', 'Kbps', 1e-3) +
1036
+ sprintfStats('lost', stats.videoSentPacketsLost, '.2f', '%', undefined, true) +
1037
+ sprintfStats('roundTripTime', stats.videoSentRoundTripTime, '.3f', 's', undefined, true) +
1038
+ sprintfStats('qualityLimitResolutionChanges', stats.videoQualityLimitationResolutionChanges, 'd', '') +
1039
+ sprintfStats('qualityLimitationCpu', stats.videoQualityLimitationCpu, 'd', '%') +
1040
+ sprintfStats('qualityLimitationBandwidth', stats.videoQualityLimitationBandwidth, 'd', '%') +
1041
+ sprintfStats('sentActiveSpatialLayers', stats.videoSentActiveSpatialLayers, 'd', 'layers', undefined, true) +
1042
+ sprintfStats('sentMaxBitrate', stats.videoSentMaxBitrate, '.2f', 'Kbps', 1e-3) +
1043
+ sprintfStats('width', stats.videoSentWidth, 'd', 'px', undefined, true) +
1044
+ sprintfStats('height', stats.videoSentHeight, 'd', 'px', undefined, true) +
1045
+ sprintfStats('fps', stats.videoSentFps, 'd', 'fps', undefined, true) +
1046
+ sprintfStats('firCountReceived', stats.videoFirCountReceived, 'd', '', undefined, true) +
1047
+ sprintfStats('pliCountReceived', stats.videoPliCountReceived, 'd', '', undefined, true)
1048
+ if (this.alertRules) {
1049
+ const report = this.formatAlertRulesReport()
1050
+ if (report.length) {
1051
+ out += sprintfStatsTitle('Alert rules report')
1052
+ out += report
1053
+ }
1054
+ }
1055
+
1056
+ if (!this.showPageLog) {
1057
+ console.clear()
1058
+ }
1059
+ console.log(out)
1060
+ }
1061
+
1062
+ /**
1063
+ * sendToPushGateway
1064
+ */
1065
+ async sendToPushGateway(): Promise<void> {
1066
+ if (!this.gateway || !this.running) {
1067
+ return
1068
+ }
1069
+ const elapsedSeconds = (Date.now() - this.startTimestamp) / 1000
1070
+ const datetime = this.startTimestampString
1071
+
1072
+ Object.entries(this.metrics).forEach(([name, metric]) => {
1073
+ if (!this.collectedStats[name]) {
1074
+ return
1075
+ }
1076
+
1077
+ const setStats = (stats: FastStats, host: string, codec: string): void => {
1078
+ const labels = { host, codec, datetime, ...this.customMetricsLabels }
1079
+ const { length, sum, mean, stddev, p5, p95, min, max } = formatStats(stats) as StatsData
1080
+ metric.length.set(labels, length)
1081
+ metric.sum.set(labels, sum)
1082
+ metric.mean.set(labels, mean)
1083
+ metric.stddev.set(labels, stddev)
1084
+ metric.p5.set(labels, p5)
1085
+ metric.p95.set(labels, p95)
1086
+ metric.min.set(labels, min)
1087
+ metric.max.set(labels, max)
1088
+ }
1089
+
1090
+ setStats(this.collectedStats[name].all, 'all', 'all')
1091
+ Object.entries(this.collectedStats[name].byHost).forEach(([host, stats]) => {
1092
+ setStats(stats, host, 'all')
1093
+ })
1094
+ Object.entries(this.collectedStats[name].byCodec).forEach(([codec, stats]) => {
1095
+ setStats(stats, 'all', codec)
1096
+ })
1097
+ if (metric.value) {
1098
+ Object.entries(this.collectedStats[name].byParticipantAndTrack).forEach(([label, value]) => {
1099
+ const [participantName, trackId] = label.split(':', 2)
1100
+ metric.value?.set(
1101
+ {
1102
+ participantName,
1103
+ trackId,
1104
+ datetime,
1105
+ ...this.customMetricsLabels,
1106
+ },
1107
+ value,
1108
+ )
1109
+ })
1110
+ }
1111
+
1112
+ // Set alerts metrics.
1113
+ if (this.alertRules && this.alertRules[name]) {
1114
+ const rule = this.alertRules[name]
1115
+ // eslint-disable-next-line prefer-const
1116
+ for (let [ruleKey, ruleValues] of Object.entries(rule)) {
1117
+ if (ruleKey === 'tags') {
1118
+ continue
1119
+ }
1120
+ if (!Array.isArray(ruleValues)) {
1121
+ ruleValues = [ruleValues as AlertRuleValue]
1122
+ } else {
1123
+ ruleValues = ruleValues as AlertRuleValue[]
1124
+ }
1125
+ for (const ruleValue of ruleValues) {
1126
+ // Send rule values as metrics.
1127
+ if (ruleValue.$after !== undefined && elapsedSeconds < ruleValue.$after) {
1128
+ continue
1129
+ }
1130
+ const ruleName = `alert_${name}_${ruleKey}`
1131
+ const ruleObj = this.metrics[name].alertRules[ruleName]
1132
+ const remove = ruleValue.$before !== undefined && elapsedSeconds > ruleValue.$before
1133
+ // Send rule report as metric.
1134
+ const ruleDesc = this.getAlertRuleDesc(ruleKey, ruleValue)
1135
+ const report = this.alertRulesReport.get(name)
1136
+ if (report) {
1137
+ const ruleReport = report.get(ruleDesc)
1138
+ if (ruleReport) {
1139
+ const labels = {
1140
+ rule: ruleDesc,
1141
+ datetime,
1142
+ ...this.customMetricsLabels,
1143
+ }
1144
+ if (!remove) {
1145
+ ruleObj.report.set(labels, ruleReport.failAmountPercentile)
1146
+ ruleObj.mean.set(labels, ruleReport.valueStats.amean())
1147
+ } else {
1148
+ ruleObj.report.remove(labels)
1149
+ ruleObj.mean.remove(labels)
1150
+ }
1151
+ }
1152
+ }
1153
+ // Send rules values as metrics.
1154
+ if (ruleValue.$eq !== undefined) {
1155
+ const labels = {
1156
+ rule: `${name} ${ruleKey} =`,
1157
+ datetime,
1158
+ ...this.customMetricsLabels,
1159
+ }
1160
+ if (!remove) {
1161
+ ruleObj.rule.set(labels, ruleValue.$eq)
1162
+ } else {
1163
+ ruleObj.rule.remove(labels)
1164
+ }
1165
+ }
1166
+ if (ruleValue.$lt !== undefined) {
1167
+ const labels = {
1168
+ rule: `${name} ${ruleKey} <`,
1169
+ datetime,
1170
+ ...this.customMetricsLabels,
1171
+ }
1172
+ if (!remove) {
1173
+ ruleObj.rule.set(labels, ruleValue.$lt)
1174
+ } else {
1175
+ ruleObj.rule.remove(labels)
1176
+ }
1177
+ }
1178
+ if (ruleValue.$lte !== undefined) {
1179
+ const labels = {
1180
+ rule: `${name} ${ruleKey} <=`,
1181
+ datetime,
1182
+ ...this.customMetricsLabels,
1183
+ }
1184
+ if (!remove) {
1185
+ ruleObj.rule.set(labels, ruleValue.$lte)
1186
+ } else {
1187
+ ruleObj.rule.remove(labels)
1188
+ }
1189
+ }
1190
+ if (ruleValue.$gt !== undefined) {
1191
+ const labels = {
1192
+ rule: `${name} ${ruleKey} >`,
1193
+ datetime,
1194
+ ...this.customMetricsLabels,
1195
+ }
1196
+ if (!remove) {
1197
+ ruleObj.rule.set(labels, ruleValue.$gt)
1198
+ } else {
1199
+ ruleObj.rule.remove(labels)
1200
+ }
1201
+ }
1202
+ if (ruleValue.$gte !== undefined) {
1203
+ const labels = {
1204
+ rule: `${name} ${ruleKey} >=`,
1205
+ datetime,
1206
+ ...this.customMetricsLabels,
1207
+ }
1208
+ if (!remove) {
1209
+ ruleObj.rule.set(labels, ruleValue.$gte)
1210
+ } else {
1211
+ ruleObj.rule.remove(labels)
1212
+ }
1213
+ }
1214
+ }
1215
+ }
1216
+ }
1217
+ })
1218
+
1219
+ const alertRulesReportTags = this.getAlertRulesTags()
1220
+ if (alertRulesReportTags && this.alertTagsMetrics) {
1221
+ for (const [tag, stat] of alertRulesReportTags.entries()) {
1222
+ this.alertTagsMetrics.set(
1223
+ { datetime, tag, ...this.customMetricsLabels },
1224
+ calculateFailAmountPercentile(stat, this.alertRulesFailPercentile),
1225
+ )
1226
+ }
1227
+ }
1228
+
1229
+ try {
1230
+ const { resp, body } = await this.gateway.push({
1231
+ jobName: this.prometheusPushgatewayJobName,
1232
+ })
1233
+ if ((body as string).length) {
1234
+ log.warn(`Pushgateway error ${(resp as http.ServerResponse).statusCode}: ${body as string}`)
1235
+ }
1236
+ } catch (err) {
1237
+ log.error(`Pushgateway push error: ${(err as Error).stack}`)
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * alertRuleDesc
1243
+ */
1244
+ getAlertRuleDesc(ruleKey: string, ruleValue: AlertRuleValue): string {
1245
+ const ruleDescs = []
1246
+ if (ruleValue.$eq !== undefined) {
1247
+ ruleDescs.push(`= ${ruleValue.$eq}`)
1248
+ }
1249
+ if (ruleValue.$gt !== undefined) {
1250
+ ruleDescs.push(`> ${ruleValue.$gt}`)
1251
+ }
1252
+ if (ruleValue.$gte !== undefined) {
1253
+ ruleDescs.push(`>= ${ruleValue.$gte}`)
1254
+ }
1255
+ if (ruleValue.$lt !== undefined) {
1256
+ ruleDescs.push(`< ${ruleValue.$lt}`)
1257
+ }
1258
+ if (ruleValue.$lte !== undefined) {
1259
+ ruleDescs.push(`<= ${ruleValue.$lte}`)
1260
+ }
1261
+ let ruleDesc = `${ruleKey} ${ruleDescs.join(' and ')}`
1262
+ if (ruleValue.$after !== undefined) {
1263
+ ruleDesc += ` after ${ruleValue.$after}s`
1264
+ }
1265
+ if (ruleValue.$before !== undefined) {
1266
+ ruleDesc += ` before ${ruleValue.$before}s`
1267
+ }
1268
+ return ruleDesc
1269
+ }
1270
+
1271
+ /**
1272
+ * checkAlertRules
1273
+ */
1274
+ checkAlertRules(): void {
1275
+ if (!this.alertRules || !this.running) {
1276
+ return
1277
+ }
1278
+ const now = Date.now()
1279
+ const elapsedSeconds = (now - this.startTimestamp) / 1000
1280
+
1281
+ for (const [key, rule] of Object.entries(this.alertRules)) {
1282
+ if (!this.collectedStats[key]) {
1283
+ continue
1284
+ }
1285
+ let failPercentile = this.alertRulesFailPercentile
1286
+ const value = formatStats(this.collectedStats[key].all) as StatsData
1287
+ // eslint-disable-next-line prefer-const
1288
+ for (let [ruleKey, ruleValues] of Object.entries(rule)) {
1289
+ if (['tags', 'failPercentile'].includes(ruleKey)) {
1290
+ if (ruleKey === 'failPercentile') {
1291
+ failPercentile = ruleValues as number
1292
+ }
1293
+ continue
1294
+ }
1295
+ if (!Array.isArray(ruleValues)) {
1296
+ ruleValues = [ruleValues as AlertRuleValue]
1297
+ } else {
1298
+ ruleValues = ruleValues as AlertRuleValue[]
1299
+ }
1300
+ let ruleElapsedSeconds = elapsedSeconds
1301
+ for (const ruleValue of ruleValues) {
1302
+ if (
1303
+ (ruleValue.$after !== undefined && elapsedSeconds < ruleValue.$after) ||
1304
+ (ruleValue.$before !== undefined && elapsedSeconds > ruleValue.$before)
1305
+ ) {
1306
+ continue
1307
+ }
1308
+ if (ruleValue.$after !== undefined) {
1309
+ ruleElapsedSeconds -= ruleValue.$after
1310
+ }
1311
+ const checkValue = value[ruleKey as StatsDataKey]
1312
+ if (!isFinite(checkValue)) {
1313
+ continue
1314
+ }
1315
+ const ruleDesc = this.getAlertRuleDesc(ruleKey, ruleValue)
1316
+ let failed = false
1317
+ let failAmount = 0
1318
+
1319
+ if (
1320
+ (ruleValue.$skip_lt !== undefined && checkValue < ruleValue.$skip_lt) ||
1321
+ (ruleValue.$skip_lte !== undefined && checkValue <= ruleValue.$skip_lte) ||
1322
+ (ruleValue.$skip_gt !== undefined && checkValue > ruleValue.$skip_gt) ||
1323
+ (ruleValue.$skip_gte !== undefined && checkValue >= ruleValue.$skip_gte)
1324
+ ) {
1325
+ continue
1326
+ }
1327
+
1328
+ if (ruleValue.$eq !== undefined) {
1329
+ if (checkValue !== ruleValue.$eq) {
1330
+ failed = true
1331
+ failAmount = calculateFailAmount(checkValue, ruleValue.$eq)
1332
+ }
1333
+ } else {
1334
+ if (ruleValue.$lt !== undefined) {
1335
+ if (checkValue >= ruleValue.$lt) {
1336
+ failed = true
1337
+ failAmount = calculateFailAmount(checkValue, ruleValue.$lt)
1338
+ }
1339
+ } else if (ruleValue.$lte !== undefined) {
1340
+ if (checkValue > ruleValue.$lte) {
1341
+ failed = true
1342
+ failAmount = calculateFailAmount(checkValue, ruleValue.$lte)
1343
+ }
1344
+ }
1345
+ if (!failed) {
1346
+ if (ruleValue.$gt !== undefined) {
1347
+ if (checkValue <= ruleValue.$gt) {
1348
+ failed = true
1349
+ failAmount = calculateFailAmount(checkValue, ruleValue.$gt)
1350
+ }
1351
+ } else if (ruleValue.$gte !== undefined) {
1352
+ if (checkValue < ruleValue.$gte) {
1353
+ failed = true
1354
+ failAmount = calculateFailAmount(checkValue, ruleValue.$gte)
1355
+ }
1356
+ }
1357
+ }
1358
+ }
1359
+ // Report if failed or not.
1360
+ this.updateRulesReport(key, checkValue, ruleDesc, failed, failAmount, now, ruleElapsedSeconds, failPercentile)
1361
+ }
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * addFailedRule
1368
+ */
1369
+ updateRulesReport(
1370
+ key: string,
1371
+ checkValue: number,
1372
+ ruleDesc: string,
1373
+ failed: boolean,
1374
+ failAmount: number,
1375
+ now: number,
1376
+ elapsedSeconds: number,
1377
+ failPercentile: number,
1378
+ ): void {
1379
+ if (failed) {
1380
+ log.debug(
1381
+ `updateRulesReport ${key}.${ruleDesc} failed: ${failed} checkValue: ${checkValue} failAmount: ${failAmount} elapsedSeconds: ${elapsedSeconds}`,
1382
+ )
1383
+ }
1384
+ let report = this.alertRulesReport.get(key)
1385
+ if (!report) {
1386
+ report = new Map()
1387
+ this.alertRulesReport.set(key, report)
1388
+ }
1389
+ let reportValue = report.get(ruleDesc)
1390
+ if (!reportValue) {
1391
+ reportValue = {
1392
+ totalFails: 0,
1393
+ totalFailsTime: 0,
1394
+ totalFailsTimePerc: 0,
1395
+ lastFailed: 0,
1396
+ valueStats: new FastStats(),
1397
+ failAmountStats: new FastStats(),
1398
+ failAmountPercentile: 0,
1399
+ }
1400
+ report.set(ruleDesc, reportValue)
1401
+ }
1402
+ if (failed) {
1403
+ reportValue.totalFails += 1
1404
+ if (reportValue.lastFailed) {
1405
+ reportValue.totalFailsTime += (now - reportValue.lastFailed) / 1000
1406
+ }
1407
+ reportValue.lastFailed = now
1408
+ } else {
1409
+ reportValue.lastFailed = 0
1410
+ }
1411
+ reportValue.totalFailsTimePerc = Math.round((100 * reportValue.totalFailsTime) / elapsedSeconds)
1412
+ reportValue.valueStats.push(checkValue)
1413
+ reportValue.failAmountStats.push(failAmount)
1414
+ reportValue.failAmountPercentile = calculateFailAmountPercentile(reportValue.failAmountStats, failPercentile)
1415
+ }
1416
+
1417
+ getAlertRulesTags(): Map<string, FastStats> | undefined {
1418
+ if (!this.alertRules) {
1419
+ return
1420
+ }
1421
+ const alertRulesReportTags = new Map<string, FastStats>()
1422
+ for (const [key, report] of this.alertRulesReport.entries()) {
1423
+ const tags = this.alertRules[key].tags || []
1424
+ for (const tag of tags) {
1425
+ if (!alertRulesReportTags.has(tag)) {
1426
+ alertRulesReportTags.set(tag, new FastStats())
1427
+ }
1428
+ }
1429
+ for (const reportValue of report.values()) {
1430
+ const { failAmountPercentile } = reportValue
1431
+ for (const tag of tags) {
1432
+ const stat = alertRulesReportTags.get(tag)
1433
+ if (!stat) {
1434
+ continue
1435
+ }
1436
+ stat.push(failAmountPercentile)
1437
+ }
1438
+ }
1439
+ }
1440
+ return alertRulesReportTags
1441
+ }
1442
+
1443
+ /**
1444
+ * formatAlertRulesReport
1445
+ * @param ext
1446
+ */
1447
+ formatAlertRulesReport(ext: string | null = null): string {
1448
+ if (!this.alertRulesReport || !this.alertRules) {
1449
+ return ''
1450
+ }
1451
+ // Update tags values.
1452
+ const alertRulesReportTags = this.getAlertRulesTags()!
1453
+ // JSON output.
1454
+ if (ext === 'json') {
1455
+ const out = {
1456
+ tags: {} as Record<string, number>,
1457
+ reports: {} as Record<
1458
+ string,
1459
+ {
1460
+ totalFails: number
1461
+ totalFailsTime: number
1462
+ totalFailsTimePerc: number
1463
+ failAmount: number
1464
+ count: number
1465
+ valueAverage: number
1466
+ // failAmountStats: number[]
1467
+ }
1468
+ >,
1469
+ }
1470
+ for (const [key, report] of this.alertRulesReport.entries()) {
1471
+ for (const [reportDesc, reportValue] of report.entries()) {
1472
+ const { totalFails, totalFailsTime, valueStats, totalFailsTimePerc, failAmountStats, failAmountPercentile } =
1473
+ reportValue
1474
+ if (totalFails) {
1475
+ out.reports[`${key} ${reportDesc}`] = {
1476
+ totalFails,
1477
+ totalFailsTime: Math.round(totalFailsTime),
1478
+ valueAverage: valueStats.amean(),
1479
+ totalFailsTimePerc,
1480
+ failAmount: failAmountPercentile,
1481
+ count: failAmountStats.length,
1482
+
1483
+ // failAmountStats: (failAmountStats as any).data as number[],
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ for (const [tag, stat] of alertRulesReportTags.entries()) {
1489
+ out.tags[tag] = calculateFailAmountPercentile(stat, this.alertRulesFailPercentile)
1490
+ }
1491
+ return JSON.stringify(out, null, 2)
1492
+ }
1493
+ // Textual output.
1494
+ let out = ''
1495
+ // Calculate max column size.
1496
+ let colSize = 20
1497
+ for (const [key, report] of this.alertRulesReport.entries()) {
1498
+ for (const [reportDesc, reportValue] of report.entries()) {
1499
+ const { totalFails, totalFailsTimePerc } = reportValue
1500
+ if (totalFails && totalFailsTimePerc > 0) {
1501
+ const check = `${key} ${reportDesc}`
1502
+ colSize = Math.max(colSize, check.length)
1503
+ }
1504
+ }
1505
+ }
1506
+ if (ext) {
1507
+ out += sprintf(
1508
+ `| %(check)-${colSize}s | %(total)-10s | %(totalFailsTime)-15s | %(totalFailsTimePerc)-15s | %(failAmount)-15s |\n`,
1509
+ {
1510
+ check: 'Condition',
1511
+ total: 'Fails',
1512
+ totalFailsTime: 'Fail time (s)',
1513
+ totalFailsTimePerc: 'Fail time (%)',
1514
+ failAmount: 'Fail amount %',
1515
+ },
1516
+ )
1517
+ } else {
1518
+ out += sprintf(
1519
+ chalk`{bold %(check)-${colSize}s} {bold %(total)-10s} {bold %(totalFailsTime)-15s} {bold %(totalFailsTimePerc)-15s} {bold %(failAmount)-15s}\n`,
1520
+ {
1521
+ check: 'Condition',
1522
+ total: 'Fails',
1523
+ totalFailsTime: 'Fail time (s)',
1524
+ totalFailsTimePerc: 'Fail time (%)',
1525
+ failAmount: 'Fail amount %',
1526
+ },
1527
+ )
1528
+ }
1529
+ for (const [key, report] of this.alertRulesReport.entries()) {
1530
+ for (const [reportDesc, reportValue] of report.entries()) {
1531
+ const { totalFails, totalFailsTime, failAmountPercentile, totalFailsTimePerc } = reportValue
1532
+ if (totalFails && totalFailsTimePerc > 0) {
1533
+ if (ext) {
1534
+ out += sprintf(
1535
+ `| %(check)-${colSize}s | %(totalFails)-10s | %(totalFailsTime)-15s | %(totalFailsTimePerc)-15s | %(failAmountPercentile)-15s |\n`,
1536
+ {
1537
+ check: `${key} ${reportDesc}`,
1538
+ totalFails,
1539
+ totalFailsTime: Math.round(totalFailsTime),
1540
+ totalFailsTimePerc,
1541
+ failAmountPercentile,
1542
+ },
1543
+ )
1544
+ } else {
1545
+ out += sprintf(
1546
+ chalk`{red {bold %(check)-${colSize}s}} {bold %(totalFails)-10s} {bold %(totalFailsTime)-15s} {bold %(totalFailsTimePerc)-15s} {bold %(failAmountPercentile)-15s}\n`,
1547
+ {
1548
+ check: `${key} ${reportDesc}`,
1549
+ totalFails,
1550
+ totalFailsTime: Math.round(totalFailsTime),
1551
+ totalFailsTimePerc,
1552
+ failAmountPercentile,
1553
+ },
1554
+ )
1555
+ }
1556
+ }
1557
+ }
1558
+ }
1559
+ // Tags report.
1560
+ if (ext) {
1561
+ out += sprintf(`%(fill)s\n`, { fill: '-'.repeat(colSize + 15 + 7) })
1562
+
1563
+ out += sprintf(`| %(name)-${colSize}s | %(failPerc)-15s |\n`, {
1564
+ name: 'Tag',
1565
+ failPerc: 'Fail %',
1566
+ })
1567
+ } else {
1568
+ out += sprintf(`%(fill)s\n`, { fill: '-'.repeat(colSize + 15) })
1569
+
1570
+ out += sprintf(chalk`{bold %(name)-${colSize}s} {bold %(failPerc)-15s}\n`, {
1571
+ name: 'Tag',
1572
+ failPerc: 'Fail %',
1573
+ })
1574
+ }
1575
+ for (const [tag, stat] of alertRulesReportTags.entries()) {
1576
+ const failPerc = calculateFailAmountPercentile(stat, this.alertRulesFailPercentile)
1577
+ if (ext) {
1578
+ out += sprintf(`| %(tag)-${colSize}s | %(failPerc)-15s |\n`, {
1579
+ tag,
1580
+ failPerc,
1581
+ })
1582
+ } else {
1583
+ const color = failPerc < 5 ? 'green' : failPerc < 25 ? 'yellowBright' : failPerc < 50 ? 'yellow' : 'red'
1584
+
1585
+ out += sprintf(chalk`{${color} {bold %(tag)-${colSize}s %(failPerc)-15s}}\n`, {
1586
+ tag,
1587
+ failPerc,
1588
+ })
1589
+ }
1590
+ }
1591
+ return out
1592
+ }
1593
+
1594
+ /**
1595
+ * writeAlertRulesReport
1596
+ */
1597
+ async writeAlertRulesReport(): Promise<void> {
1598
+ if (!this.alertRules || !this.alertRulesFilename || !this.running) {
1599
+ return
1600
+ }
1601
+ log.debug(`writeAlertRulesReport writing in ${this.alertRulesFilename}`)
1602
+ try {
1603
+ const ext = this.alertRulesFilename.split('.').slice(-1)[0]
1604
+ const report = this.formatAlertRulesReport(ext)
1605
+ if (!report.length) {
1606
+ return
1607
+ }
1608
+ let out
1609
+ if (ext === 'log') {
1610
+ const lines = report.split('\n').filter(line => line.length)
1611
+ const name = `Alert rules report (${new Date().toISOString()})`
1612
+ out = sprintf(`-- %(name)s %(fill)s\n`, {
1613
+ name,
1614
+ fill: '-'.repeat(Math.max(4, lines[0].length - name.length - 4)),
1615
+ })
1616
+ out += report
1617
+ out += sprintf(`%(fill)s\n`, {
1618
+ fill: '-'.repeat(lines[lines.length - 1].length),
1619
+ })
1620
+ } else {
1621
+ out = report
1622
+ }
1623
+ await fs.promises.mkdir(path.dirname(this.alertRulesFilename), {
1624
+ recursive: true,
1625
+ })
1626
+ await fs.promises.writeFile(this.alertRulesFilename, out)
1627
+ } catch (err) {
1628
+ log.error(`writeAlertRulesReport error: ${(err as Error).stack}`)
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Stop the stats collector and the added Sessions.
1634
+ */
1635
+ async stop(): Promise<void> {
1636
+ if (!this.running) {
1637
+ return
1638
+ }
1639
+ this.running = false
1640
+ log.debug('stop')
1641
+ if (this.scheduler) {
1642
+ this.scheduler.stop()
1643
+ this.scheduler = undefined
1644
+ }
1645
+
1646
+ for (const session of this.sessions.values()) {
1647
+ try {
1648
+ session.removeAllListeners()
1649
+ await session.stop()
1650
+ } catch (err) {
1651
+ log.error(`session stop error: ${(err as Error).stack}`)
1652
+ }
1653
+ }
1654
+ this.sessions.clear()
1655
+
1656
+ this.statsWriter = null
1657
+
1658
+ // delete metrics
1659
+ if (this.gateway) {
1660
+ await this.deletePushgatewayStats()
1661
+ this.gateway = null
1662
+ this.metrics = {}
1663
+ }
1664
+
1665
+ this.collectedStats = this.initCollectedStats()
1666
+ this.externalCollectedStats.clear()
1667
+ }
1668
+ }