@microsoft/dev-tunnels-ssh
Version:
SSH library for Dev Tunnels
362 lines • 17.9 kB
JavaScript
"use strict";
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionContour = void 0;
const errors_1 = require("../errors");
const queue_1 = require("../util/queue");
const semaphore_1 = require("../util/semaphore");
/**
* Collects session metrics over time, producing an outline of the timing, speed,
* and quantity of bytes sent/received during the session.
*
* Metrics are recorded across a number of equal time intervals. As the session time
* increases, intervals are expanded to keep the number of intervals under the configured
* maximum. Each expansion doubles the length of all intervals, while combining the metrics
* within each pair of combined intervals. Therefore, a longer session has longer intervals
* and less-granular metrics. In this way, the memory usage (and serialized size) of the
* session contour remains roughly constant regardless of the length of the session.
*
* Metrics exposed via the collection properties on this class may be momentarily
* inconsistent (but will not throw exceptions) if continued session operation causes
* intervals to be expanded while the data is being read concurrently. To avoid any
* inconsistency, hold a lock on the <see cref="SessionContour" /> instance while reading
* data. (Or wait until the session ends.)
*
* A session contour can be exported in a compact form suitable for logging or telemetry.
* Use the code in `SessionContour.kql` to chart a session contour in Azure Data Explorer.
*/
class SessionContour {
/**
* Creates a new instance of the `SessionContour` class.
*
* @param maxIntervals Maximum number of metric intervals to record,
* defaults to 256. Must be a power of two.
*/
constructor(maxIntervals = 256) {
/** Current size of the metrics interval, in milliseconds. */
this.intervalMs = SessionContour.initialInterval;
/** Number of intervals for which metrics have been recorded. */
this.count = 0;
this.updateQueue = new queue_1.Queue();
this.updateSemaphore = new semaphore_1.Semaphore(0);
this.disposed = false;
if (maxIntervals < 2 || (maxIntervals & (maxIntervals - 1)) !== 0) {
throw new Error('Contour intervals must be a power of two.');
}
this.maxIntervals = maxIntervals;
this.intervalBytesSent = new Array(maxIntervals);
this.intervalBytesReceived = new Array(maxIntervals);
this.intervalLatencyMin = new Array(maxIntervals);
this.intervalLatencyMax = new Array(maxIntervals);
this.intervalLatencySum = new Array(maxIntervals);
this.intervalLatencyCount = new Array(maxIntervals);
this.intervalLatencyAvg = new Array(maxIntervals);
this.intervalBytesSent.fill(0);
this.intervalBytesReceived.fill(0);
this.intervalLatencyMin.fill(0);
this.intervalLatencyMax.fill(0);
this.intervalLatencySum.fill(0);
this.intervalLatencyCount.fill(0);
this.intervalLatencyAvg.fill(0);
}
/**
* Gets the current number of contour intervals with recorded metrics. This is always
* less than `maxIntervals`.
*/
get intervalCount() {
return this.count;
}
/**
* Gets the current time span of each contour interval, in milliseconds. This interval time
* span is doubled as necesary such that the entire duration of the session is always covered
* by fewer intervals than the maximum.
*/
get interval() {
return this.intervalMs;
}
/**
* Gets the total number of bytes sent for each interval during the session,
* including all channels and non-channel protocol messages, and including message
* framing, padding, and MAC bytes. The number of values is equal to `intervalCount`.
*/
get bytesSent() {
return this.intervalBytesSent.slice(0, this.count);
}
/**
* Gets the total number of bytes received for each interval during the session,
* including all channels and non-channel protocol messages, and including message
* framing, padding, and MAC bytes. The number of values is equal to `intervalCount`.
*/
get bytesReceived() {
return this.intervalBytesReceived.slice(0, this.count);
}
/**
* Gets the minimum recorded round-trip connection latency between client and server for
* each interval during the session. The number of values is equal to `intervalCount`.
*/
get latencyMinMs() {
return this.intervalLatencyMin.slice(0, this.count);
}
/**
* Gets the maximum recorded round-trip connection latency between client and server for
* each interval during the session. The number of values is equal to `intervalCount`.
*/
get latencyMaxMs() {
return this.intervalLatencyMax.slice(0, this.count);
}
/**
* Gets the average recorded round-trip connection latency between client and server for
* each interval during the session. The number of values is equal to `intervalCount`.
*/
get latencyAverageMs() {
return this.intervalLatencyAvg.slice(0, this.count);
}
onMessageSent(e) {
this.updateQueue.enqueue({
time: e.time,
bytesSent: e.size,
});
this.updateSemaphore.tryRelease();
}
onMessageReceived(e) {
this.updateQueue.enqueue({
time: e.time,
bytesReceived: e.size,
});
this.updateSemaphore.tryRelease();
}
onLatencyUpdated(e) {
this.updateQueue.enqueue(e);
this.updateSemaphore.tryRelease();
}
onSessionClosed() {
this.updateSemaphore.tryRelease();
}
/**
* Starts collecting session metrics, and processes the metrics in a backgroud loop until
* cancelled or until the session is closed or the `SessionContour` instance is disposed.
*/
async collectMetrics(sessionMetrics, cancellation) {
if (!sessionMetrics)
throw new TypeError('A session metrics object is required.');
if (this.disposed)
throw new errors_1.ObjectDisposedError(this);
const eventRegistrations = [];
eventRegistrations.push(sessionMetrics.onMessageSent(this.onMessageSent.bind(this)));
eventRegistrations.push(sessionMetrics.onMessageReceived(this.onMessageReceived.bind(this)));
eventRegistrations.push(sessionMetrics.onLatencyUpdated(this.onLatencyUpdated.bind(this)));
eventRegistrations.push(sessionMetrics.onSessionClosed(this.onSessionClosed.bind(this)));
try {
while (!(cancellation === null || cancellation === void 0 ? void 0 : cancellation.isCancellationRequested)) {
try {
await this.updateSemaphore.wait(cancellation);
}
catch (e) {
// The semaphore was disposed.
break;
}
const update = this.updateQueue.dequeue();
if (!update) {
// The semaphore was released without enqueueing an update item.
break;
}
const intervalIndex = this.updateInterval(update.time);
if (update.bytesSent) {
this.intervalBytesSent[intervalIndex] += update.bytesSent;
}
if (update.bytesReceived) {
this.intervalBytesReceived[intervalIndex] += update.bytesReceived;
}
const latency = update.latency;
if (latency) {
if (!this.intervalLatencyMin[intervalIndex] ||
latency < this.intervalLatencyMin[intervalIndex]) {
this.intervalLatencyMin[intervalIndex] = latency;
}
if (!this.intervalLatencyMax[intervalIndex] ||
latency > this.intervalLatencyMax[intervalIndex]) {
this.intervalLatencyMax[intervalIndex] = latency;
}
this.intervalLatencySum[intervalIndex] += latency;
this.intervalLatencyCount[intervalIndex]++;
this.intervalLatencyAvg[intervalIndex] =
this.intervalLatencySum[intervalIndex] / this.intervalLatencyCount[intervalIndex];
}
}
if (this.disposed) {
this.updateSemaphore.dispose();
}
}
finally {
for (const eventRegistration of eventRegistrations) {
eventRegistration.dispose();
}
}
}
updateInterval(time) {
let intervalIndex = Math.floor(time / this.intervalMs);
if (intervalIndex >= this.intervalCount) {
// Expand as needed to accomodate the current time interval.
while (intervalIndex >= this.maxIntervals) {
this.expandIntervals();
intervalIndex = Math.floor(time / this.intervalMs);
}
this.count = intervalIndex + 1;
}
return intervalIndex;
}
expandIntervals() {
const combineLatency = (a, b, f) => a === 0 ? b : b === 0 ? a : f(a, b);
const halfMaxIntervals = this.maxIntervals / 2;
for (let i = 0; i < halfMaxIntervals; i++) {
const iA = 2 * i;
const iB = 2 * i + 1;
this.intervalBytesSent[i] = this.intervalBytesSent[iA] + this.intervalBytesSent[iB];
this.intervalBytesReceived[i] =
this.intervalBytesReceived[iA] + this.intervalBytesReceived[iB];
this.intervalLatencyMin[i] = combineLatency(this.intervalLatencyMin[iA], this.intervalLatencyMin[iB], Math.min);
this.intervalLatencyMax[i] = combineLatency(this.intervalLatencyMax[iA], this.intervalLatencyMax[iB], Math.max);
this.intervalLatencySum[i] = this.intervalLatencySum[iA] + this.intervalLatencySum[iB];
const countSum = this.intervalLatencyCount[iA] + this.intervalLatencyCount[iB];
this.intervalLatencyCount[i] = countSum;
this.intervalLatencyAvg[i] =
countSum === 0 ? 0 : this.intervalLatencySum[i] / this.intervalLatencyCount[i];
}
this.intervalBytesSent.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalBytesReceived.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalLatencyMin.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalLatencyMax.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalLatencySum.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalLatencyCount.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalLatencyAvg.fill(0, halfMaxIntervals, this.maxIntervals);
this.intervalMs *= 2;
}
dispose() {
this.disposed = true;
// The semaphore will be disposed after all remaining updates have been processed.
this.updateSemaphore.tryRelease();
}
/**
* Serializes the session contour into a compact form suitable for recording in
* logs or telemetry.
*
* This compact serialization format uses one byte per metric per interval, so there is
* some loss of precision, but generally not so much that it affects a visualization. A
* scale factor for each metric is automatically determined and included in the serialized
* header. The size of the serialized encoded data will be a little under 7 bytes per
* interval. With the default interval maximum (256), that comes out to less than 1.75 KB.
*
* Use the code in `SessionContour.kql` to decode and chart this output in
* Azure Data Explorer.
*/
export() {
// Time and value scales are in log2 form, determined based on the maximum
// value in each series. This allows for a reasonable range of precision for each
// value (with byte values ranging from 0-255). For example a max latency in the
// 500ms range will get a scale factor of 1 (because ceil(log2(500/255)) = 1), so
// each serialized value (0-255) is half the actual value (0-510).
const getScale = (values) => Math.max(0, Math.ceil(Math.log2(Math.max(...values) / 255)));
const applyReverseScale = (value, scale) => Math.round(value / Math.pow(2, scale));
const bytes = Buffer.alloc(3 + (2 + this.intervalCount) * 5);
const version = 1;
const timeScale = Math.log2(this.interval / SessionContour.initialInterval);
bytes[0] = version;
bytes[1] = 5; // Number of metrics per interval
bytes[2] = timeScale;
bytes[3] = getScale(this.latencyMinMs);
bytes[4] = getScale(this.latencyMaxMs);
bytes[5] = getScale(this.latencyAverageMs);
bytes[6] = getScale(this.bytesSent);
bytes[7] = getScale(this.bytesReceived);
bytes[8] = SessionMetric.latencyMin;
bytes[9] = SessionMetric.latencyMax;
bytes[10] = SessionMetric.latencyAverage;
bytes[11] = SessionMetric.bytesSent;
bytes[12] = SessionMetric.bytesReceived;
for (let i = 0; i < this.intervalCount; i++) {
const offset = 13 + 5 * i;
bytes[offset + 0] = applyReverseScale(this.intervalLatencyMin[i], bytes[3]);
bytes[offset + 1] = applyReverseScale(this.intervalLatencyMax[i], bytes[4]);
bytes[offset + 2] = applyReverseScale(this.intervalLatencyAvg[i], bytes[5]);
bytes[offset + 3] = applyReverseScale(this.intervalBytesSent[i], bytes[6]);
bytes[offset + 4] = applyReverseScale(this.intervalBytesReceived[i], bytes[7]);
}
return bytes.toString('base64');
}
/**
* Deserializes a session contour that was previously exported.
*
* Due to loss in precision, some values in the deserialized contour will not exactly match
* the original, but they will be close.
*/
static import(contourBase64) {
const bytes = Buffer.from(contourBase64, 'base64');
if (bytes.length < 3) {
throw new Error('Invalid session contour string.');
}
const version = bytes[0];
const metricsPerInterval = bytes[1];
const timeScale = bytes[2];
if (version !== 1) {
throw new Error(`Unsupported session contour version: ${version}`);
}
const intervalCount = (bytes.length - 3) / metricsPerInterval - 2;
if (intervalCount < 1 || bytes.length !== 3 + metricsPerInterval * (intervalCount + 2)) {
throw new Error('Incomplete session contour string.');
}
const maxIntervals = Math.pow(2, Math.ceil(Math.log2(intervalCount)));
const sessionContour = new SessionContour(maxIntervals);
sessionContour.intervalMs = Math.pow(2, timeScale) * SessionContour.initialInterval;
sessionContour.count = intervalCount;
const scales = new Array(metricsPerInterval);
for (let m = 0; m < metricsPerInterval; m++) {
scales[m] = Math.pow(2, bytes[3 + m]);
}
const ids = new Array(metricsPerInterval);
for (let m = 0; m < metricsPerInterval; m++) {
ids[m] = bytes[3 + metricsPerInterval + m];
}
for (let i = 0; i < intervalCount; i++) {
const offset = 3 + (2 + i) * metricsPerInterval;
for (let m = 0; m < metricsPerInterval; m++) {
switch (ids[m]) {
case SessionMetric.latencyMin:
sessionContour.intervalLatencyMin[i] = bytes[offset + m] * scales[m];
break;
case SessionMetric.latencyMax:
sessionContour.intervalLatencyMax[i] = bytes[offset + m] * scales[m];
break;
case SessionMetric.latencyAverage:
sessionContour.intervalLatencyAvg[i] = sessionContour.intervalLatencySum[i] =
bytes[offset + m] * scales[m];
sessionContour.intervalLatencyCount[i] = bytes[offset + m] === 0 ? 0 : 1;
break;
case SessionMetric.bytesSent:
sessionContour.intervalBytesSent[i] = bytes[offset + m] * scales[m];
break;
case SessionMetric.bytesReceived:
sessionContour.intervalBytesReceived[i] = bytes[offset + m] * scales[m];
break;
default:
// Ignore any unknown metrics
break;
}
}
}
return sessionContour;
}
}
exports.SessionContour = SessionContour;
SessionContour.initialInterval = 1000; // 1 second (in milliseconds)
var SessionMetric;
(function (SessionMetric) {
SessionMetric[SessionMetric["none"] = 0] = "none";
SessionMetric[SessionMetric["latencyMin"] = 1] = "latencyMin";
SessionMetric[SessionMetric["latencyMax"] = 2] = "latencyMax";
SessionMetric[SessionMetric["latencyAverage"] = 3] = "latencyAverage";
SessionMetric[SessionMetric["bytesSent"] = 11] = "bytesSent";
SessionMetric[SessionMetric["bytesReceived"] = 12] = "bytesReceived";
})(SessionMetric || (SessionMetric = {}));
//# sourceMappingURL=sessionContour.js.map