@artilleryio/int-core 1.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.
package/lib/ssms.js ADDED
@@ -0,0 +1,715 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
+
5
+ // Super simple metric store
6
+
7
+ // Use our own fork of DDSketch until this PR is merged into main:
8
+ // https://github.com/DataDog/sketches-js/pull/13
9
+ const { DDSketch } = require('@artilleryio/sketches-js');
10
+ // const {DDSketch} = require('@datadog/sketches-js');
11
+ const EventEmitter = require('events');
12
+ const { setDriftlessInterval, clearDriftless } = require('driftless');
13
+ const debug = require('debug')('ssms');
14
+
15
+ class SSMS extends EventEmitter {
16
+ constructor(_options) {
17
+ super();
18
+
19
+ this.opts = _options || {};
20
+
21
+ this._counterEarliestMeasurementByPeriod = {};
22
+ this._counterLastMeasurementByPeriod = {};
23
+ this._histogramEarliestMeasurementByPeriod = {};
24
+ this._histogramLastMeasurementByPeriod = {};
25
+
26
+ this._aggregationIntervalSec = 5;
27
+ this._aggregateInterval = setDriftlessInterval(
28
+ this.aggregate.bind(this),
29
+ this._aggregationIntervalSec * 1000
30
+ );
31
+
32
+ this._lastPeriod = null;
33
+
34
+ this.isPullOnly = this.opts.pullOnly;
35
+
36
+ if (!this.isPullOnly) {
37
+ this._emitInterval = setDriftlessInterval(
38
+ this._maybeEmitMostRecentPeriod.bind(this),
39
+ Math.max((this._aggregationIntervalSec * 1000) / 2, 1000)
40
+ );
41
+ }
42
+
43
+ this._counters = [];
44
+ this._histograms = [];
45
+ this._rates = [];
46
+
47
+ this._active = true;
48
+
49
+ this._aggregatedCounters = {};
50
+ this._aggregatedHistograms = {};
51
+ this._aggregatedRates = {};
52
+ }
53
+
54
+ stop() {
55
+ this._active = false;
56
+ clearDriftless(this._aggregateInterval);
57
+
58
+ if (!this.isPullOnly) {
59
+ clearDriftless(this._emitInterval);
60
+ }
61
+
62
+ return this;
63
+ }
64
+
65
+ static report(pds) {
66
+ return pds;
67
+ }
68
+
69
+ // TODO: first/last metric timestamps should not = period
70
+ static empty(ts) {
71
+ const period = normalizeTs(ts || Date.now());
72
+ return {
73
+ counters: {},
74
+ histograms: {},
75
+ rates: {},
76
+ firstCounterAt: 0,
77
+ firstHistogramAt: 0,
78
+ lastCounterAt: 0,
79
+ lastHistogramAt: 0,
80
+ firstMetricAt: 0,
81
+ lastMetricAt: 0,
82
+ period
83
+ };
84
+ }
85
+
86
+ static summarizeHistogram(h) {
87
+ return summarizeHistogram(h);
88
+ }
89
+
90
+ // Take metric data for a period and return a summary object with 1.6.x-compatible format
91
+ static legacyReport(pd) {
92
+ const result = {
93
+ // Custom field compatibility not supported:
94
+ customStats: {},
95
+ counters: {},
96
+
97
+ scenariosAvoided: pd.counters['vusers.skipped'] || 0,
98
+ timestamp: new Date(pd.period),
99
+ scenariosCreated: pd.counters['vusers.created'] || 0,
100
+ scenariosCompleted: pd.counters['vusers.completed'] || 0,
101
+
102
+ requestsCompleted:
103
+ pd.counters['http.responses'] ||
104
+ pd.counters['socketio.emit'] ||
105
+ pd.counters['websocket.messages_sent'] ||
106
+ 0,
107
+ latency: {},
108
+ rps: {
109
+ mean: pd.rates
110
+ ? pd.rates['http.response_rate'] ||
111
+ pd.rates['socketio.emit_rate'] ||
112
+ 0
113
+ : 0,
114
+ count:
115
+ pd.counters['http.responses'] || pd.counters['socketio.emit'] || 0
116
+ },
117
+ scenarioDuration: {},
118
+ scenarioCounts: {},
119
+
120
+ errors: {},
121
+ codes: {}
122
+ };
123
+
124
+ if (
125
+ pd.histograms &&
126
+ typeof pd.histograms['vusers.session_length'] !== 'undefined'
127
+ ) {
128
+ result.scenarioDuration = summarizeHistogram(
129
+ pd.histograms['vusers.session_length']
130
+ );
131
+ }
132
+
133
+ // scenarioCounts
134
+ const names = Object.keys(pd.counters).filter((k) =>
135
+ k.startsWith('vusers.created_by_name.')
136
+ );
137
+ for (const n of names) {
138
+ result.scenarioCounts[n.split('vusers.created_by_name.')[1]] =
139
+ pd.counters[n];
140
+ }
141
+
142
+ // latency
143
+ const latencyh = pd.histograms
144
+ ? pd.histograms['http.response_time'] ||
145
+ pd.histograms['socketio.response_time']
146
+ : null;
147
+ if (latencyh) {
148
+ result.latency = summarizeHistogram(latencyh);
149
+ }
150
+
151
+ // HTTP codes
152
+ const codeNames = Object.keys(pd.counters).filter((k) =>
153
+ k.match(/^(http|socketio)\.codes.*/)
154
+ );
155
+ for (const n of codeNames) {
156
+ const code = parseInt(n.split('.codes.')[1]);
157
+ result.codes[code] = pd.counters[n];
158
+ }
159
+
160
+ // errors
161
+ const errNames = Object.keys(pd.counters).filter((k) =>
162
+ k.startsWith('errors.')
163
+ );
164
+ for (const n of errNames) {
165
+ const errName = n.split('errors.')[1];
166
+ result.errors[errName] = pd.counters[n];
167
+ }
168
+
169
+ return {
170
+ report: function () {
171
+ return result;
172
+ }
173
+ };
174
+ }
175
+
176
+ // Return object indexed by period (as string):
177
+ static mergeBuckets(periodData) {
178
+ debug(`mergeBuckets // timeslices: ${periodData.map((pd) => pd.period)}`);
179
+
180
+ // Returns result[timestamp] = {histograms:{},counters:{},rates:{}}
181
+ // ie. the result is indexed by timeslice
182
+ const result = {};
183
+
184
+ for (const pd of periodData) {
185
+ const ts = pd.period;
186
+
187
+ if (!result[ts]) {
188
+ result[ts] = {
189
+ counters: {},
190
+ histograms: {},
191
+ rates: {}
192
+ };
193
+ }
194
+
195
+ pd.counters = pd.counters || {};
196
+ pd.histograms = pd.histograms || {};
197
+ pd.rates = pd.rates || {};
198
+
199
+ //
200
+ // counters
201
+ //
202
+ for (const [name, value] of Object.entries(pd.counters)) {
203
+ if (!result[ts].counters[name]) {
204
+ result[ts].counters[name] = 0;
205
+ }
206
+
207
+ result[ts].counters[name] += value;
208
+ }
209
+
210
+ //
211
+ // histograms
212
+ //
213
+ for (const [name, origValue] of Object.entries(pd.histograms)) {
214
+ const value = SSMS.cloneHistogram(origValue);
215
+ if (typeof result[ts].histograms[name] === 'undefined') {
216
+ result[ts].histograms[name] = value;
217
+ } else {
218
+ // NOTE: this will throw if gamma (accuracy) parameters are different
219
+ // in those two sketches
220
+ result[ts].histograms[name].merge(value);
221
+ }
222
+ }
223
+
224
+ //
225
+ // rates
226
+ //
227
+ for (const [name, value] of Object.entries(pd.rates)) {
228
+ if (typeof result[ts].rates[name] === 'undefined') {
229
+ result[ts].rates[name] = 0;
230
+ }
231
+
232
+ result[ts].rates[name] += value;
233
+ }
234
+
235
+ for (const name of Object.keys(pd.rates)) {
236
+ result[ts][name] = round(result[ts][name] / periodData.length, 1);
237
+ }
238
+
239
+ result[ts].firstCounterAt = min([
240
+ result[ts].firstCounterAt,
241
+ pd.firstCounterAt
242
+ ]);
243
+ result[ts].firstHistogramAt = min([
244
+ result[ts].firstHistogramAt,
245
+ pd.firstHistogramAt
246
+ ]);
247
+ result[ts].lastCounterAt = max([
248
+ result[ts].lastCounterAt,
249
+ pd.lastCounterAt
250
+ ]);
251
+ result[ts].lastHistogramAt = max([
252
+ result[ts].lastHistogramAt,
253
+ pd.lastHistogramAt
254
+ ]);
255
+
256
+ result[ts].firstMetricAt = min([
257
+ result[ts].firstHistogramAt,
258
+ result[ts].firstCounterAt
259
+ ]);
260
+ result[ts].lastMetricAt = max([
261
+ result[ts].lastHistogramAt,
262
+ result[ts].lastCounterAt
263
+ ]);
264
+ result[ts].period = ts;
265
+ }
266
+
267
+ return result;
268
+ }
269
+
270
+ // Aggregate at lower resolution, i.e. combine three distinct periods of 10s into one of 30s
271
+ // Note: does not check that periods are contiguous, everything is simply merged together
272
+ static pack(periods) {
273
+ const result = {
274
+ counters: {},
275
+ histograms: {},
276
+ rates: {}
277
+ };
278
+
279
+ for (const pd of periods) {
280
+ pd.counters = Object.assign({}, pd.counters || {});
281
+ pd.histograms = Object.assign({}, pd.histograms || {});
282
+ pd.rates = Object.assign({}, pd.rates || {});
283
+
284
+ for (const [name, value] of Object.entries(pd.counters)) {
285
+ if (!result.counters[name]) {
286
+ result.counters[name] = 0;
287
+ }
288
+
289
+ result.counters[name] += value;
290
+ }
291
+
292
+ for (const [name, origValue] of Object.entries(pd.histograms)) {
293
+ const value = SSMS.cloneHistogram(origValue);
294
+ if (typeof result.histograms[name] === 'undefined') {
295
+ result.histograms[name] = value;
296
+ } else {
297
+ // NOTE: this will throw if gamma (accuracy) parameters are different
298
+ // in those two sketches
299
+ result.histograms[name].merge(value);
300
+ }
301
+ }
302
+
303
+ for (const [name, value] of Object.entries(pd.rates)) {
304
+ if (!result.rates[name]) {
305
+ result.rates[name] = 0;
306
+ }
307
+
308
+ // TODO: retain first/last so that we have the duration
309
+ // or retain the duration of the window in which rate events
310
+ // were recorded alongside the average value
311
+ result.rates[name] += value;
312
+ }
313
+ }
314
+
315
+ for (const [name, _value] of Object.entries(result.rates)) {
316
+ result.rates[name] = round(result.rates[name] / periods.length, 0);
317
+ }
318
+
319
+ result.firstCounterAt = min(periods.map((p) => p.firstCounterAt));
320
+ result.firstHistogramAt = min(periods.map((p) => p.firstHistogramAt));
321
+ result.lastCounterAt = max(periods.map((p) => p.lastCounterAt));
322
+ result.lastHistogramAt = max(periods.map((p) => p.lastHistogramAt));
323
+
324
+ result.firstMetricAt = min([
325
+ result.firstHistogramAt,
326
+ result.firstCounterAt
327
+ ]);
328
+ result.lastMetricAt = max([result.lastHistogramAt, result.lastCounterAt]);
329
+
330
+ result.period = max(periods.map((p) => p.period));
331
+ return result;
332
+ }
333
+
334
+ static cloneHistogram(h) {
335
+ return DDSketch.fromProto(h.toProto());
336
+ }
337
+
338
+ static serializeMetrics(pd) {
339
+ // TODO: Add ability to include arbitrary metadata e.g. worker IDs
340
+ const serializedHistograms = {};
341
+ const ph = pd.histograms;
342
+ if (ph) {
343
+ for (const n of Object.keys(ph)) {
344
+ const h = ph[n];
345
+ const buf = h.toProto();
346
+ serializedHistograms[n] = buf;
347
+ }
348
+ }
349
+
350
+ // TODO: Mark as serialized, otherwise to check whether we have a serialized object or not
351
+ // is to check if .histograms is a Buffer
352
+ const result = Object.assign({}, pd, { histograms: serializedHistograms });
353
+ return stringify(result);
354
+ }
355
+
356
+ static deserializeMetrics(pd) {
357
+ const object = parse(pd);
358
+ for (const [name, buf] of Object.entries(object.histograms)) {
359
+ const h = DDSketch.fromProto(buf);
360
+ object.histograms[name] = h;
361
+ }
362
+
363
+ object.period = object.period;
364
+
365
+ return object;
366
+ }
367
+
368
+ getBucketIds() {
369
+ return [
370
+ ...new Set(
371
+ Object.keys(this._aggregatedCounters)
372
+ .concat(Object.keys(this._aggregatedHistograms))
373
+ .sort()
374
+ )
375
+ ].reverse();
376
+ }
377
+
378
+ // TODO: Deprecate
379
+ counter(name, value) {
380
+ this.incr(name, value);
381
+ }
382
+
383
+ incr(name, value, t) {
384
+ this._counters.push(t || Date.now(), name, value);
385
+ }
386
+
387
+ // TODO: Deprecate
388
+ summary(name, value) {
389
+ this.histogram(name, value);
390
+ }
391
+
392
+ histogram(name, value, t) {
393
+ this._histograms.push(t || Date.now(), name, value);
394
+ }
395
+
396
+ rate(name, t) {
397
+ this._rates.push(t || Date.now(), name);
398
+ }
399
+
400
+ getMetrics(period) {
401
+ const result = {};
402
+
403
+ const counters = this._aggregatedCounters[period];
404
+ const histograms = this._aggregatedHistograms[period];
405
+ const rates = this._aggregatedRates[period];
406
+
407
+ if (counters) {
408
+ result.counters = counters;
409
+ }
410
+
411
+ if (histograms) {
412
+ result.histograms = histograms;
413
+ }
414
+
415
+ if (rates) {
416
+ result.rates = rates;
417
+ }
418
+
419
+ result.period = period;
420
+ result.firstCounterAt = this._counterEarliestMeasurementByPeriod[period];
421
+ result.firstHistogramAt =
422
+ this._histogramEarliestMeasurementByPeriod[period];
423
+
424
+ result.lastCounterAt = this._counterLastMeasurementByPeriod[period];
425
+ result.lastHistogramAt = this._histogramLastMeasurementByPeriod[period];
426
+
427
+ result.firstMetricAt = min([
428
+ result.firstHistogramAt,
429
+ result.firstCounterAt
430
+ ]);
431
+ result.lastMetricAt = max([result.lastHistogramAt, result.lastCounterAt]);
432
+
433
+ // TODO: Include size of the window, for cases when it's not 10s
434
+
435
+ return result;
436
+ }
437
+
438
+ _aggregateHistograms(upToTimeslice) {
439
+ for (let i = 0; i < this._histograms.length; i += 3) {
440
+ const ts = this._histograms[i];
441
+ const timeslice = normalizeTs(ts);
442
+
443
+ if (timeslice >= upToTimeslice) {
444
+ this._histograms.splice(0, i);
445
+ return;
446
+ }
447
+
448
+ const name = this._histograms[i + 1];
449
+ const value = this._histograms[i + 2];
450
+
451
+ if (!this._aggregatedHistograms[timeslice]) {
452
+ this._aggregatedHistograms[timeslice] = {};
453
+ this._histogramEarliestMeasurementByPeriod[timeslice] = ts;
454
+ }
455
+
456
+ if (!this._aggregatedHistograms[timeslice][name]) {
457
+ this._aggregatedHistograms[timeslice][name] = new DDSketch({
458
+ relativeAccuracy: 0.01
459
+ });
460
+ }
461
+
462
+ // TODO: Benchmark
463
+ this._histogramLastMeasurementByPeriod[timeslice] = ts;
464
+
465
+ this._aggregatedHistograms[timeslice][name].accept(value);
466
+ }
467
+
468
+ this._histograms.splice(0, this._histograms.length);
469
+ }
470
+
471
+ _aggregateCounters(upToTimeslice) {
472
+ // Consider memory-CPU tradeoff. Depending on the length of the buffer, we may want to
473
+ // not exceed N total entries we're processing if we can delay reporting by one or more
474
+ // reporting periods
475
+
476
+ for (let i = 0; i < this._counters.length; i += 3) {
477
+ const ts = this._counters[i];
478
+ const timeslice = normalizeTs(ts);
479
+
480
+ if (timeslice >= upToTimeslice) {
481
+ this._counters.splice(0, i);
482
+ return;
483
+ }
484
+
485
+ const name = this._counters[i + 1];
486
+ const value = this._counters[i + 2];
487
+
488
+ if (!this._aggregatedCounters[timeslice]) {
489
+ this._aggregatedCounters[timeslice] = {};
490
+ this._counterEarliestMeasurementByPeriod[timeslice] = ts;
491
+ }
492
+
493
+ if (typeof this._aggregatedCounters[timeslice][name] === 'undefined') {
494
+ this._aggregatedCounters[timeslice][name] = value;
495
+ } else {
496
+ this._aggregatedCounters[timeslice][name] += value;
497
+ }
498
+
499
+ this._counterLastMeasurementByPeriod[timeslice] = ts;
500
+ }
501
+
502
+ this._counters.splice(0, this._counters.length);
503
+ }
504
+
505
+ _aggregateRates(upToTimeslice) {
506
+ debug('_aggregateRates to', upToTimeslice, new Date(upToTimeslice));
507
+
508
+ const a = {};
509
+ let spliceTo = this._rates.length;
510
+
511
+ for (let i = 0; i < this._rates.length; i += 2) {
512
+ const ts = this._rates[i];
513
+ const timeslice = normalizeTs(ts);
514
+
515
+ if (timeslice >= upToTimeslice) {
516
+ debug(
517
+ '_aggregateRates early return // i=',
518
+ i,
519
+ 'timeslice=',
520
+ timeslice,
521
+ new Date(timeslice)
522
+ );
523
+ spliceTo = i;
524
+ break;
525
+ }
526
+
527
+ const name = this._rates[i + 1];
528
+ if (!a[timeslice]) {
529
+ a[timeslice] = {};
530
+ }
531
+
532
+ if (!a[timeslice][name]) {
533
+ a[timeslice][name] = {
534
+ first: Number.POSITIVE_INFINITY,
535
+ last: 0,
536
+ count: 0
537
+ };
538
+ }
539
+
540
+ a[timeslice][name].first = Math.min(a[timeslice][name].first, ts);
541
+ a[timeslice][name].last = Math.max(a[timeslice][name].last, ts);
542
+ a[timeslice][name].count++;
543
+ }
544
+
545
+ for (const [ts, rs] of Object.entries(a)) {
546
+ for (const [name, _] of Object.entries(rs)) {
547
+ const { first, last, count } = a[ts][name];
548
+ if (!this._aggregatedRates[ts]) {
549
+ this._aggregatedRates[ts] = {};
550
+ }
551
+
552
+ this._aggregatedRates[ts][name] = round(
553
+ count / (Math.max(last - first, 1000) / 1000),
554
+ 0
555
+ );
556
+ }
557
+ }
558
+
559
+ this._rates.splice(0, spliceTo);
560
+ }
561
+
562
+ aggregate(forceAll) {
563
+ const currentTimeslice =
564
+ normalizeTs(Date.now()) + (forceAll ? 30 * 1000 : 0);
565
+
566
+ this._aggregateCounters(currentTimeslice);
567
+ this._aggregateHistograms(currentTimeslice);
568
+ this._aggregateRates(currentTimeslice);
569
+
570
+ if (forceAll) {
571
+ this._emitPeriods();
572
+ } else {
573
+ this._maybeEmitMostRecentPeriod();
574
+ }
575
+ }
576
+
577
+ _emitPeriods() {
578
+ const bucketIds = this.getBucketIds();
579
+ const lastPeriod = parseInt(this._lastPeriod, 10);
580
+
581
+ for (let i = 0; i < bucketIds.length; i++) {
582
+ const period = bucketIds[i];
583
+
584
+ if (!this._lastPeriod || parseInt(period, 10) > lastPeriod) {
585
+ this.emit('metricData', period, this.getMetrics(period));
586
+ }
587
+ }
588
+ }
589
+
590
+ _maybeEmitMostRecentPeriod() {
591
+ const p = this.getBucketIds()[0];
592
+
593
+ if (p && p !== this._lastPeriod) {
594
+ this.emit('metricData', p, this.getMetrics(p)); // Measurements in period p have been aggregated
595
+ this._lastPeriod = p;
596
+ }
597
+ }
598
+ }
599
+
600
+ function normalizeTs(epochMs, windowSize = 10) {
601
+ // Reset down to minute
602
+ const m = Math.floor((epochMs - (epochMs % 1000)) / 1000 / 60) * 60 * 1000;
603
+ // Number of seconds past the minute
604
+ const s = ((epochMs - (epochMs % 1000)) / 1000) % 60;
605
+ // Number of seconds to take off
606
+ const d = s % windowSize;
607
+ return m + (s - d) * 1000;
608
+ }
609
+
610
+ // Function hms(epochMs) {
611
+ // return [
612
+ // Math.round((epochMs / 1000 / 60 / 60) % 24),
613
+ // Math.round((epochMs / 1000 / 60) % 60),
614
+ // Math.round(epochMs / 1000) % 60
615
+ // ];
616
+ // }
617
+
618
+ function round(number, decimals) {
619
+ const m = 10 ** decimals;
620
+ return Math.round(number * m) / m;
621
+ }
622
+
623
+ // h is an instance of DDSketch
624
+ function summarizeHistogram(h) {
625
+ return {
626
+ min: round(h.min, 1),
627
+ max: round(h.max, 1),
628
+ count: h.count,
629
+ p50: round(h.getValueAtQuantile(0.5), 1),
630
+ median: round(h.getValueAtQuantile(0.5), 1), // Here for compatibility
631
+ p75: round(h.getValueAtQuantile(0.75), 1),
632
+ p90: round(h.getValueAtQuantile(0.9), 1),
633
+ p95: round(h.getValueAtQuantile(0.95), 1),
634
+ p99: round(h.getValueAtQuantile(0.99), 1),
635
+ p999: round(h.getValueAtQuantile(0.999), 1)
636
+ };
637
+ }
638
+
639
+ /// ///////////////////////////////////////////
640
+ function stringify(value, space) {
641
+ return JSON.stringify(value, replacer, space);
642
+ }
643
+
644
+ function parse(text) {
645
+ return JSON.parse(text, reviver);
646
+ }
647
+
648
+ function replacer(key, value) {
649
+ if (isBufferLike(value) && isArray(value.data)) {
650
+ if (value.data.length > 0) {
651
+ value.data = 'base64:' + Buffer.from(value.data).toString('base64');
652
+ } else {
653
+ value.data = '';
654
+ }
655
+ }
656
+
657
+ return value;
658
+ }
659
+
660
+ function reviver(key, value) {
661
+ if (isBufferLike(value)) {
662
+ if (isArray(value.data)) {
663
+ return Buffer.from(value.data);
664
+ }
665
+
666
+ if (isString(value.data)) {
667
+ if (value.data.startsWith('base64:')) {
668
+ return Buffer.from(value.data.slice('base64:'.length), 'base64');
669
+ }
670
+
671
+ // Assume that the string is UTF-8 encoded (or empty).
672
+ return Buffer.from(value.data);
673
+ }
674
+ }
675
+
676
+ return value;
677
+ }
678
+
679
+ function isBufferLike(x) {
680
+ return (
681
+ isObject(x) && x.type === 'Buffer' && (isArray(x.data) || isString(x.data))
682
+ );
683
+ }
684
+
685
+ function isArray(x) {
686
+ return Array.isArray(x);
687
+ }
688
+
689
+ function isString(x) {
690
+ return typeof x === 'string';
691
+ }
692
+
693
+ function isObject(x) {
694
+ return typeof x === 'object' && x !== null;
695
+ }
696
+ /// /////////////////
697
+
698
+ // Like Math.min and Math.max but take a list of values, and ignore
699
+ // undefined's rather than returning NaN when a value is undefined.
700
+ // Returns undefined if all arguments are undefined.
701
+ function min(values) {
702
+ const m = Math.min(...values.filter((x) => x));
703
+ return m === Number.POSITIVE_INFINITY ? undefined : m;
704
+ }
705
+
706
+ function max(values) {
707
+ const m = Math.max(...values.filter((x) => x));
708
+ return m === Number.NEGATIVE_INFINITY ? undefined : m;
709
+ }
710
+
711
+ module.exports = {
712
+ SSMS,
713
+ summarizeHistogram,
714
+ normalizeTs
715
+ };