├── .babelrc.json ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── example └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── api-wrapper.ts ├── constants.ts ├── index.ts ├── sdk_integrations.ts ├── types │ ├── api.ts │ └── index.ts ├── user.ts └── utils.ts └── tsconfig.json /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "minify", 4 | ["@babel/env", { 5 | "targets": "defaults" 6 | }], 7 | "@babel/typescript" 8 | ], 9 | "plugins": [ 10 | ["@babel/transform-runtime"] 11 | ] 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | example -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrei Onel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PeerMetrics SDK 2 | 3 | 4 | 5 | This is the repo for the `PeerMetrics` JS SDK. 6 | 7 | Peer metrics is a service that helps you collect events and metrics for your `WebRTC` connections. 8 | 9 | You can read more about the service on [peermetrics.io](https://peermetrics.io/). 10 | 11 | ### Contents 12 | 13 | 1. [Install](#install) 14 | 2. [Usage](#usage) 15 | 1. [Options](#options) 16 | 2. [API](#api) 17 | 3. [Static methods](#static-methods) 18 | 3. [SDK integrations](#sdk-integrations) 19 | 1. [LiveKit](#livekit) 20 | 2. [Twilio Video](#twilio-video) 21 | 3. [Mediasoup](#mediasoup) 22 | 4. [Janus](#janus) 23 | 5. [Vonage](#vonage) 24 | 6. [Agora](#agora) 25 | 7. [Pion](#pion) 26 | 8. [SimplePeer](#simplepeer) 27 | 4. [Browser support](#browser-support) 28 | 5. [Use cases](#use-cases) 29 | 6. [License](#license) 30 | 31 | 32 | 33 | ## Install 34 | 35 | To use the sdk you can install the package through npm: 36 | 37 | ```sh 38 | npm install @peermetrics/sdk 39 | ``` 40 | 41 | Then 42 | 43 | ```js 44 | import { PeerMetrics } from '@peermetrics/sdk' 45 | ``` 46 | 47 | Or load it directly in the browser: 48 | 49 | ```html 50 | 51 | ``` 52 | 53 | 54 | 55 | ## Usage 56 | 57 | To use the sdk you need a peer metrics account. Once you've created an organization and an app you will receive an `apiKey` 58 | 59 | ```js 60 | let peerMetrics = new PeerMetrics({ 61 | apiKey: '7090df95cd247f4aa735779636b202', 62 | userId: '1234', 63 | userName: 'My user', 64 | conferenceId: 'conference-1', 65 | conferenceName: 'Conference from 4pm', 66 | appVersion: '1.0.1' 67 | }) 68 | // initialize the sdk 69 | await peerMetrics.initialize() 70 | ``` 71 | In order to start tracking a connection, use the `.addConnection()` method: 72 | ```js 73 | let pc1 = new RTCPeerConnection({...}) 74 | await peerMetrics.addConnection({ 75 | pc: pc1, 76 | peerId: '1' # any string that helps you identify this peer 77 | }) 78 | ``` 79 | ### Options 80 | To instantiate the sdk you have the following options: 81 | ```js 82 | let peerMetrics = new PeerMetrics({ 83 | // the api key associated to the app created inside your account 84 | apiKey: '', // String, mandatory 85 | 86 | // a string that will help you indentify this user inside your peer metrics account 87 | userId: '1234', // String, mandatory 88 | 89 | // a readable name for this user 90 | userName: 'My user', // String, optional 91 | 92 | // an ID to identify this conference 93 | conferenceId: 'conference-1', // String, mandatory 94 | 95 | // a readable name for this conference 96 | conferenceName: 'Conference from 4pm', // String, optional 97 | 98 | // the version of your app. this helps you filter conferecens/stats/issues for a specific version 99 | appVersion: '0.0.1', // String, optional 100 | 101 | // if the sdk can't be run on the other side of the call (for example a SFU) you can still collect some stats for that using this flag 102 | remote: true, // Boolean, optional, Default: true 103 | 104 | // Object, optional: if you would like to save some additional info about this user 105 | // there is a limit of 5 attributes that can be added. only string, number, boolean supported as values 106 | meta: { 107 | isRegistered: true, 108 | plan: 'premium' 109 | }, 110 | 111 | // if you would like to save events from some page events 112 | pageEvents: { 113 | pageVisibility: false // when the user focuses on another tab 114 | } 115 | }) 116 | ``` 117 | 118 | ### API 119 | 120 | #### `.initialize()` 121 | 122 | Used to initialize the SDK. Returns a promise that rejects if any problems were encountered (for example invalid apiKey, over quota, etc) 123 | 124 | 125 | 126 | #### `.addSdkIntegration(options)` 127 | 128 | Used to integrate with different SDKs. See [here](#sdk-integrations) list for options. 129 | 130 | 131 | 132 | #### `.addConnection(options)` 133 | Adds a connection to the watch list. 134 | `options` 135 | 136 | ```js 137 | { 138 | `pc`: pc, // RTCPeerConnection instance 139 | `peerId`: 'peer-1' // String, a unique Id to identify this peer 140 | } 141 | ``` 142 | 143 | **Note:** Monitoring of a peer will automatically end when the connection is closed. 144 | 145 | 146 | 147 | #### `.endCall()` 148 | 149 | The helper method stops listening to events on all connections and also ends the current session of this user. 150 | 151 | This is useful for the case when multiple conferences happen consecutively without the user refreshing the page. 152 | 153 | 154 | 155 | #### `.removeConnection(options)` 156 | 157 | Stop listening for events on a specific connection. 158 | 159 | `options` can be one of two options: 160 | 161 | ```js 162 | { 163 | 'connectionId': '123' // the one returned after calling `.addConnection()` 164 | } 165 | ``` 166 | 167 | OR 168 | 169 | ```js 170 | { 171 | 'pc': pc // the `RTCPeerConnection` instance 172 | } 173 | ``` 174 | 175 | 176 | 177 | #### `.removePeer(peerId)` 178 | 179 | Stop listening for events/stats on all the connections for this peer 180 | 181 | 182 | 183 | #### `.addEvent(object)` 184 | 185 | Add a custom event for this participant. Example: 186 | 187 | ```js 188 | { 189 | eventName: 'open settings', 190 | description: 'user opened settings dialog' 191 | } 192 | ``` 193 | 194 | `object` doesn't require a specific structure, but if the `eventName` attribute is present, it will be displayed on the event timeline in your dashboard. 195 | 196 | This helps you get a better context of the actions the user took that might have impacted the WebRTC experience. 197 | 198 | 199 | 200 | #### `.mute()`/`.unmute()` 201 | 202 | Save event that user muted/unmuted the microphone 203 | 204 | 205 | 206 | ### Static methods 207 | 208 | #### `.getPageUrl()` 209 | 210 | Method used to get the peer metrics page url for a conference or a participants. Useful if you would like to link to one of these pages in your internal website/tool. 211 | 212 | ```js 213 | await PeerMetrics.getPageUrl({ 214 | apiKey: 'you-api-key', // mandatory 215 | 216 | userId: 'my-user-id', // the userId provided to peer metrics during a call 217 | // or 218 | conferenceId: 'confence-id' // an ID provided for a past conference 219 | }) 220 | ``` 221 | 222 | 223 | 224 | ## SDK integrations 225 | 226 | You can use `PeerMetrics` with many well known WebRTC SDKs. 227 | 228 | In order to integrate you can initialize the SDK as usually and then call `.addSdkIntegration()` with special options: 229 | 230 | ```js 231 | let peerMetrics = new PeerMetrics({ 232 | apiKey: '7090df95cd247f4aa735779636b202', 233 | userId: '1234', 234 | userName: 'My user', 235 | conferenceId: 'room-1', 236 | conferenceName: 'Call from 4pm' 237 | }) 238 | 239 | // initialize the SDK 240 | await peerMetrics.initialize() 241 | 242 | // call addSdkIntegration() with the appropriate options for each SDK 243 | await peerMetrics.addSdkIntegration(options) 244 | 245 | // That's it 246 | ``` 247 | 248 | The `options` object differs depending on the integration. 249 | 250 | **Note:** There's no need to call `addConnection()` anymore, the `PeerMetrics` SDK will take care of adding connection listeners and sending events. 251 | 252 | 253 | 254 | ### List of SDKs that `PeerMetrics` supports: 255 | 256 | ### LiveKit 257 | 258 | To integrate with [LiveKit' js sdk](https://github.com/livekit/client-sdk-js) you need to pass an instance of `Room`. 259 | 260 | **Note** You need at least version `v0.16.2` of `livekit-client`. 261 | 262 | ```js 263 | import { Room } from 'livekit-client' 264 | 265 | const room = new Room(roomOptions) 266 | 267 | peerMetrics.addSdkIntegration({ 268 | livekit: { 269 | room: room, // mandatory, the livekit client Room instance 270 | serverId: '', // string, optional, an ID to indentify the SFU server the user connects to (default: livekit-sfu-server) 271 | serverName: '' // string, optional, a more readable name for this server (default: LiveKit SFU Server) 272 | } 273 | }) 274 | ``` 275 | 276 | ### Twilio Video 277 | 278 | You can integrate with v2 of [Twilio Video SDK](https://github.com/twilio/twilio-video.js). To do that, you need to pass the instance of `Room`. For example: 279 | 280 | ```js 281 | import Video from 'twilio-video' 282 | 283 | Video.connect('$TOKEN', { name: 'room-name' }).then(room => { 284 | peerMetrics.addSdkIntegration({ 285 | twilioVideo: { 286 | room: room, // mandatory, the twilio video Room instance 287 | } 288 | }) 289 | }) 290 | 291 | ``` 292 | 293 | ### Mediasoup 294 | 295 | To integrate with [mediasoup](https://mediasoup.org/) you need to pass in the device instance: 296 | 297 | ```js 298 | import * as mediasoupClient from 'mediasoup-client' 299 | 300 | let device = new mediasoupClient.Device({ 301 | handlerName : this._handlerName 302 | }) 303 | 304 | peerMetrics.addSdkIntegration({ 305 | mediasoup: { 306 | device: device, // mandatory, the mediasoupClient.Device() instance 307 | serverId: '', // string, optional, an ID to indentify the SFU server the user connects to (default: mediasoup-sfu-server) 308 | serverName: '' // string, optional, a more readable name for this server (default: MediaSoup SFU Server) 309 | } 310 | }) 311 | ``` 312 | 313 | ### Janus 314 | 315 | If you are using the [Janus](https://janus.conf.meetecho.com/docs/JS.html) javascript sdk to create connections to Janus server, you can integrate by sending the plugin handler that result from calling `.attach()`. First thing: 316 | 317 | ```js 318 | let peerMetrics = new PeerMetrics({ 319 | ... 320 | }) 321 | await peerMetrics.initialize() 322 | ``` 323 | 324 | And then: 325 | 326 | ```js 327 | let janus = new Janus({ 328 | server: server, 329 | success: function() { 330 | // Attach to VideoCall plugin 331 | janus.attach({ 332 | plugin: "janus.plugin.videocall", 333 | opaqueId: opaqueId, 334 | success: function(pluginHandle) { 335 | peerMetrics.addSdkIntegration({ 336 | janus: { 337 | plugin: pluginHandle, // mandatory 338 | serverId: '', // string, optional, an ID for this SFU server (default: janus-sfu-server) 339 | serverName: '' // string, optional, a more readable name for this server (default: Janus SFU Server) 340 | } 341 | }) 342 | 343 | ... 344 | } 345 | }) 346 | } 347 | }) 348 | ``` 349 | 350 | ### Vonage 351 | 352 | To integrate with [Vonage](https://www.vonage.com/) SDK (previously Tokbox) you will need to load `PeerMetrics` before it. For example: 353 | 354 | ```html 355 | 356 | 361 | 362 | 363 | 364 | 365 | 366 | 378 | 379 | 380 | 381 | ``` 382 | 383 | ### Agora 384 | 385 | To integrate with [Agora](https://www.agora.io/) SDK you will need to load `PeerMetrics` before it. For example: 386 | 387 | ```html 388 | 389 | 394 | 395 | 396 | 397 | 398 | 399 | 411 | 412 | 413 | 414 | ``` 415 | 416 | Or, if you are using a bundler: 417 | 418 | ```js 419 | import { PeerMetrics } from '@peermetrics/sdk' 420 | // call wrapPeerConnection as soon as possible 421 | PeerMetrics.wrapPeerConnection() 422 | 423 | let peerMetrics = new PeerMetrics({...}) 424 | await peerMetrics.initialize() 425 | 426 | peerMetrics.addSdkIntegration({ 427 | agora: true 428 | }) 429 | ``` 430 | 431 | ### Pion 432 | 433 | Integrating with Pion is dead simple. If for example you are using [ion sdk js](https://github.com/pion/ion-sdk-js), just initialize peer metrics first and you are good to go: 434 | 435 | ```js 436 | import { Client, LocalStream, RemoteStream } from 'ion-sdk-js'; 437 | import { IonSFUJSONRPCSignal } from 'ion-sdk-js/lib/signal/json-rpc-impl'; 438 | import { PeerMetrics } from '@peermetrics/sdk' 439 | 440 | let peerMetrics = new PeerMetrics({...}) 441 | await peerMetrics.initialize() 442 | 443 | peerMetrics.addSdkIntegration({ 444 | pion: true 445 | }) 446 | 447 | // then continue with the usual things 448 | const signal = new IonSFUJSONRPCSignal("wss://ion-sfu:7000/ws"); 449 | const client = new Client(signal); 450 | signal.onopen = () => client.join("test session", "test uid") 451 | ``` 452 | 453 | You can pass additional details to `addSdkIntegration()` to better identify the SFU server the user is connecting to: 454 | 455 | ```js 456 | peerMetrics.addSdkIntegration({ 457 | pion: { 458 | serverId: 'pion-sfu-na', 459 | serverName: 'Pion SFU North America' 460 | } 461 | }) 462 | ``` 463 | 464 | ### SimplePeer 465 | 466 | To integrate with `SimplePeer` you would just need to pass the `RTCPeerConnection` to `PeerMetrics`. For example: 467 | 468 | ```js 469 | var peer = new SimplePeer({ 470 | initiator: true, 471 | config: iceServers, 472 | stream: stream, 473 | trickle: true 474 | }) 475 | 476 | peerMetrics.addConnection({ 477 | pc: peer._pc, 478 | peerId: peerId 479 | }) 480 | ``` 481 | 482 | 483 | 484 | ## Browser support 485 | 486 | Right now, the SDK is compatible with the latest version of Chromium based browsers (Chrome, Edge, Brave, etc), Firefox and Safari. 487 | 488 | ## Use cases 489 | 490 | #### Multiple calls on a page 491 | 492 | If you app permits the user to have multiple calls on page you'll need to initialize the `PeerMetrics` instance every time. 493 | 494 | For example: 495 | 496 | 1. On page load initialize the SDK as mentioned in the [docs](#usage) 497 | 498 | ```js 499 | let peerMetrics = new PeerMetrics({ 500 | apiKey: '7090df95cd247f4aa735779636b202', 501 | userId: '1234', 502 | userName: 'My user', 503 | conferenceId: 'conference-1', 504 | conferenceName: 'Conference from 4pm', 505 | appVersion: '1.0.1' 506 | }) 507 | ``` 508 | 509 | 2. Call `initialize()` 510 | 511 | ```js 512 | await peerMetrics.initialize() 513 | ``` 514 | 515 | 3. Call `addConnection()` or if you use one of our [SDK integration](#sdk-integrations), follow the steps for that specific case 516 | 517 | ```js 518 | await peerMetrics.addConnection({ 519 | pc: pc1, 520 | peerId: '1' # any string that helps you identify this peer 521 | }) 522 | ``` 523 | 524 | 4. At the end of the current call/conference, call `.endCall()` 525 | 526 | ```js 527 | await peerMetrics.endCall() 528 | ``` 529 | 530 | 5. To start monitoring again, call `initialize()` again with details for the new conference 531 | 532 | ```js 533 | await peerMetrics.initialize({ 534 | conferenceId: 'conference-2', 535 | conferenceName: 'Second Conference', 536 | }) 537 | ``` 538 | 539 | 6. Continue from step `3` 540 | 541 | 542 | 543 | ## License 544 | MIT 545 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 39 | 40 | 158 | 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@peermetrics/sdk", 3 | "version": "2.7.2", 4 | "description": "The peer metrics javascript SDK", 5 | "main": "dist/index.js", 6 | "repository": "https://github.com/peermetrics/sdk-js", 7 | "author": "Andrei Onel ", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "rollup -c", 11 | "serve": "http-server ./example", 12 | "watch": "rollup -c -w", 13 | "prepublishOnly": "npm run build", 14 | "pretest": "npm run build", 15 | "test": "echo 'Soon'" 16 | }, 17 | "types": "dist/index.d.ts", 18 | "engines": { 19 | "node": ">=8" 20 | }, 21 | "keywords": [ 22 | "webrtc", 23 | "webrtc-stats", 24 | "stats", 25 | "rtcpeerconnection", 26 | "analytics" 27 | ], 28 | "dependencies": { 29 | "@peermetrics/webrtc-stats": "5.7.1", 30 | "ua-parser-js": "1.0.33", 31 | "wretch": "1.7.10" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.13.10", 35 | "@babel/plugin-transform-runtime": "^7.13.10", 36 | "@babel/preset-env": "^7.13.10", 37 | "@babel/preset-typescript": "^7.13.0", 38 | "@rollup/plugin-babel": "^5.3.0", 39 | "@rollup/plugin-commonjs": "^11.1.0", 40 | "@rollup/plugin-node-resolve": "^7.1.3", 41 | "babel-preset-minify": "^0.5.1", 42 | "http-server": "^0.12.3", 43 | "rollup": "^2.51.1", 44 | "rollup-plugin-license": "^2.5.0", 45 | "rollup-plugin-node-polyfills": "^0.2.1", 46 | "rollup-plugin-terser": "^7.0.2", 47 | "rollup-plugin-ts": "^3.0.2", 48 | "standard": "^16.0.0", 49 | "tslib": "^2.1.0", 50 | "typescript": "^4.2.3" 51 | }, 52 | "standard": { 53 | "ignore": [ 54 | "**/dist/", 55 | "**/example/" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | import babel from '@rollup/plugin-babel' 4 | import resolve from '@rollup/plugin-node-resolve' 5 | import commonJS from '@rollup/plugin-commonjs' 6 | 7 | import nodePolyfills from 'rollup-plugin-node-polyfills' 8 | import { terser } from 'rollup-plugin-terser' 9 | import license from 'rollup-plugin-license' 10 | import ts from 'rollup-plugin-ts' 11 | 12 | import pkg from './package.json' 13 | import babelConfig from './.babelrc.json' 14 | 15 | const plugins = [ 16 | nodePolyfills(), 17 | resolve(), 18 | commonJS({ 19 | include: 'node_modules/**' 20 | }), 21 | ts(), 22 | license({ 23 | banner: { 24 | commentStyle: 'regular', 25 | content: { 26 | file: path.join(__dirname, 'LICENSE') 27 | } 28 | } 29 | }) 30 | ] 31 | 32 | export default [{ 33 | input: 'src/index.ts', 34 | output: [ 35 | { 36 | file: 'dist/browser.js', 37 | name: 'window', 38 | format: 'iife', 39 | extend: true 40 | }, 41 | { 42 | file: 'dist/browser.min.js', 43 | format: 'iife', 44 | name: 'window', 45 | extend: true, 46 | plugins: [ 47 | babel({ 48 | ...babelConfig, 49 | babelHelpers: 'runtime' 50 | }), 51 | terser() 52 | ] 53 | }, 54 | { 55 | file: pkg.main, 56 | format: 'cjs' 57 | } 58 | ], 59 | plugins: plugins 60 | }] 61 | -------------------------------------------------------------------------------- /src/api-wrapper.ts: -------------------------------------------------------------------------------- 1 | import wretch from 'wretch' 2 | 3 | import {log} from './utils' 4 | 5 | import type {User} from './user' 6 | import type { ApiInitializeData, MakeRequest, ConnectionEventData, SessionData } from './types' 7 | import type {Wretcher} from 'wretch' 8 | 9 | let externalApi: Wretcher 10 | 11 | let token = '' 12 | let start = 0 13 | 14 | const DEFAULT_OPTIONS = { 15 | batchConnectionEvents: false, 16 | connectionTimeoutValue: 500 17 | } 18 | 19 | const REQUEST_TIMEOUT = 10 * 1000 20 | 21 | const EXPONENTIAL_BACKOFF = 500 22 | const MAX_EXPONENTIAL_BACKOFF = 60 * 1000 23 | 24 | const UNRECOVERABLE_ERRORS = [ 25 | 'domain_not_allowed', 26 | 'quota_exceeded', 27 | 'invalid_api_key', 28 | 'app_not_recording', 29 | 'connection_ended', 30 | ] 31 | 32 | /** 33 | * An object to map endpoint names to URLs 34 | * @type {Object} 35 | */ 36 | let urlsMap = { 37 | 'session': '/sessions', 38 | 'events-getusermedia': '/events/get-user-media', 39 | 'events-browser': '/events/browser', 40 | 'connection': '/connection', 41 | 'batch-connection': '/connection/batch', 42 | 'stats': '/stats', 43 | 'track': '/tracks', 44 | 'getPageUrl': '/services/get-url' 45 | } 46 | 47 | export class ApiWrapper { 48 | private apiKey: string 49 | private apiRoot: string 50 | private user: User 51 | private mockRequests: boolean 52 | private unrecoverable: string[] = UNRECOVERABLE_ERRORS 53 | /** 54 | * If we should batch connections events 55 | * defaults to false and we'll let the server tell us if we should 56 | */ 57 | private batchConnectionEvents: boolean = DEFAULT_OPTIONS.batchConnectionEvents 58 | private connectionEvents: Array<[ConnectionEventData, DOMHighResTimeStamp]> = [] 59 | private connectionTimeout: number | null = null 60 | 61 | constructor (options) { 62 | this.apiKey = options.apiKey 63 | this.apiRoot = options.apiRoot 64 | 65 | this.user = options.user 66 | 67 | // debug options 68 | this.mockRequests = options.mockRequests 69 | 70 | externalApi = wretch() 71 | // Set the base url 72 | .url(this.apiRoot) 73 | .content('text/plain') 74 | .accept('application/json') 75 | .options({ 76 | mode: 'cors', 77 | cache: 'no-cache', 78 | redirect: 'follow' 79 | }) 80 | // .catcher(405, this._handleFailedRequest) 81 | } 82 | 83 | /* 84 | * Checks to see if the apiKey is valid 85 | * and if the account has enough ... 86 | * initialiaze the session 87 | * @return {Promise} The fetch promise 88 | */ 89 | async initialize (data: ApiInitializeData): Promise { 90 | let toSend = {...data} as any 91 | 92 | // add the user details 93 | // used to create the participant object 94 | toSend.userId = this.user.userId 95 | toSend.userName = this.user.userName 96 | toSend.apiKey = this.apiKey 97 | 98 | return this.makeRequest({ 99 | // this is the only hard coded path that should not change 100 | path: '/initialize', 101 | // @ts-ignore 102 | data: toSend 103 | }).then((response) => { 104 | if (response) { 105 | if (response.urls) { 106 | // update the urls map with the response from server 107 | urlsMap = {urlsMap, ...response.urls} 108 | } 109 | 110 | if (typeof response.batchConnectionEvents === 'boolean') { 111 | this.batchConnectionEvents = response.batchConnectionEvents 112 | } 113 | 114 | token = response.token 115 | } 116 | 117 | return response 118 | }) 119 | } 120 | 121 | async getPageUrl(data) { 122 | return this.makeRequest({ 123 | path: urlsMap['getPageUrl'], 124 | data: data 125 | }).then((response) => { 126 | return response.url 127 | }) 128 | } 129 | 130 | createSession(data) { 131 | return this.makeRequest({ 132 | path: urlsMap['session'], 133 | data: data 134 | }).then((response) => { 135 | if (response.token) { 136 | token = response.token 137 | } 138 | }) 139 | } 140 | 141 | /** 142 | * Used to save initial data about the current user 143 | * @return {Promise} The fetch promise 144 | */ 145 | addSessionDetails (data) { 146 | return this.makeRequest({ 147 | path: urlsMap['session'], 148 | method: 'put', 149 | data: data 150 | }) 151 | } 152 | 153 | sendPageEvent (data) { 154 | return this.makeRequest({ 155 | path: urlsMap['events-browser'], 156 | data: data 157 | }) 158 | } 159 | 160 | sendCustomEvent (data) { 161 | return this.makeRequest({ 162 | path: urlsMap['events-browser'], 163 | data: { 164 | eventName: 'custom', 165 | data: data 166 | } 167 | }) 168 | } 169 | 170 | sendMediaDeviceChange (devices) { 171 | return this.makeRequest({ 172 | path: urlsMap['events-browser'], 173 | data: { 174 | eventName: 'mediaDeviceChange', 175 | devices: devices 176 | } 177 | }) 178 | } 179 | 180 | saveGetUserMediaEvent (data) { 181 | return this.makeRequest({ 182 | path: urlsMap['events-getusermedia'], 183 | data: { 184 | eventName: 'getUserMedia', 185 | data: data 186 | } 187 | }) 188 | } 189 | 190 | sendConnectionEvent (data: ConnectionEventData) { 191 | if (this.batchConnectionEvents === false) { 192 | return this._sendConnectionEvent(data) 193 | } 194 | 195 | if (this.connectionTimeout !== null) { 196 | clearTimeout(this.connectionTimeout) 197 | } 198 | 199 | this.connectionTimeout = window.setTimeout(() => { 200 | this.sendBatchConnectionEvents() 201 | }, DEFAULT_OPTIONS.connectionTimeoutValue) 202 | 203 | this.connectionEvents.push([data, Date.now()]) 204 | } 205 | 206 | sendBatchConnectionEvents () { 207 | let events = Array.from(this.connectionEvents) 208 | this.connectionEvents = [] 209 | clearTimeout(this.connectionTimeout) 210 | 211 | if (events.length === 1) { 212 | this._handleSingleConnectionEvent(events[0]) 213 | } else { 214 | this._handleBatchConnectionEvents(events) 215 | } 216 | } 217 | 218 | private _handleSingleConnectionEvent ([ev, timestamp]: [ConnectionEventData, DOMHighResTimeStamp]) { 219 | let now = Date.now() 220 | let { eventName, peerId, data } = ev 221 | 222 | return this._sendConnectionEvent({ 223 | eventName, 224 | peerId, 225 | timeDelta: now - timestamp, 226 | data 227 | }) 228 | } 229 | 230 | private _handleBatchConnectionEvents (events: Array<[ConnectionEventData, DOMHighResTimeStamp]>) { 231 | let now = Date.now() 232 | 233 | let data = events.map((ev) => { 234 | let [ eventData, timestamp ] = ev 235 | let { eventName, peerId, data } = eventData 236 | 237 | return { 238 | eventName, 239 | peerId, 240 | timeDelta: now - timestamp, 241 | data 242 | } 243 | }) 244 | 245 | return this._sendBatchConnectionEvents(data) 246 | } 247 | 248 | private _sendConnectionEvent (data) { 249 | return this.makeRequest({ 250 | path: urlsMap['connection'], 251 | data: data 252 | }) 253 | } 254 | 255 | private _sendBatchConnectionEvents (data) { 256 | return this.makeRequest({ 257 | path: urlsMap['batch-connection'], 258 | data: data 259 | }) 260 | } 261 | 262 | sendWebrtcStats (data) { 263 | return this.makeRequest({ 264 | path: urlsMap['stats'], 265 | retry: true, 266 | data: data 267 | }) 268 | } 269 | 270 | sendTrackEvent (data) { 271 | const method = data.event === 'ontrack' ? 'post' : 'put' 272 | return this.makeRequest({ 273 | path: urlsMap['track'], 274 | method: method, 275 | retry: true, 276 | data: data 277 | }) 278 | } 279 | 280 | /** 281 | * This is a special method because it uses beacons instead of fetch 282 | */ 283 | sendLeaveEvent (event) { 284 | let path = urlsMap['events-browser'] 285 | let data = JSON.stringify({ 286 | token: token, 287 | eventName: event 288 | }) 289 | 290 | if (this.mockRequests) { 291 | return log('request', Date.now() - start, urlsMap['events-browser'], data) 292 | } 293 | 294 | externalApi.url(path).options({keepalive: true}).post(data) 295 | } 296 | 297 | sendBeaconEvent (event) { 298 | let url = this._createUrl(urlsMap['events-browser']) 299 | let data = JSON.stringify({ 300 | token: token, 301 | eventName: event 302 | }) 303 | 304 | if (navigator.sendBeacon) { 305 | // send a beacon event 306 | navigator.sendBeacon(url, data) 307 | } 308 | } 309 | 310 | sendEndCall () { 311 | return this.makeRequest({ 312 | path: urlsMap['events-browser'], 313 | retry: true, 314 | data: { 315 | eventName: 'endCall' 316 | } 317 | }) 318 | } 319 | 320 | private async makeRequest (options: MakeRequest) { 321 | // we just need the path, the base url is set at initialization 322 | let {path, timestamp, data, retry = false} = options 323 | 324 | if (path === '/initialize' && start === 0) { 325 | start = Date.now() 326 | } 327 | 328 | log('request', Date.now() - start, path, data) 329 | 330 | // most of the request require a token 331 | // if we have it, add it to the body 332 | if (token) { 333 | data.token = token 334 | } 335 | 336 | // if we mock requests, resolve immediately 337 | if (this.mockRequests) { 338 | return new Promise((resolve) => { 339 | let response = {} 340 | if (data.eventName === 'addConnection') { 341 | response = { 342 | // @ts-ignore 343 | peer_id: data.peerId 344 | } 345 | } 346 | // mock a request that takes anywhere between 0 and 1000ms 347 | setTimeout(() => resolve(response), Math.floor(Math.random() * 1000)) 348 | }) 349 | } 350 | 351 | // if we have a timestamps than this event happened in the past 352 | // add the delta attribute so the backend knows 353 | // we might get the timestamp attribute inside data 354 | // this happens for events that we manually delay sending 355 | timestamp = timestamp || data.timestamp 356 | if (timestamp) { 357 | data.delta = Date.now() - timestamp 358 | } else { 359 | // if not, than timestamp this request to be used in case of failure 360 | timestamp = Date.now() 361 | } 362 | 363 | let toSend: string 364 | try { 365 | toSend = JSON.stringify(data) 366 | } catch (e) { 367 | throw new Error('Could not stringify request data') 368 | } 369 | 370 | // keep the content type as text plain to avoid CORS preflight requests 371 | let request = externalApi.url(path).content('text/plain') 372 | let requestToMake 373 | 374 | if (options.method === 'put') { 375 | requestToMake = request.put(toSend) 376 | } else { 377 | requestToMake = request.post(toSend) 378 | } 379 | 380 | return requestToMake 381 | .setTimeout(REQUEST_TIMEOUT) 382 | .json(this._handleResponse) 383 | .catch((response) => { 384 | // if we should retry the request 385 | if (retry) { 386 | return this._handleFailedRequest({response, timestamp, options}) 387 | } 388 | 389 | throw response 390 | }) 391 | } 392 | 393 | private async _handleResponse (response) { 394 | if (response) { 395 | log(response) 396 | } 397 | 398 | return response 399 | } 400 | 401 | /** 402 | * Used to handle a failed fetch request 403 | * @param {Object} arg 404 | */ 405 | private async _handleFailedRequest (arg) { 406 | let {response, timestamp, options} = arg 407 | let {backoff = EXPONENTIAL_BACKOFF} = options 408 | let body 409 | 410 | try { 411 | body = JSON.parse(response.message) 412 | // we have a domain restriction, app paused, invalid api key or over quota, no need for retry 413 | if (this.unrecoverable.includes(body.error_code)) { 414 | return Promise.reject(body) 415 | } 416 | } catch (e) {} 417 | 418 | // if we got an error, then the user is offline or a timeout 419 | if (response instanceof Error || response.status > 500) { 420 | // double the value with each run. starts at 1s 421 | backoff *= 2 422 | 423 | // don't go over 1 min 424 | if (backoff > MAX_EXPONENTIAL_BACKOFF) { 425 | throw new Error('request failed after exponential backoff') 426 | } 427 | 428 | return new Promise((resolve, reject) => { 429 | setTimeout(() => { 430 | options.timestamp = timestamp 431 | options.backoff = backoff 432 | this.makeRequest(options).then(resolve).catch(reject) 433 | }, backoff) 434 | }) 435 | } 436 | 437 | return Promise.reject(body) 438 | } 439 | 440 | private _createUrl (path = '/') { 441 | return `${this.apiRoot}${path}` 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultOptions } from './types/index' 2 | 3 | export const DEFAULT_OPTIONS = { 4 | pageEvents: { 5 | pageVisibility: false, 6 | // fullScreen: false 7 | }, 8 | apiRoot: 'https://api.peermetrics.io/v1', 9 | debug: false, 10 | mockRequests: false, 11 | remote: true, 12 | getStatsInterval: 5000 13 | } as DefaultOptions 14 | 15 | export const CONSTRAINTS = { 16 | meta: { 17 | // how many tags per conference we allow 18 | length: 5, 19 | keyLength: 64, 20 | accepted: ['number', 'string', 'boolean'] 21 | // how long should a tag be 22 | // tagLengs: 50 23 | }, 24 | customEvent: { 25 | eventNameLength: 120, 26 | bodyLength: 2048 27 | }, 28 | peer: { 29 | nameLength: 64, 30 | idLength: 64 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {WebRTCStats} from '@peermetrics/webrtc-stats' 2 | 3 | // import type { RemoveConnectionOptions } from '@peermetrics/webrtc-stats' 4 | 5 | import {User} from './user' 6 | import { DEFAULT_OPTIONS, CONSTRAINTS } from "./constants"; 7 | import {ApiWrapper} from './api-wrapper' 8 | import SdkIntegration from "./sdk_integrations"; 9 | 10 | import { enableDebug, log, wrapPeerConnection, PeerMetricsError} from './utils' 11 | 12 | import type { 13 | PeerMetricsConstructor, 14 | InitializeObject, 15 | GetUrlOptions, 16 | SdkIntegrationInterface, 17 | WebrtcSDKs, 18 | AddConnectionOptions, 19 | RemoveConnectionOptions, 20 | SessionData, 21 | PageEvents, 22 | AddEventOptions, 23 | PeersToMonitor 24 | } from './types/index' 25 | 26 | export {PeerMetricsConstructor, AddConnectionOptions, AddEventOptions} 27 | 28 | /** 29 | * Used to keep track of peers 30 | * @type {Object} 31 | */ 32 | let peersToMonitor = {} as PeersToMonitor 33 | 34 | /** 35 | * Used to keep track of connection IDs: the ones from WebrtcStats and the ones from the DB 36 | */ 37 | let monitoredConnections = {} 38 | 39 | let eventQueue = [] 40 | 41 | let peerConnectionEventEmitter = null 42 | // if the user has provided an options object 43 | if (typeof window !== "undefined" && typeof window.PeerMetricsOptions === 'object') { 44 | if (window.PeerMetricsOptions.wrapPeerConnection === true) { 45 | peerConnectionEventEmitter = wrapPeerConnection(window) 46 | if (!peerConnectionEventEmitter) { 47 | console.warn('Could not wrap window.RTCPeerConnection') 48 | } 49 | } 50 | } 51 | 52 | export class PeerMetrics { 53 | 54 | private user: User 55 | private apiWrapper: ApiWrapper 56 | private webrtcStats: typeof WebRTCStats 57 | private pageEvents: PageEvents 58 | private _options: PeerMetricsConstructor 59 | private _initialized: boolean = false 60 | private webrtcSDK: WebrtcSDKs = '' 61 | 62 | /** 63 | * Used to initialize the SDK 64 | * @param {Object} options 65 | */ 66 | constructor (options: PeerMetricsConstructor) { 67 | // check if options are valid 68 | if (typeof options !== 'object') { 69 | throw new Error('Invalid argument. Expected object, got something else.') 70 | } 71 | 72 | options = {...DEFAULT_OPTIONS, ...options} 73 | 74 | // validate options 75 | if (!options.apiKey) { 76 | throw new Error('Missing argument apiKey') 77 | } 78 | 79 | if (!options.conferenceId) { 80 | throw new Error('Missing argument conferenceId') 81 | } 82 | 83 | if ('appVersion' in options) { 84 | if (typeof options.appVersion !== 'string') { 85 | throw new Error('appVersion must be a string') 86 | } 87 | 88 | if (options.appVersion.length > 16) { 89 | throw new Error('appVersion must have a max length of 16') 90 | } 91 | } 92 | 93 | // if meta tags were added 94 | if ('meta' in options) { 95 | if (!options.meta || typeof options.meta !== 'object') { 96 | throw new Error('The meta attribute should be of type object') 97 | } 98 | 99 | const keys = Object.keys(options.meta) 100 | 101 | if (keys.length > CONSTRAINTS.meta.length) { 102 | throw new Error(`Argument meta should only have a maximum of ${CONSTRAINTS.meta.length} attributes`) 103 | } 104 | 105 | for (const key of keys) { 106 | if (key.length > CONSTRAINTS.meta.keyLength) { 107 | console.error(`Meta keys should not be larger than ${CONSTRAINTS.meta.keyLength}`) 108 | delete options.meta[key] 109 | continue 110 | } 111 | 112 | // make sure each value is an accepted format 113 | const value = options.meta[key] 114 | if (!CONSTRAINTS.meta.accepted.includes(typeof value)) { 115 | console.error(`Meta values should be one of the following: ${CONSTRAINTS.meta.accepted.join(', ')}`) 116 | delete options.meta[key] 117 | } 118 | } 119 | } 120 | 121 | // create the user model 122 | // userId 123 | // userName 124 | // conferenceId 125 | // conference name 126 | this.user = new User(options) 127 | 128 | /** 129 | * Let the user specify a different apiRoot 130 | * useful in dev, might be removed for prod 131 | * @type {String} 132 | */ 133 | var apiRoot = options.apiRoot || DEFAULT_OPTIONS.apiRoot 134 | 135 | // create the api wrapper, used to talk with the api server 136 | this.apiWrapper = new ApiWrapper({ 137 | apiRoot: apiRoot, 138 | apiKey: options.apiKey, 139 | mockRequests: options.mockRequests, 140 | user: this.user 141 | }) 142 | 143 | /** 144 | * the initial options the user used to instantiate the sdk 145 | * @type {[type]} 146 | */ 147 | this._options = options 148 | 149 | this.pageEvents = options.pageEvents 150 | 151 | enableDebug(!!options.debug) 152 | 153 | if (options.wrapPeerConnection) { 154 | if (peerConnectionEventEmitter) { 155 | console.warn('RTCPeerConnection already wrapped') 156 | } else { 157 | peerConnectionEventEmitter = wrapPeerConnection(window) 158 | } 159 | } 160 | } 161 | 162 | /** 163 | * Used to initialize the sdk. Accepts an optional object with a conferenceId and conferenceName 164 | * @return {Promise} 165 | */ 166 | async initialize (options?: InitializeObject) { 167 | let response 168 | let conferenceId = this._options.conferenceId 169 | let conferenceName = this._options.conferenceName 170 | 171 | // if the user sent an object, extract the conferenceId and conferenceName 172 | if (typeof options === 'object') { 173 | if (!options.conferenceId) { 174 | throw new Error('Missing conferenceId argument') 175 | } 176 | 177 | conferenceId = options.conferenceId 178 | 179 | if (options.conferenceName) { 180 | conferenceName = options.conferenceName 181 | } 182 | } 183 | 184 | // if we are already initialized 185 | if (this._initialized) return 186 | 187 | // check if browser 188 | if (typeof window === 'undefined' || typeof navigator === 'undefined') { 189 | throw new Error('The SDK is meant to be used in a browser.') 190 | } 191 | 192 | // check if webrtc compatible 193 | let pc = window.RTCPeerConnection 194 | let gum = navigator.mediaDevices.getUserMedia 195 | if (!pc || !gum) { 196 | throw new Error('This device doesn\'t seem to support RTCPeerConnection or getUserMedia') 197 | } 198 | 199 | // check if fetch is available 200 | if (typeof window.fetch === 'undefined') { 201 | throw new Error('This device doesn\'t seem to support the fetch API.') 202 | } 203 | 204 | try { 205 | // initialize the session 206 | // check if the apiKey is valid 207 | // check quota, etc 208 | // create the conference 209 | response = await this.apiWrapper.initialize({ 210 | conferenceId, 211 | conferenceName 212 | }) 213 | } catch (responseError) { 214 | const error = new PeerMetricsError(responseError.message) 215 | // if the api key is not valid 216 | // or the quota is exceded 217 | if (responseError.error_code) { 218 | error.code = responseError.error_code 219 | } 220 | 221 | throw error 222 | } 223 | 224 | // if the apiKey is ok 225 | // what's the interval desired 226 | 227 | // gather platform info about the user's device. OS, browser, etc 228 | // we need to do them after gUM is called to get the correct labels for devices 229 | // when we get all of them send them over 230 | let sessionData = await this.user.getUserDetails() as SessionData 231 | 232 | // add app version and meta if present 233 | sessionData.appVersion = this._options.appVersion 234 | sessionData.meta = this._options.meta 235 | 236 | sessionData.webrtcSdk = this.webrtcSDK 237 | 238 | try { 239 | // save this initial details about this user 240 | await this.apiWrapper.createSession(sessionData) 241 | } catch (e) { 242 | console.error(e) 243 | throw new Error('Could not start session.') 244 | } 245 | 246 | this._initialized = true 247 | 248 | // add global event listeners 249 | this.addPageEventListeners(this.pageEvents) 250 | 251 | this.addMediaDeviceChangeListener() 252 | 253 | this._initializeStatsModule(response.getStatsInterval) 254 | } 255 | 256 | /** 257 | * Wrap native RTCPeerConnection class 258 | * @return {boolean} if the wrapping was successful 259 | */ 260 | static wrapPeerConnection(): boolean { 261 | if (typeof window === 'undefined') { 262 | throw new Error('Could not find gloal window. This method should be called in a browser context.') 263 | } 264 | 265 | peerConnectionEventEmitter = wrapPeerConnection(window) 266 | if (!peerConnectionEventEmitter) { 267 | log('Could not wrap window.RTCPeerConnection') 268 | return false 269 | } 270 | 271 | return true 272 | } 273 | 274 | /** 275 | * Method used to return an app url for a conference or a participant 276 | * @param {Object} options Object containing participantId or conferenceId 277 | * @return {string} The url 278 | */ 279 | static async getPageUrl (options: GetUrlOptions): Promise { 280 | const {apiKey, userId, conferenceId} = options 281 | 282 | if (!apiKey) { 283 | throw new Error('Missing apiKey argument') 284 | } 285 | 286 | if (!userId && !conferenceId) { 287 | throw new Error('Missing arguments. Either userId or conferenceId must be sent.') 288 | } 289 | 290 | if (userId && conferenceId) { 291 | throw new Error('Either userId or conferenceId must be sent as arguments.') 292 | } 293 | 294 | let apiWrapper = new ApiWrapper({ 295 | apiRoot: DEFAULT_OPTIONS.apiRoot, 296 | apiKey 297 | }) 298 | 299 | return apiWrapper.getPageUrl({ 300 | apiKey, 301 | userId, 302 | conferenceId 303 | }) 304 | } 305 | 306 | async addPeer (options: AddConnectionOptions) { 307 | console.warn('The addPeer() method has been deprecated, please use addConnection() instead') 308 | return this.addConnection(options) 309 | } 310 | 311 | /** 312 | * Used to start monitoring for a peer 313 | * @param {Object} options Options for this peer 314 | */ 315 | async addConnection (options: AddConnectionOptions) { 316 | if (!this._initialized) { 317 | throw new Error('SDK not initialized. Please call initialize() first.') 318 | } 319 | 320 | if (!this.webrtcStats) { 321 | throw new Error('The stats module is not instantiated yet.') 322 | } 323 | 324 | if (typeof options !== 'object') { 325 | throw new Error('Argument for addConnection() should be an object.') 326 | } 327 | 328 | let {pc, peerId, peerName, isSfu} = options 329 | 330 | if (!pc) { 331 | throw new Error('Missing argument pc: RTCPeerConnection.') 332 | } 333 | 334 | if (!peerId) { 335 | throw new Error('Missing argument peerId.') 336 | } 337 | 338 | // make the peerId a string 339 | peerId = String(peerId) 340 | 341 | // validate the peerName if it exists 342 | if (peerName) { 343 | if (typeof peerName !== 'string') { 344 | throw new Error('peerName should be a string') 345 | } 346 | 347 | // if the name is too long, just snip it 348 | if (peerName.length > CONSTRAINTS.peer.nameLength) { 349 | peerName = peerName.slice(CONSTRAINTS.peer.nameLength) 350 | } 351 | } 352 | 353 | if (peerId === this.user.userId) { 354 | throw new Error('peerId can\'t be the same as the id used to initialize PeerMetrics.') 355 | } 356 | 357 | log('addConnection', options) 358 | 359 | // add the peer to webrtcStats now, so we don't miss any events 360 | let {connectionId} = await this.webrtcStats.addConnection({peerId, pc}) 361 | 362 | // lets not block this function call for this request 363 | this._sendAddConnectionRequest({connectionId, options: {pc, peerId, peerName, isSfu}}) 364 | 365 | return { 366 | connectionId 367 | } 368 | } 369 | 370 | /** 371 | * Stop listening for events for a specific connection 372 | */ 373 | async removeConnection (options: RemoveConnectionOptions) { 374 | let peerId, peer 375 | 376 | // remove the event listeners 377 | let {connectionId} = this.webrtcStats.removeConnection(options) 378 | 379 | const internalId = monitoredConnections[connectionId] 380 | 381 | if (!internalId) { 382 | return 383 | } 384 | 385 | for (let pId in peersToMonitor) { 386 | if (peersToMonitor[pId].connections.includes(internalId)) { 387 | peer = peersToMonitor[pId] 388 | peerId = pId 389 | break 390 | } 391 | } 392 | 393 | // we need both connectionId and peerId for this request 394 | await this.apiWrapper.sendConnectionEvent({ 395 | eventName: 'removeConnection', 396 | connectionId: internalId, 397 | peerId: peerId 398 | }) 399 | 400 | // cleanup 401 | delete monitoredConnections[connectionId] 402 | peer.connections = peer.connections.filter(cId => cId !== internalId) 403 | } 404 | 405 | /** 406 | * Stop listening for all connections for a specific peer 407 | * @param {string} peerId The peer ID to stop listening to 408 | */ 409 | async removePeer (peerId: string) { 410 | if (typeof peerId !== 'string') { 411 | throw new Error('Argument for removePeer() should be a string.') 412 | } 413 | 414 | if (!peersToMonitor[peerId]) { 415 | throw new Error(`Could not find peer with id ${peerId}`) 416 | } 417 | 418 | this.webrtcStats.removePeer(peerId) 419 | 420 | await this.apiWrapper.sendConnectionEvent({ 421 | eventName: 'removePeer', 422 | peerId: peerId 423 | }) 424 | 425 | delete peersToMonitor[peerId] 426 | } 427 | 428 | /** 429 | * Method used to add an integration with different WebRTC SDKs 430 | * @param options Options object 431 | */ 432 | public async addSdkIntegration(options: SdkIntegrationInterface) { 433 | 434 | let sdkIntegration = new SdkIntegration() 435 | 436 | sdkIntegration.on('newConnection', (options) => { 437 | this.addConnection(options) 438 | }) 439 | 440 | // if we have a pion integration, it's safe to wrap the peer connection later 441 | if (options.pion) { 442 | // if we haven't already wrapped 443 | if (!peerConnectionEventEmitter) { 444 | peerConnectionEventEmitter = wrapPeerConnection(window) 445 | } 446 | } 447 | 448 | sdkIntegration.addIntegration(options, peerConnectionEventEmitter) 449 | 450 | this.webrtcSDK = sdkIntegration.webrtcSDK 451 | 452 | // if the user is integrating with any sdk 453 | if (sdkIntegration.foundIntegration) { 454 | // and PM is already initialized 455 | if (this._initialized) { 456 | // update the session to signal as such 457 | this.apiWrapper.addSessionDetails({ 458 | webrtcSdk: this.webrtcSDK 459 | }) 460 | } 461 | } else { 462 | throw new Error("We could not find any integration details in the options object that was passed in.") 463 | } 464 | } 465 | 466 | /** 467 | * Add a custom event for this user 468 | * @param {Object} options The details for this event 469 | */ 470 | async addEvent (options: AddEventOptions) { 471 | if (typeof options !== 'object') { 472 | throw new Error('Parameter for addEvent() should be an object.') 473 | } 474 | 475 | if (options.eventName && options.eventName.length > CONSTRAINTS.customEvent.eventNameLength) { 476 | throw new Error(`eventName should be shorter than ${CONSTRAINTS.customEvent.eventNameLength}.`) 477 | } 478 | 479 | try { 480 | let json = JSON.stringify(options) 481 | if (json.length > CONSTRAINTS.customEvent.bodyLength) { 482 | throw new Error('Custom event body size limit reached.') 483 | } 484 | } catch (e) { 485 | throw new Error('Custom event is not serializable.') 486 | } 487 | 488 | await this.apiWrapper.sendCustomEvent(options) 489 | } 490 | 491 | /** 492 | * Called when the current user has muted the mic 493 | */ 494 | async mute () { 495 | return this.apiWrapper.sendCustomEvent({eventName: 'mute'}) 496 | } 497 | 498 | /** 499 | * Called when the current user has unmuted the mic 500 | */ 501 | async unmute () { 502 | return this.apiWrapper.sendCustomEvent({eventName: 'unmute'}) 503 | } 504 | 505 | /** 506 | * Used to stop all event listeners and end current session 507 | */ 508 | async endCall () { 509 | this.webrtcStats.destroy() 510 | this.webrtcStats = null 511 | 512 | peersToMonitor = {} 513 | monitoredConnections = {} 514 | 515 | this._initialized = false 516 | 517 | window.removeEventListener('beforeunload', this._eventListenersCallbacks.beforeunload) 518 | window.removeEventListener('unload', this._eventListenersCallbacks.unload) 519 | navigator.mediaDevices.removeEventListener('devicechange', this._eventListenersCallbacks.devicechange) 520 | 521 | return this.apiWrapper.sendEndCall() 522 | } 523 | 524 | private addPageEventListeners (options: PageEvents) { 525 | window.addEventListener('beforeunload', this._eventListenersCallbacks.beforeunload) 526 | 527 | window.addEventListener('unload', this._eventListenersCallbacks.unload) 528 | 529 | // tab focus/unfocus 530 | if (options.pageVisibility && window.document) { 531 | this.addPageVisibilityListeners(window.document) 532 | } 533 | 534 | // track full screen 535 | // if (options.fullScreen) { 536 | // this.addFullScreenEventListeners() 537 | // } 538 | } 539 | 540 | private addPageVisibilityListeners (document: Document & {msHidden?: boolean; webkitHidden?: boolean}) { 541 | // Set the name of the hidden property and the change event for visibility 542 | let hidden, visibilityChange 543 | 544 | if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support 545 | hidden = 'hidden' 546 | visibilityChange = 'visibilitychange' 547 | } else if (typeof document.msHidden !== 'undefined') { 548 | hidden = 'msHidden' 549 | visibilityChange = 'msvisibilitychange' 550 | } else if (typeof document.webkitHidden !== 'undefined') { 551 | hidden = 'webkitHidden' 552 | visibilityChange = 'webkitvisibilitychange' 553 | } 554 | 555 | if (hidden === undefined) { 556 | log('Page visibility is not supported') 557 | return 558 | } 559 | 560 | // TODO: inspire some functionality from 561 | // https://github.com/addyosmani/visibly.js/blob/master/visibly.js 562 | document.addEventListener(visibilityChange, (ev) => { 563 | this.apiWrapper.sendPageEvent({ 564 | eventName: 'tabFocus', 565 | focus: document[hidden] 566 | }) 567 | }, false) 568 | } 569 | 570 | /** 571 | * Add event listeners for fullScreen events 572 | * from: https://gist.github.com/samccone/1653975 573 | */ 574 | private addFullScreenEventListeners () { 575 | // TODO: add full screen events 576 | 577 | if (document.body.requestFullscreen) { 578 | window.addEventListener('fullscreenchange', (ev) => { 579 | log(ev) 580 | 581 | // this.apiWrapper.sendPageEvent({ 582 | // eventName: 'fullScreen', 583 | // fullScreen: true 584 | // }) 585 | }) 586 | } 587 | } 588 | 589 | private addMediaDeviceChangeListener () { 590 | navigator.mediaDevices.addEventListener('devicechange', this._eventListenersCallbacks.devicechange) 591 | } 592 | 593 | private _eventListenersCallbacks = { 594 | beforeunload: () => { 595 | this.apiWrapper.sendLeaveEvent('beforeunload') 596 | }, 597 | 598 | unload: () => { 599 | this.apiWrapper.sendBeaconEvent('unload') 600 | }, 601 | 602 | devicechange: () => { 603 | // first get the new devices 604 | return this.user.getDevices() 605 | .then((devices) => { 606 | this.user.devices = devices 607 | // and then send the event to the server 608 | this.apiWrapper.sendMediaDeviceChange(devices) 609 | }) 610 | } 611 | } 612 | 613 | private _initializeStatsModule (getStatsInterval = DEFAULT_OPTIONS.getStatsInterval) { 614 | // initialize the webrtc stats module 615 | this.webrtcStats = new WebRTCStats({ 616 | getStatsInterval: getStatsInterval, 617 | rawStats: false, 618 | statsObject: true, 619 | filteredStats: false, 620 | remote: this._options.remote, 621 | wrapGetUserMedia: true, 622 | logLevel: 'none' 623 | }) 624 | 625 | this._addWebrtcStatsEventListeners() 626 | } 627 | 628 | /** 629 | * Adds event listener for the stats library 630 | */ 631 | private _addWebrtcStatsEventListeners () { 632 | this.webrtcStats 633 | // just listen on the timeline and handle them differently 634 | .on('timeline', this._handleTimelineEvent.bind(this)) 635 | } 636 | 637 | /** 638 | * Make a request to the api server to signal a new connection 639 | * @param {String} connectionId The ID of the connection offered by WebRTCStats 640 | */ 641 | private async _sendAddConnectionRequest ({connectionId, options}) { 642 | let {pc, peerId, peerName, isSfu} = options 643 | let response 644 | 645 | try { 646 | // make the request to add the peer to DB 647 | response = await this.apiWrapper.sendConnectionEvent({ 648 | eventName: 'addConnection', 649 | peerId: peerId, 650 | peerName: peerName, 651 | connectionState: pc.connectionState, 652 | isSfu: !!isSfu 653 | }) 654 | 655 | if (!response) { 656 | throw new Error('There was a problem while adding this connection') 657 | } 658 | } catch (e) { 659 | log(e) 660 | this.removeConnection({connectionId}) 661 | throw e 662 | } 663 | 664 | // we'll receive a new peer id, use peersToMonitor to make the connection between them 665 | peersToMonitor[peerId] = { 666 | id: response.peer_id, 667 | connections: [] 668 | } 669 | 670 | monitoredConnections[connectionId] = response.connection_id 671 | peersToMonitor[peerId].connections.push(response.connection_id) 672 | 673 | // all the events that we captured while waiting for 'addConnection' are here 674 | // send them to the server 675 | eventQueue.map((event) => { 676 | this._handleTimelineEvent(event) 677 | }) 678 | 679 | // clear the queue 680 | eventQueue.length = 0 681 | } 682 | 683 | private _handleTimelineEvent (ev) { 684 | if (ev.peerId) { 685 | if (peersToMonitor[ev.peerId]) { 686 | // update with the new peer 687 | ev.peerId = peersToMonitor[ev.peerId].id 688 | } else { 689 | // add this special flag to signal that we've manually delayed sending this request 690 | ev.delayed = true 691 | eventQueue.push(ev) 692 | return 693 | } 694 | } 695 | 696 | // if we have a connectionId from the server, 697 | // swap it with the old value. same as peersToMonitor 698 | if (ev.connectionId) { 699 | if (ev.connectionId in monitoredConnections) { 700 | ev.connectionId = monitoredConnections[ev.connectionId] 701 | } else { 702 | ev.delayed = true 703 | eventQueue.push(ev) 704 | return 705 | } 706 | } 707 | 708 | switch (ev.tag) { 709 | case 'getUserMedia': 710 | this._handleGumEvent(ev) 711 | break 712 | case 'stats': 713 | this._handleStatsEvent(ev) 714 | break 715 | case 'track': 716 | this._handleTrackEvent(ev) 717 | break 718 | default: 719 | this._handleConnectionEvent(ev) 720 | break 721 | } 722 | } 723 | 724 | // Handle different types of events 725 | // TODO: move this somewhere else 726 | private _handleGumEvent (ev) { 727 | /** 728 | * The data for this event 729 | * Can have one of 3 arguments 730 | * constraints: the gUM constraints 731 | * stream: after we get the stream 732 | * error: well, the error 733 | * @type {Object} 734 | */ 735 | let data = ev.data 736 | 737 | /** 738 | * The object that we'll save in the DB 739 | * after we parse data 740 | * @type {Object} 741 | */ 742 | let dataToSend: any = {} 743 | if (data.constraints) { 744 | dataToSend.constraints = data.constraints 745 | } 746 | 747 | // after we get the stream, make sure we captured all the devices 748 | // only do this after we get the stream 749 | if (data.stream) { 750 | this.user.getDevices() 751 | .then((devices) => { 752 | // if we get more devices then before 753 | if (devices.length !== this.user.devices.length) { 754 | // TODO: maybe save this as a change event? 755 | this.user.devices = devices 756 | this.apiWrapper.addSessionDetails({ 757 | devices: this.user.devices 758 | }) 759 | } 760 | }) 761 | 762 | dataToSend = {...data.details} 763 | } 764 | 765 | if (data.error) { 766 | dataToSend.error = { 767 | name: data.error.name, 768 | message: data.error.message 769 | } 770 | } 771 | 772 | this.apiWrapper.saveGetUserMediaEvent(dataToSend) 773 | } 774 | 775 | private _handleStatsEvent (ev) { 776 | let {data, peerId, connectionId, timeTaken} = ev 777 | 778 | this.apiWrapper.sendWebrtcStats({data, peerId, connectionId, timeTaken}) 779 | } 780 | 781 | private _handleTrackEvent (ev) { 782 | let {data, peerId, connectionId, event} = ev 783 | let dataToSend = { 784 | event, 785 | peerId, 786 | connectionId, 787 | trackId: null, 788 | data: {} as any 789 | } 790 | 791 | if (event === 'ontrack') { 792 | dataToSend.data = data.track 793 | delete dataToSend.data._track 794 | } 795 | 796 | if (data.track) { 797 | dataToSend.trackId = data.track.id 798 | } else if (data.event) { 799 | if (data.event.target) { 800 | dataToSend.trackId = data.event.target.id 801 | } 802 | 803 | if (data.event.detail && data.event.detail.check) { 804 | dataToSend.data.check = data.event.detail.check 805 | } 806 | } else { 807 | log('Received track event without track') 808 | } 809 | 810 | this.apiWrapper.sendTrackEvent(dataToSend) 811 | } 812 | 813 | private async _handleConnectionEvent (ev) { 814 | let {event, peerId, connectionId, data, delayed} = ev 815 | let eventData = data 816 | 817 | switch (event) { 818 | case 'addConnection': 819 | // we don't need the actual RTCPeerConnection object 820 | delete eventData.options.pc 821 | 822 | // rename the event name 823 | event = 'peerDetails' 824 | break 825 | case 'onicecandidate': 826 | if (data) { 827 | eventData = { 828 | address: data.address, 829 | candidate: data.candidate, 830 | component: data.component, 831 | foundation: data.foundation, 832 | port: data.port, 833 | priority: data.priority, 834 | protocol: data.protocol, 835 | relatedAddress: data.relatedAddress, 836 | relatedPort: data.relatedPort, 837 | sdpMLineIndex: data.sdpMLineIndex, 838 | sdpMid: data.sdpMid, 839 | tcpType: data.tcpType, 840 | type: data.type, 841 | usernameFragment: data.usernameFragment 842 | } 843 | } 844 | break 845 | case 'onicecandidateerror': 846 | eventData = ev.error 847 | break 848 | case 'ondatachannel': 849 | eventData = null 850 | break 851 | default: 852 | log(ev) 853 | break 854 | } 855 | 856 | try { 857 | const timestamp = delayed ? ev.timestamp : null 858 | await this.apiWrapper.sendConnectionEvent({ 859 | eventName: event, 860 | peerId, 861 | connectionId, 862 | timestamp, 863 | data: eventData 864 | }) 865 | } catch (e) { 866 | log(e) 867 | } 868 | } 869 | } 870 | -------------------------------------------------------------------------------- /src/sdk_integrations.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | import { CONSTRAINTS } from "./constants"; 4 | 5 | import type { 6 | SdkIntegrationInterface, 7 | WebrtcSDKs, 8 | } from './types/index' 9 | 10 | export default class SdkIntegration extends EventEmitter { 11 | foundIntegration: boolean = false 12 | webrtcSDK: WebrtcSDKs 13 | 14 | addIntegration(options: SdkIntegrationInterface, peerConnectionEventEmitter: null | EventEmitter): boolean { 15 | 16 | this.addMediaSoupIntegration(options.mediasoup) 17 | this.addJanusIntegration(options.janus) 18 | this.addLivekitIntegration(options.livekit) 19 | this.addTwilioVideoIntegration(options.twilioVideo) 20 | this.addVonageIntegration(options.vonage, peerConnectionEventEmitter) 21 | this.addAgoraIntegration(options.agora, peerConnectionEventEmitter) 22 | this.addPionIntegration(options.pion, peerConnectionEventEmitter) 23 | 24 | return this.foundIntegration 25 | } 26 | 27 | addMediaSoupIntegration(options) { 28 | if (!options) return 29 | 30 | let { device, serverId = 'mediasoup-sfu-server', serverName = 'MediaSoup SFU Server' } = options 31 | // check if the user sent the right device instance 32 | if (!device || !device.observer) { 33 | throw new Error("For integrating with MediaSoup, you need to send an instace of mediasoupClient.Device().") 34 | } 35 | 36 | serverId = this.checkServerId(serverId) 37 | 38 | serverName = this.checkServerName(serverName) 39 | 40 | this.webrtcSDK = 'mediasoup' 41 | 42 | // listen for new transports 43 | device.observer.on('newtransport', (transport) => { 44 | this.emit('newConnection', { 45 | pc: transport.handler._pc, 46 | peerId: serverId, 47 | peerName: serverName, 48 | isSfu: true, 49 | remote: true 50 | }) 51 | }) 52 | 53 | this.foundIntegration = true 54 | } 55 | 56 | addJanusIntegration(options) { 57 | if (!options) return 58 | 59 | let { plugin, serverId = 'janus-sfu-server', serverName = 'Janus SFU Server' } = options 60 | 61 | // check if the user sent the right plugin instance 62 | if (!plugin || typeof plugin.webrtcStuff !== 'object') { 63 | throw new Error("For integrating with Janus, you need to send an instace of plugin after calling .attach().") 64 | } 65 | 66 | serverId = this.checkServerId(serverId) 67 | 68 | serverName = this.checkServerName(serverName) 69 | 70 | // if the pc is already attached. should not happen 71 | if (plugin.webrtcStuff.pc) { 72 | this.emit('newConnection', { 73 | pc: plugin.webrtcStuff.pc, 74 | peerId: serverId, 75 | peerName: serverName, 76 | isSfu: true, 77 | remote: true 78 | }) 79 | } else { 80 | let boundEmit = this.emit.bind(this) 81 | // create a proxy so we can watch when the pc gets created 82 | plugin.webrtcStuff = new Proxy(plugin.webrtcStuff, { 83 | set: function (obj, prop, value) { 84 | if (prop === 'pc') { 85 | boundEmit('newConnection', { 86 | pc: value, 87 | peerId: serverId, 88 | peerName: serverName, 89 | isSfu: true, 90 | remote: true 91 | }) 92 | } 93 | obj[prop] = value; 94 | return true; 95 | } 96 | }) 97 | } 98 | 99 | this.webrtcSDK = 'janus' 100 | this.foundIntegration = true 101 | } 102 | 103 | addLivekitIntegration(options) { 104 | if (!options) return 105 | 106 | let { room, serverId = 'livekit-sfu-server', serverName = 'LiveKit SFU Server' } = options 107 | 108 | // check if the user sent the right room instance 109 | if (!room || typeof room.engine !== 'object') { 110 | throw new Error("For integrating with LiveKit, you need to send an instace of the room as soon as creating it.") 111 | } 112 | 113 | serverId = this.checkServerId(serverId) 114 | 115 | serverName = this.checkServerName(serverName) 116 | 117 | // listen for the transportCreated event 118 | room.engine.on('transportsCreated', (publiser, subscriber) => { 119 | this.emit('newConnection', { 120 | pc: publiser.pc, 121 | peerId: serverId, 122 | peerName: serverName, 123 | isSfu: true, 124 | remote: true 125 | }) 126 | 127 | this.emit('newConnection', { 128 | pc: subscriber.pc, 129 | peerId: serverId, 130 | peerName: serverName, 131 | isSfu: true, 132 | remote: true 133 | }) 134 | }) 135 | 136 | this.webrtcSDK = 'livekit' 137 | this.foundIntegration = true 138 | } 139 | 140 | addTwilioVideoIntegration (options) { 141 | if (!options) return 142 | 143 | let { room } = options 144 | // check if the user sent the right room instance 145 | if (!room || typeof room._signaling !== 'object') { 146 | throw new Error("For integrating with Twilio Video SDK, you need to send an instace of the room as soon as you create it.") 147 | } 148 | 149 | room._signaling._peerConnectionManager._peerConnections.forEach(pcs => { 150 | this.emit('newConnection', { 151 | pc: pcs._peerConnection._peerConnection, 152 | peerId: 'twilio-sfu-server', 153 | peerName: 'Twilio SFU Server', 154 | isSfu: true, 155 | remote: true 156 | }) 157 | }) 158 | 159 | this.webrtcSDK = 'twilioVideo' 160 | this.foundIntegration = true 161 | } 162 | 163 | addVonageIntegration(vonage: boolean, peerConnectionEventEmitter: EventEmitter) { 164 | if (!vonage) return 165 | 166 | if (!peerConnectionEventEmitter) { 167 | throw new Error("Could not integrate with Vonage. Please make sure you set PeerMetricsOptions.wrapPeerConnection before loading the PeerMetrics script."); 168 | } 169 | 170 | peerConnectionEventEmitter.on('newRTCPeerconnection', (pc) => { 171 | this.emit('newConnection', { 172 | pc: pc, 173 | peerId: 'vonage-sfu-server', 174 | peerName: 'Vonage SFU server', 175 | isSfu: true, 176 | remote: true 177 | }) 178 | }) 179 | 180 | this.webrtcSDK = 'vonage' 181 | this.foundIntegration = true 182 | } 183 | 184 | addAgoraIntegration(agora: boolean, peerConnectionEventEmitter: EventEmitter) { 185 | if (!agora) return 186 | 187 | if (!peerConnectionEventEmitter) { 188 | throw new Error("Could not integrate with agora. Please make sure you set PeerMetricsOptions.wrapPeerConnection before loading the PeerMetrics script."); 189 | } 190 | 191 | peerConnectionEventEmitter.on('newRTCPeerconnection', (pc) => { 192 | this.emit('newConnection', { 193 | pc: pc, 194 | peerId: 'agora-sfu-server', 195 | peerName: 'Agora SFU server', 196 | isSfu: true, 197 | remote: true 198 | }) 199 | }) 200 | 201 | this.webrtcSDK = 'agora' 202 | this.foundIntegration = true 203 | } 204 | 205 | addPionIntegration (options: SdkIntegrationInterface['pion'], peerConnectionEventEmitter) { 206 | if (!options) return 207 | 208 | // if the user sent just a boolean, use the default values for server id/name 209 | if (typeof options === 'boolean') { 210 | options = {} 211 | } 212 | 213 | let { serverId = 'pion-sfu-server', serverName = 'Pion SFU server' } = options; 214 | 215 | serverId = this.checkServerId(serverId); 216 | serverName = this.checkServerName(serverName); 217 | 218 | peerConnectionEventEmitter.on('newRTCPeerconnection', (pc) => { 219 | this.emit('newConnection', { 220 | pc: pc, 221 | peerId: serverId, 222 | peerName: serverName, 223 | isSfu: true, 224 | remote: true 225 | }) 226 | }) 227 | 228 | this.webrtcSDK = 'pion'; 229 | this.foundIntegration = true; 230 | } 231 | 232 | /** 233 | * Checks if the serverId is valid 234 | * @param {string} serverId [description] 235 | * @return {string} [description] 236 | */ 237 | private checkServerId (serverId: string): string { 238 | if (typeof serverId !== 'string') { 239 | throw new Error("The serverId must be a string") 240 | } else if (serverId.length > CONSTRAINTS.peer.idLength) { 241 | throw new Error(`The serverId must be no longer than ${CONSTRAINTS.peer.idLength} characters.`) 242 | } 243 | 244 | return serverId 245 | } 246 | 247 | /** 248 | * Used to check if the serverName argument is valid 249 | * @param serverName the string to check 250 | * @returns the string 251 | */ 252 | private checkServerName(serverName: string): string { 253 | if (serverName) { 254 | if (typeof serverName !== 'string') { 255 | throw new Error('serverName should be a string') 256 | } 257 | 258 | // if the name is too long, just snip it 259 | if (serverName.length > CONSTRAINTS.peer.nameLength) { 260 | serverName = serverName.slice(CONSTRAINTS.peer.nameLength) 261 | } 262 | } 263 | 264 | return serverName 265 | } 266 | } -------------------------------------------------------------------------------- /src/types/api.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface ApiInitializeData { 3 | conferenceId: string, 4 | conferenceName: string, 5 | } 6 | 7 | export interface RequestData { 8 | data?: object, 9 | token?: string, 10 | devices?: object[], 11 | eventName?: string, 12 | timestamp?: DOMHighResTimeStamp, 13 | delta?: number 14 | } 15 | 16 | export interface MakeRequest { 17 | path: string, 18 | data: RequestData, 19 | timestamp?: DOMHighResTimeStamp, 20 | method?: 'post' | 'put', 21 | retry?: boolean 22 | } 23 | 24 | export interface ConnectionEventData { 25 | eventName: string, 26 | peerId: string, 27 | connectionState?: string, 28 | connectionId?: string, 29 | peerName?: string, 30 | timestamp?: null | DOMHighResTimeStamp, 31 | data?: null | RequestData, 32 | isSfu?: boolean 33 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export * from './api' 3 | 4 | declare global { 5 | interface Window { PeerMetricsOptions: any; } 6 | } 7 | 8 | export interface PageEvents { 9 | pageVisibility: boolean, 10 | // fullScreen: boolean 11 | } 12 | 13 | export interface DefaultOptions { 14 | apiRoot?: string, 15 | getStatsInterval?: number, 16 | remote?: boolean, 17 | debug?: boolean, 18 | mockRequests?: boolean, 19 | pageEvents?: PageEvents 20 | } 21 | 22 | interface MediaSoupIntegration { 23 | device: any 24 | serverId: string 25 | serverName?: string 26 | } 27 | 28 | interface JanusIntegrationInterface { 29 | plugin: any 30 | serverId: string 31 | serverName?: string 32 | } 33 | 34 | interface LiveKitIntegrationInterface { 35 | room: any 36 | serverId: string 37 | serverName?: string 38 | } 39 | 40 | interface TwilioVideoIntegrationInterface { 41 | room: any 42 | } 43 | 44 | export interface PionIntegrationInterface { 45 | serverId?: string 46 | serverName?: string 47 | } 48 | 49 | export interface SdkIntegrationInterface { 50 | mediasoup?: MediaSoupIntegration, 51 | janus?: JanusIntegrationInterface, 52 | livekit?: LiveKitIntegrationInterface, 53 | twilioVideo?: TwilioVideoIntegrationInterface 54 | vonage?: boolean 55 | agora?: boolean 56 | pion?: boolean | PionIntegrationInterface 57 | } 58 | 59 | export interface InitializeObject { 60 | conferenceId: string, 61 | conferenceName?: string, 62 | } 63 | 64 | export interface PeerMetricsConstructor extends DefaultOptions { 65 | apiKey: string, 66 | userId: string, 67 | userName?: string, 68 | conferenceId: string, 69 | conferenceName?: string, 70 | appVersion?: string, 71 | meta?: object, 72 | wrapPeerConnection?: boolean 73 | } 74 | 75 | export type WebrtcSDKs = '' | 'mediasoup' | 'jitsi' | 'janus' | 'livekit' | 'twilioVideo' | 'vonage' | 'agora' | 'pion' 76 | 77 | export interface SessionData { 78 | platform: object, 79 | constraints: object, 80 | devices: object, 81 | appVersion: string, 82 | meta: object, 83 | webrtcSdk: string 84 | } 85 | 86 | export interface AddConnectionOptions { 87 | peerId: string, 88 | pc: RTCPeerConnection, 89 | connectionId?: string, 90 | remote?: boolean, 91 | peerName?: string, 92 | isSfu?: boolean 93 | } 94 | 95 | export interface RemoveConnectionOptions { 96 | pc?: RTCPeerConnection 97 | connectionId?: string 98 | } 99 | 100 | export interface AddEventOptions extends Object { 101 | eventName?: string 102 | } 103 | 104 | export interface PeersToMonitor { 105 | [id: string]: { 106 | id: string, 107 | connections: string[] 108 | } 109 | } 110 | 111 | export interface GetUrlOptions { 112 | apiKey: string, 113 | userId?: string, 114 | conferenceId?: string, 115 | } -------------------------------------------------------------------------------- /src/user.ts: -------------------------------------------------------------------------------- 1 | 2 | import UAParse from 'ua-parser-js' 3 | 4 | interface ConstructorOptions { 5 | userId: string, 6 | userName?: string 7 | } 8 | 9 | /** 10 | * We gather the info for the current user here 11 | */ 12 | export class User { 13 | public userId: string 14 | public userName: string 15 | public deviceInfo: object 16 | public platform: object = {} 17 | public constraints: MediaTrackSupportedConstraints = {} 18 | public devices: object[] = [] 19 | 20 | constructor ({userId, userName}: ConstructorOptions) { 21 | if (!userId) { 22 | throw new Error('missing argument userId') 23 | } 24 | 25 | this.userId = userId 26 | this.userName = userName 27 | } 28 | 29 | /** 30 | * Used initially to gather info about the user's platform and send them 31 | * @return {Object} Details about the user: userId, userName, platform info, etc 32 | */ 33 | async getUserDetails () { 34 | let platform = await this.gatherPlatformInfo() 35 | return {...platform} 36 | } 37 | 38 | async gatherPlatformInfo () { 39 | // browser data 40 | // version, name, OS 41 | this.platform = this.getUAdetails() 42 | 43 | this.constraints = this.getContraints() 44 | 45 | this.devices = await this.getDevices() 46 | 47 | this.deviceInfo = await this.getDeviceInfo() 48 | 49 | return { 50 | platform: this.platform, 51 | constraints: this.constraints, 52 | devices: this.devices 53 | } 54 | } 55 | 56 | getUAdetails () { 57 | return new UAParse().getResult() 58 | } 59 | 60 | getContraints () { 61 | if (!window.navigator || !window.navigator.mediaDevices) { 62 | return {} 63 | } 64 | 65 | return window.navigator.mediaDevices.getSupportedConstraints() 66 | } 67 | 68 | async getDeviceInfo () { 69 | // @ts-ignore 70 | let getBattery: any = navigator.getBattery 71 | let battery 72 | 73 | if (getBattery) { 74 | try { 75 | battery = await getBattery() 76 | battery = { 77 | charging: battery.charging, 78 | chargingTime: battery.chargingTime, 79 | dischargingTime: battery.dischargingTime, 80 | level: battery.level 81 | } 82 | } catch (e) { 83 | battery = {} 84 | } 85 | } 86 | 87 | return { 88 | battery: battery, 89 | cores: navigator.hardwareConcurrency, 90 | // @ts-ignore 91 | memory: window.performance.memory || {}, 92 | timing: window.performance.timing || {}, 93 | navigation: window.performance.navigation || {} 94 | } 95 | } 96 | 97 | /** 98 | * Get connected audio/video devices connected to this device 99 | * @return {Promise} 100 | */ 101 | getDevices () { 102 | if (!window.navigator.mediaDevices || !window.navigator.mediaDevices.enumerateDevices) { 103 | return Promise.resolve([]) 104 | } 105 | 106 | return window.navigator.mediaDevices.enumerateDevices() 107 | .then((devices) => { 108 | let deviceArray = [] 109 | devices.forEach((device) => { 110 | let dev = device.toJSON() 111 | if (dev.label) { 112 | deviceArray.push(dev) 113 | } 114 | }) 115 | 116 | return deviceArray 117 | }) 118 | .catch(() => { 119 | return [] 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | let debug = false 4 | let realPeerConnection = null 5 | 6 | export function enableDebug (newValue) { 7 | debug = newValue 8 | } 9 | 10 | export function log (...options) { 11 | debug && console.log(...arguments) 12 | } 13 | 14 | export class PeerMetricsError extends Error { 15 | code: number 16 | } 17 | 18 | export function wrapPeerConnection(global) { 19 | if (global.RTCPeerConnection) { 20 | realPeerConnection = global.RTCPeerConnection 21 | let peerConnectionEventEmitter = new EventEmitter() 22 | 23 | // this is the ideal way but it causes problems with AdBlocker's wrapper 24 | // class RTCPeerConnection extends global.RTCPeerConnection { 25 | // constructor(parameters) { 26 | // super(parameters) 27 | // peerConnectionEventEmitter.emit('newRTCPeerconnection', this) 28 | // } 29 | // } 30 | // global.RTCPeerConnection = RTCPeerConnection 31 | 32 | let WrappedRTCPeerConnection = function (configuration, constraints) { 33 | let peerconnection = new realPeerConnection(configuration, constraints) 34 | peerConnectionEventEmitter.emit('newRTCPeerconnection', peerconnection) 35 | return peerconnection 36 | } 37 | WrappedRTCPeerConnection.prototype = realPeerConnection.prototype 38 | global.RTCPeerConnection = WrappedRTCPeerConnection 39 | 40 | return peerConnectionEventEmitter 41 | } 42 | 43 | return false 44 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Target latest version of ECMAScript. 4 | "target": "esnext", 5 | // Search under node_modules for non-relative imports. 6 | "moduleResolution": "node", 7 | // Process & infer types from .js files. 8 | "allowJs": true, 9 | // Don't emit; allow Babel to transform files. 10 | "noEmit": true, 11 | // Enable strictest settings like strictNullChecks & noImplicitAny. 12 | // "strict": true, 13 | // Import non-ES modules as default imports. 14 | "esModuleInterop": true, 15 | "lib": ["esnext", "dom"], 16 | // "dir": "dist", 17 | "declarationDir": "./dist", 18 | "declaration": true, 19 | // "declarationMap": true 20 | }, 21 | "include": [ 22 | "src" 23 | ] 24 | } --------------------------------------------------------------------------------