@twilio/rtc-diagnostics
Version:
Various diagnostics functions to help analyze connections to Twilio
608 lines (536 loc) • 21 kB
text/typescript
import { EventEmitter } from 'events';
import {
BITRATE_TEST_TIMEOUT_MS,
BYTES_KEEP_BUFFERED,
MAX_NUMBER_PACKETS,
MIN_BITRATE_FAIL_COUNT,
MIN_BITRATE_SAMPLE_COUNT,
MIN_BITRATE_THRESHOLD,
TEST_PACKET,
WarningName,
} from './constants';
import { DiagnosticError } from './errors/DiagnosticError';
import { TimeMeasurement } from './types';
import {
getRTCIceCandidateStatsReport,
RTCIceCandidateStats,
RTCIceCandidateStatsReport,
RTCSelectedIceCandidatePairStats,
} from './utils/candidate';
export declare interface MediaConnectionBitrateTest {
/**
* Raised every second with a `bitrate` parameter in kbps which represents the connection's bitrate since the last time this event was raised.
* The bitrate value is limited by either your downlink or uplink, whichever is lower.
* For example, if your downlink and uplink is 50mbps and 10mbps respectively, bitrate value will not exceed 10mbps.
* @param event [[MediaConnectionBitrateTest.Events.Bitrate]].
* @param listener A callback with a `bitrate`(kbps) parameter since the last time this event was raised.
* @returns This [[MediaConnectionBitrateTest]] instance.
* @event
*/
on(
event: MediaConnectionBitrateTest.Events.Bitrate,
listener: (bitrate: number) => any,
): this;
/**
* Raised when the test encounters an error.
* When this happens, the test will immediately stop and emit [[MediaConnectionBitrateTest.Events.End]].
* @param event [[MediaConnectionBitrateTest.Events.Error]].
* @param listener A callback with a [[DiagnosticError]] parameter.
* @returns This [[MediaConnectionBitrateTest]] instance.
* @event
*/
on(
event: MediaConnectionBitrateTest.Events.Error,
listener: (error: DiagnosticError) => any,
): this;
/**
* Raised upon completion of the test.
* @param event [[MediaConnectionBitrateTest.Events.End]].
* @param listener A callback with a [[MediaConnectionBitrateTest.Report]] parameter.
* @returns This [[MediaConnectionBitrateTest]] instance.
* @event
*/
on(
event: MediaConnectionBitrateTest.Events.End,
listener: (report: MediaConnectionBitrateTest.Report) => any,
): this;
/**
* Raised when the test encounters a non-fatal warning during its run-time.
* @param event [[MediaConnectionBitrateTest.Events.Warning]].
* @param listener A callback with a [[WarningName]] parameter.
* @returns This [[MediaConnectionBitrateTest]] instance.
* @event
*/
on(
event: MediaConnectionBitrateTest.Events.Warning,
listener: (warningName: WarningName) => any,
): this;
/**
* Raised when the test clears a previously encountered non-fatal warning during its run-time.
* @param event [[MediaConnectionBitrateTest.Events.WarningCleared]].
* @param listener A callback with a [[WarningName]] parameter.
* @returns This [[MediaConnectionBitrateTest]] instance.
* @event
*/
on(
event: MediaConnectionBitrateTest.Events.WarningCleared,
listener: (warningName: WarningName) => any,
): this;
}
/**
* MediaConnectionBitrateTest uses two [RTCPeerConnections](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection)
* connected via a [Twilio Network Traversal Service](https://www.twilio.com/docs/stun-turn).
* Using [RTCDataChannel](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel), one RTCPeerConnection will saturate the data channel buffer and will
* constantly send data packets to the other RTCPeerConnection. The receiving peer will measure the bitrate base on the amount of packets received every second.
* See [[MediaConnectionBitrateTest.Options.iceServers]] for information how to use Twilio NTS.
*/
export class MediaConnectionBitrateTest extends EventEmitter {
/**
* Name of this test
*/
static readonly testName: string = 'media-connection-bitrate-test';
/**
* Active warnings to keep track of.
*/
readonly activeWarnings: Set<WarningName> = new Set();
/**
* Interval id for checking bitrate
*/
private _checkBitrateIntervalId: NodeJS.Timer | undefined;
/**
* A timestamp of when the test ends.
*/
private _endTime: number | null = null;
/**
* Errors detected during the test
*/
private _errors: DiagnosticError[] = [];
/**
* An array of WebRTC stats for the ICE candidates gathered when connecting to media.
*/
private _iceCandidateStats: RTCIceCandidateStats[] = [];
/**
* Number of bytes received the last time it was checked
*/
private _lastBytesChecked: number = 0;
/**
* Last timestamp when the bytes received was checked
*/
private _lastCheckedTimestamp: number = 0;
/**
* The options passed to [[MediaConnectionBitrateTest]] constructor.
*/
private _options: MediaConnectionBitrateTest.ExtendedOptions;
/**
* The RTCPeerConnection that will receive data
*/
private _pcReceiver: RTCPeerConnection;
/**
* The RTCPeerConnection that will send data
*/
private _pcSender: RTCPeerConnection;
/**
* RTCDataChannel to use for sending data
*/
private _rtcDataChannel: RTCDataChannel | undefined;
/**
* A WebRTC stats for the ICE candidate pair used to connect to media, if candidates were selected.
*/
private _selectedIceCandidatePairStats: RTCSelectedIceCandidatePairStats | undefined;
/**
* Interval id for sending data
*/
private _sendDataIntervalId: NodeJS.Timer | undefined;
/**
* A timestamp of when the test starts. This is set during initialization of the test
* and not when the test succesfully starts.
*/
private _startTime: number;
/**
* Timeout reference that should be cleared when we receive any data. If this
* times out, it means something has timed out our [[MediaConnectionBitrateTest]].
*/
private _timeout: NodeJS.Timer;
/**
* Total number of bytes received by the receiver RTCPeerConnection
*/
private _totalBytesReceived: number = 0;
/**
* Bitrate (kbps) values collected during the test
*/
private _values: number[] = [];
/**
* Construct a [[MediaConnectionBitrateTest]] instance. The test will start immediately.
* Test should be allowed to run for a minimum of 8 seconds. To stop the test, call [[MediaConnectionBitrateTest.stop]].
* @constructor
* @param options
*/
constructor(options: MediaConnectionBitrateTest.ExtendedOptions) {
super();
this._options = { ...options };
const iceServers = this._options.iceServers;
this._pcReceiver = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'relay' });
this._pcSender = new RTCPeerConnection({ iceServers });
this._pcReceiver.onicecandidate = (event: RTCPeerConnectionIceEvent) => this._onIceCandidate(this._pcSender, event);
this._pcSender.onicecandidate = (event: RTCPeerConnectionIceEvent) => this._onIceCandidate(this._pcReceiver, event);
this._setupNetworkListeners(this._pcSender);
this._startTime = Date.now();
// Return before starting the test to allow consumer
// to listen and capture errors
setTimeout(() => {
this._setupDataChannel();
this._startTest();
});
this._timeout = setTimeout(() => {
this._onError(`Network timeout; exceeded limit of ${BITRATE_TEST_TIMEOUT_MS}ms`);
}, BITRATE_TEST_TIMEOUT_MS);
}
/**
* Stops the current test.
*/
stop(): void {
clearTimeout(this._timeout!);
clearInterval(this._sendDataIntervalId!);
clearInterval(this._checkBitrateIntervalId!);
if (typeof this._endTime !== 'number' || this._endTime === 0) {
this._pcSender.close();
this._pcReceiver.close();
this._endTime = Date.now();
this.emit(MediaConnectionBitrateTest.Events.End, this._getReport());
}
}
/**
* Calculate bitrate by comparing bytes received between current time and the last time it was checked
*/
private _checkBitrate(): void {
// No data yet
if (!this._lastCheckedTimestamp || !this._lastBytesChecked) {
this._lastCheckedTimestamp = Date.now();
this._lastBytesChecked = this._totalBytesReceived;
return;
}
// Calculate bitrate in kbps
const now = Date.now();
const bitrate = 8 * (this._totalBytesReceived - this._lastBytesChecked) / (now - this._lastCheckedTimestamp);
if (bitrate > 0) {
clearTimeout(this._timeout!);
}
this._lastCheckedTimestamp = now;
this._lastBytesChecked = this._totalBytesReceived;
this._values.push(bitrate);
this.emit(MediaConnectionBitrateTest.Events.Bitrate, bitrate);
this._maybeEmitWarning();
}
/**
* Generate and returns the report for this test
*/
private _getReport(): MediaConnectionBitrateTest.Report {
let averageBitrate = this._values
.reduce((total: number, value: number) => total += value, 0) / this._values.length;
averageBitrate = isNaN(averageBitrate) ? 0 : averageBitrate;
const testTiming: TimeMeasurement = { start: this._startTime };
if (this._endTime) {
testTiming.end = this._endTime;
testTiming.duration = this._endTime - this._startTime;
}
const report: MediaConnectionBitrateTest.Report = {
averageBitrate,
errors: this._errors,
iceCandidateStats: this._iceCandidateStats,
testName: MediaConnectionBitrateTest.testName,
testTiming,
values: this._values,
};
if (this._selectedIceCandidatePairStats) {
report.selectedIceCandidatePairStats = this._selectedIceCandidatePairStats;
}
return report;
}
/**
* Check current bitrate values and emit warnings
* if [[WarningName.LowBitrate]] criteria are met.
*/
private _maybeEmitWarning(): void {
if (this._values.length < MIN_BITRATE_SAMPLE_COUNT) {
return;
}
if (this._values
.slice(this._values.length - MIN_BITRATE_SAMPLE_COUNT)
.filter((bitrate: number) => bitrate < (this._options.minBitrateThreshold ?? MIN_BITRATE_THRESHOLD))
.length > MIN_BITRATE_FAIL_COUNT) {
if (!this.activeWarnings.has(WarningName.LowBitrate)) {
this.activeWarnings.add(WarningName.LowBitrate);
this.emit(MediaConnectionBitrateTest.Events.Warning, WarningName.LowBitrate);
}
} else if (this.activeWarnings.has(WarningName.LowBitrate)) {
this.activeWarnings.delete(WarningName.LowBitrate);
this.emit(MediaConnectionBitrateTest.Events.WarningCleared, WarningName.LowBitrate);
}
}
/**
* Called when an error is detected
* @param message - Message that describes the error
* @param error - The error object
* @param isFatal - Whether this is a fatal error
*/
private _onError(message: string, error?: DOMError): void {
const diagnosticError = new DiagnosticError(error, message);
this._errors.push(diagnosticError);
this.emit(MediaConnectionBitrateTest.Events.Error, diagnosticError);
this.stop();
}
/**
* Called when a local candidate is gathered
* @param remotePc - The remote RTCPeerConnection
*/
private _onIceCandidate(remotePc: RTCPeerConnection, event: RTCPeerConnectionIceEvent): void {
if (event.candidate) {
remotePc.addIceCandidate(event.candidate)
.catch((error: DOMError) => this._onError('Unable to add candidate', error));
}
}
/**
* Called when a message is received
* @param event
*/
private _onMessageReceived(event: MessageEvent) {
this._totalBytesReceived += event.data.length;
}
/**
* Called when an answer is created by the receiver
* @param answer - The answer session description created by the receiver RTCPeerConnection
*/
private _onReceiverAnswerCreated(answer: RTCSessionDescriptionInit): Promise<void | [void, void]> {
return Promise.all([
this._pcReceiver.setLocalDescription(answer),
this._pcSender.setRemoteDescription(answer),
]).catch((error: DOMError) =>
this._onError('Unable to set local or remote description from createAnswer', error));
}
/**
* Called when an offer has been created by the sender
* @param offer - The offer session description created by the sender RTCPeerConnection
*/
private _onSenderOfferCreated(offer: RTCSessionDescriptionInit): Promise<void | [void, void]> {
return Promise.all([
this._pcSender.setLocalDescription(offer),
this._pcReceiver.setRemoteDescription(offer),
]).catch((error: DOMError) =>
this._onError('Unable to set local or remote description from createOffer', error));
}
/**
* Send packets using data channel
*/
private _sendData(): void {
if (!this._rtcDataChannel || this._rtcDataChannel.readyState !== 'open') {
return;
}
for (let i = 0; i < MAX_NUMBER_PACKETS; ++i) {
if (this._rtcDataChannel.bufferedAmount >= BYTES_KEEP_BUFFERED) {
break;
}
this._rtcDataChannel.send(TEST_PACKET);
}
}
/**
* Setup data channel for sending data
*/
private _setupDataChannel(): void {
try {
this._rtcDataChannel = this._pcSender.createDataChannel('sender');
} catch (e) {
this._onError('Error creating data channel', e);
return;
}
this._rtcDataChannel.onopen = () => {
this._sendDataIntervalId = setInterval(() => this._sendData(), 1);
this._checkBitrateIntervalId = setInterval(() => this._checkBitrate(), 1000);
};
this._pcReceiver.ondatachannel = (dataChannelEvent: RTCDataChannelEvent) => {
dataChannelEvent.channel.onmessage = (event: MessageEvent) => this._onMessageReceived(event);
};
}
/**
* Setup network related event listeners on a PeerConnection
* @param pc
*/
private _setupNetworkListeners(pc: RTCPeerConnection) {
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'connected') {
(this._options.getRTCIceCandidateStatsReport || getRTCIceCandidateStatsReport)(this._pcSender)
.then((statsReport: RTCIceCandidateStatsReport) => {
this._iceCandidateStats = statsReport.iceCandidateStats;
this._selectedIceCandidatePairStats = statsReport.selectedIceCandidatePairStats;
})
.catch((error: DOMError) => {
this._onError('Unable to generate WebRTC stats report', error);
});
}
};
}
/**
* Starts the test.
*/
private _startTest(): void {
if (!this._options.iceServers) {
return this._onError('No iceServers found', undefined);
}
this._pcSender.createOffer()
.then((offer: RTCSessionDescriptionInit) => this._onSenderOfferCreated(offer))
.then(() => {
return this._pcReceiver.createAnswer()
.then((answer: RTCSessionDescriptionInit) => this._onReceiverAnswerCreated(answer))
.catch((error: Error) => this._onError('Unable to create answer', error));
}).catch((error: Error) => this._onError('Unable to create offer', error));
}
}
export namespace MediaConnectionBitrateTest {
/**
* Possible events that a [[MediaConnectionBitrateTest]] might emit. See [[MediaConnectionBitrateTest.on]].
*/
export enum Events {
Bitrate = 'bitrate',
End = 'end',
Error = 'error',
Warning = 'warning',
WarningCleared = 'warning-cleared',
}
/**
* Options that may be passed to [[MediaConnectionBitrateTest]] constructor for internal testing.
* @internalapi
*/
export interface ExtendedOptions extends Options {
/**
* A function that generates a WebRTC stats report containing relevant information about ICE candidates for
* the given [PeerConnection](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection)
*/
getRTCIceCandidateStatsReport?: (peerConnection: RTCPeerConnection) => Promise<RTCIceCandidateStatsReport>;
}
/**
* Options passed to [[MediaConnectionBitrateTest]] constructor.
*/
export interface Options {
/**
* The array of [RTCIceServer](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer) configurations to use.
* You need to provide STUN and TURN server configurations to ensure that your network bitrate is tested.
* You can use [Twilio's Network Traversal Service](https://www.twilio.com/stun-turn) to get STUN and TURN server configurations.
*
* The following example demonstrates how to use the [twilio npm module](https://www.npmjs.com/package/twilio) to generate
* credentials with a ttl of 120 seconds, using UDP protocol, and specifying ashburn as the
* [edge location](https://www.twilio.com/docs/global-infrastructure/edge-locations).
*
* ```ts
* import Client from 'twilio';
* import { testMediaConnectionBitrate } from '@twilio/rtc-diagnostics';
*
* // Generate the STUN and TURN server credentials with a ttl of 120 seconds
* const client = Client(twilioAccountSid, authToken);
* const token = await client.tokens.create({ ttl: 120 });
* const iceServers = [];
*
* // Grab STUN server
* iceServers.push({ urls: token.iceServers.find(item => item.urls.includes('stun:global.stun.twilio.com')).urls });
*
* // Grab TURN servers.
* // Use the following filters if you want to use UDP, TCP, or TLS
* // UDP: turn:global.turn.twilio.com:3478?transport=udp
* // TCP: turn:global.turn.twilio.com:3478?transport=tcp
* // TLS: turn:global.turn.twilio.com:443?transport=tcp
* let { urls, username, credential } = token.iceServers
* .find(item => item.url === 'turn:global.turn.twilio.com:3478?transport=udp');
* iceServers.push({ urls, username, credential });
*
* // By default, global will be used as the default edge location.
* // You can replace global with a specific edge name.
* iceServers.forEach(iceServer => {
* iceServer.urls = iceServer.urls.replace('global', 'ashburn');
* });
*
* // Use the TURN credentials using the iceServers parameter
* const mediaConnectionBitrateTest = testMediaConnectionBitrate({ iceServers });
* ```
* Note, for production code, the above code should not be executed client side as it requires the authToken which must be treated like a private key.
*/
iceServers: RTCIceServer[];
/**
* The minimum bitrate in kilobits per second expected to be available.
* This value is used to determine when to raise [[WarningName.LowBitrate]] warning.
* @default 100
*/
minBitrateThreshold?: number;
}
/**
* Represents the report generated from a [[MediaConnectionBitrateTest]].
*/
export interface Report {
/**
* Average bitrate calculated during the test.
*/
averageBitrate: number;
/**
* Any errors that occurred during the test.
*/
errors: DiagnosticError[];
/**
* An array of WebRTC stats for the ICE candidates gathered when connecting to media.
*/
iceCandidateStats: RTCIceCandidateStats[];
/**
* A WebRTC stats for the ICE candidate pair used to connect to media, if candidates were selected.
*/
selectedIceCandidatePairStats?: RTCSelectedIceCandidatePairStats;
/**
* The name of the test.
*/
testName: typeof MediaConnectionBitrateTest.testName;
/**
* Time measurements of test run time.
*/
testTiming: TimeMeasurement;
/**
* Bitrate values collected during the test.
*/
values: number[];
}
}
/**
* The test uses two [RTCPeerConnections](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection) connected via a Twilio TURN server.
* Using [RTCDataChannel](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel), one RTCPeerConnection will saturate the data channel buffer and will
* constantly send data packets to the other RTCPeerConnection. The receiving peer will measure the bitrate base on the amount of packets received every second.
*
* Example:
* ```ts
* import { testMediaConnectionBitrate } from '@twilio/rtc-diagnostics';
*
* const mediaConnectionBitrateTest = testMediaConnectionBitrate({
* iceServers: [{
* urls: 'stun:global.stun.twilio.com:3478?transport=udp',
* }, {
* credential: 'bar',
* username: 'foo',
* urls: 'turn:global.turn.twilio.com:3478?transport=udp',
* }],
* });
*
* mediaConnectionBitrateTest.on('bitrate', (bitrate) => {
* console.log(bitrate);
* });
*
* mediaConnectionBitrateTest.on('error', (error) => {
* console.log(error);
* });
*
* mediaConnectionBitrateTest.on('end', (report) => {
* console.log(report);
* });
*
* // Run the test for 15 seconds
* setTimeout(() => {
* mediaConnectionBitrateTest.stop();
* }, 15000);
* ```
* See [[MediaConnectionBitrateTest.Options.iceServers]] for details on how to obtain STUN and TURN server configurations.
*/
export function testMediaConnectionBitrate(options: MediaConnectionBitrateTest.Options): MediaConnectionBitrateTest {
return new MediaConnectionBitrateTest(options);
}